Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
97178c5
feat(drift): add DriftClass enum, QuarantineEntry, per-item id/class,…
jpr5 Jul 15, 2026
66f575d
feat(drift): add pure computeExitCode and route main() through it (qu…
jpr5 Jul 15, 2026
2dd48f7
feat(drift): add shared normalizeModelFamily primitive
jpr5 Jul 15, 2026
854c4e6
refactor(drift): reimplement normalizeVoiceModelFamily over shared pr…
jpr5 Jul 15, 2026
4a388f2
feat(drift): quarantine unmapped/unparseable-not-infra failures inste…
jpr5 Jul 15, 2026
5300f6b
test(drift): exit-code taxonomy + quarantine harness for computeExitC…
jpr5 Jul 15, 2026
17314eb
chore(drift): merge Wave A S1 (collector taxonomy + schema)
jpr5 Jul 15, 2026
a76fba4
chore(drift): merge Wave A S3 (normalize primitive)
jpr5 Jul 15, 2026
f021fe6
feat(drift): treat exit 5 as quarantine in drift-retry — no retry, pr…
jpr5 Jul 15, 2026
6ae96cd
feat(drift): handle exit 5 (quarantine) as non-aborting in fix-drift …
jpr5 Jul 15, 2026
5286c72
test(drift): add per-provider model-family registry (B4.1)
jpr5 Jul 15, 2026
210720f
feat(drift): emit quarantine ::error:: label for exit 5 in test-drift…
jpr5 Jul 15, 2026
b8e404d
test(drift): rewrite models.drift to family normalize+subtract (B4.2)
jpr5 Jul 15, 2026
3f39c8c
fix(drift): strip Anthropic contiguous -YYYYMMDD snapshot in normaliz…
jpr5 Jul 15, 2026
1ee0a82
test(drift): restore Anthropic dated-id incident-2 regression case
jpr5 Jul 15, 2026
d43e363
test(drift): add §6.1c builder/fixture cross-check, delete models-scr…
jpr5 Jul 15, 2026
5dd051d
chore(drift): merge Wave B S2 (exit-5 workflow threading)
jpr5 Jul 15, 2026
b011eda
chore(drift): merge Wave B S4 (/models W1 rewrite + Anthropic normalize)
jpr5 Jul 15, 2026
ca873e0
feat(drift): wire numeric HTTP status onto InfraError (C5.1)
jpr5 Jul 15, 2026
6884ae1
feat(drift): C5.2 — headline class + per-item detail in summarizeDrif…
jpr5 Jul 15, 2026
dcbf747
feat(drift): C5.3a — per-provider key-freshness preflight (stale-key)
jpr5 Jul 15, 2026
6047fa6
feat(drift): C5.3b — promote collector exit_code to job output + noti…
jpr5 Jul 15, 2026
70d457a
chore(drift): merge Wave C S5 (classified alerting + preflight + noti…
jpr5 Jul 15, 2026
6c84dc9
feat(drift): D6.2 — populate per-item id on ParsedDiff for delta keying
jpr5 Jul 15, 2026
13c66dc
feat: add delta-gating core (computeDelta + isBaseReportReusable)
jpr5 Jul 15, 2026
aff9a31
chore(drift): merge Wave D D6.1 (drift-delta new-in-head gating)
jpr5 Jul 15, 2026
61a2051
chore(drift): merge Wave D D6.2 (per-item id for delta keying)
jpr5 Jul 15, 2026
b28c9ec
ci(drift): add drift-live-pr job skeleton (base+head two-run)
jpr5 Jul 15, 2026
1db6516
ci(drift): wire delta gate — block new-in-head, advisory-annotate bas…
jpr5 Jul 15, 2026
62b449e
chore(drift): merge Wave D D6.3 (drift-live-pr delta-gating)
jpr5 Jul 15, 2026
8618afe
fix(drift): use printable separator in drift-delta keyOf (was NUL, re…
jpr5 Jul 15, 2026
8c7fd88
ci(drift): degrade drift-live-pr gracefully when provider keys absent
jpr5 Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/fix-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
if [ "$EXIT_CODE" -eq 2 ]; then
: # critical drift found, continue
elif [ "$EXIT_CODE" -eq 5 ]; then
: # quarantine: collector encountered unparseable output, continue without aborting
elif [ "$EXIT_CODE" -ne 0 ]; then
echo "::error::Collector script crashed with exit code $EXIT_CODE"
exit $EXIT_CODE
Expand Down
541 changes: 538 additions & 3 deletions .github/workflows/test-drift.yml

Large diffs are not rendered by default.

146 changes: 146 additions & 0 deletions scripts/drift-delta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Delta-gating core for the drift pipeline.
*
* A PR-scoped drift run produces a HEAD report; a same-UTC-day cached MAIN run
* produces a BASE report. The gate must only BLOCK on drift that is attributable
* to the diff under review — a drift already present on main (environmental /
* world drift) is the daily job's concern, NOT the PR's. This module reduces the
* two reports to a per-key delta:
*
* - block → keys NEW in head (absent from base): diff-attributable, hard fail
* - advisory → keys present in BOTH base and head: pre-existing / environmental
* - fixed → keys present in base but GONE in head: informational
*
* The block/advisory/fixed decision is driven ENTIRELY by key presence. The
* coarse `DriftClass` (critical/advisory/quarantine) is annotation only and NEVER
* enters the routing decision — a new-in-head *critical* still BLOCKS, and a
* both-present *critical* is still only ADVISORY. This is the #292 invariant: a
* real-drift/critical finding that is new in head must block regardless of class.
*
* Pure and side-effect-free — no filesystem, no network, no process state.
*/

import type { DriftClass, DriftReport } from "./drift-types.js";

/**
* A single failure identified by the delta layer.
*
* A failure is keyed by `provider` + per-item `id` (NOT `path`, which is a
* generic bucket like `"knownModels"` that would collapse N distinct model
* drifts into one key). `class` is carried through purely for annotation and
* never participates in routing.
*/
export interface DeltaKey {
/** Provider the failing entry belongs to (e.g. "anthropic"). */
provider: string;
/** Stable per-item key (e.g. a model id). Falls back to path when absent. */
id: string;
/** Coarse classification — annotation only, never routes block vs advisory. */
class?: DriftClass;
}

/** The delta between a base (main) report and a head (PR) report. */
export interface DeltaResult {
/** New-in-head failures — diff-attributable, hard fail. */
block: DeltaKey[];
/** Failures present in both base and head — pre-existing / environmental. */
advisory: DeltaKey[];
/** Failures present in base but gone in head — informational. */
fixed: DeltaKey[];
}

/**
* Build the canonical string key for a failure. Keys by provider + per-item id.
* When a diff has no `id` (legacy diffs), fall back to its `path` so it still
* participates in the delta rather than being dropped — this is a coarse bucket
* but preserves the block/advisory distinction across reports.
*/
function keyOf(provider: string, id: string): string {
return `${provider}::${id}`;
}

/**
* Flatten a report into a map of `key → DeltaKey`. When multiple diffs collapse
* to the same key, the last one wins for annotation purposes (the routing
* decision does not depend on which representative is retained).
*/
function indexReport(report: DriftReport): Map<string, DeltaKey> {
const index = new Map<string, DeltaKey>();
for (const entry of report.entries) {
for (const diff of entry.diffs) {
// `id` is the stable per-item key; `path` is the legacy fallback bucket.
const id = diff.id ?? diff.path;
const key = keyOf(entry.provider, id);
index.set(key, { provider: entry.provider, id, class: diff.class });
}
}
return index;
}

/**
* Compute the delta between a base report and a head report.
*
* Routing is by KEY PRESENCE only:
* - key in head but not base → block (new-in-head, diff-attributable)
* - key in both → advisory (pre-existing / environmental)
* - key in base but not head → fixed (informational)
*
* `DriftClass` NEVER enters this decision — a new-in-head critical blocks, a
* both-present critical is advisory. See the module docstring (#292 invariant).
*/
export function computeDelta(baseReport: DriftReport, headReport: DriftReport): DeltaResult {
const base = indexReport(baseReport);
const head = indexReport(headReport);

const block: DeltaKey[] = [];
const advisory: DeltaKey[] = [];
const fixed: DeltaKey[] = [];

for (const [key, headKey] of head) {
if (base.has(key)) {
advisory.push(headKey);
} else {
block.push(headKey);
}
}

for (const [key, baseKey] of base) {
if (!head.has(key)) {
fixed.push(baseKey);
}
}

return { block, advisory, fixed };
}

/**
* Known-good collector conclusions that make a cached base report trustworthy to
* reuse. Anything else (crash, quarantine, unknown) means the base is not a
* reliable baseline and a fresh live base run should be preferred.
*/
const REUSABLE_CONCLUSIONS: ReadonlySet<string> = new Set(["clean", "success"]);

/**
* Guard (O-2) for reusing a cached same-UTC-day main report as the delta base.
*
* A base report is reusable only when ALL of:
* 1. it has a non-empty `entries[]` (rejects empty / malformed cached JSON),
* 2. its collector `conclusion` is known-good (rejects crash / quarantine),
* 3. it was produced on the same UTC day (`sameUtcDay`) — a stale baseline
* would misattribute a day's worth of environmental drift to the PR.
*
* Anything else → not reusable → caller should run a fresh live base.
*/
export function isBaseReportReusable(
report: DriftReport | null | undefined,
conclusion: string | null | undefined,
sameUtcDay: boolean,
): boolean {
if (!report || !Array.isArray(report.entries) || report.entries.length === 0) {
return false;
}
if (!conclusion || !REUSABLE_CONCLUSIONS.has(conclusion)) {
return false;
}
return sameUtcDay === true;
}
Loading
Loading