Skip to content

feat(e2e): evaluate scorecard gates, score families, and baseline comparison - #181

Open
ahrav wants to merge 1 commit into
stack/scorecard-03-evidence-loaderfrom
stack/scorecard-04-gates-families
Open

feat(e2e): evaluate scorecard gates, score families, and baseline comparison#181
ahrav wants to merge 1 commit into
stack/scorecard-03-evidence-loaderfrom
stack/scorecard-04-gates-families

Conversation

@ahrav

@ahrav ahrav commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Stack 4/6 of the release scorecard. Three pure stages over the evidence bundle.

  • gates.ts: GATE_SOURCES: Record<GateId, Extractor | null>; null yields not-observed / no-producing-lane. Five rows always, in fixed order, four states. A throwing extractor becomes errored / extractor-threw without copying the message. gate-injection-promoted reads metamorphic evidence only when every pre-registered canary scenario has an applied transform. No exemption path.
  • families.ts: five fixed score families, every slot emitted every run as measured or not-measured with a reason (lane-missing, lane-incomplete, producer-pending, ...). Derived rates are arithmetic over published counts (false-authoritative memory rate = summed matches / summed visible claims; duplicate rate = mean per-scenario duplicateRateAt50 over holdout). An unfinished paired-delta lane still contributes run-health and exclusion counts to reliability.
  • comparison.ts: pairs each current (endpoint, estimate family) with the baseline scorecard's row; delta = current − baseline point, interval = current paired interval shifted by the baseline point. Wholly adverse pairs are listed; outside-floor and family-missing rows block. No baseline → every delta no-baseline with its absolute value plus a limitation.

Testing

  • bun run test:scorecard-unit (39 passed at this head), incl. AE3/AE4/AE5/AE6/AE7 from the plan and permuted-input byte stability.

Stack

Depends on #180. Next: #182.

…parison

A release reviewer must see every gate and every metric slot on every
run, including the ones no lane can observe, or a missing probe reads as
a passing one. These stages therefore never shorten their output.

Gates come from one producer table with four row states: a gate without
a producing lane is not-observed rather than omitted, and a throwing
extractor becomes an errored row without copying its message. The
injection gate reads metamorphic evidence only under complete
pre-registered canary coverage.

Score families always emit their full slot list: a lane that is missing
or did not finish yields not-measured slots with a reason code, and an
unfinished paired-delta lane still contributes its run-health and
exclusion counts to reliability. Derived rates are arithmetic over counts
the lane published. Baseline comparison shifts each current paired
interval by the baseline point estimate, lists every wholly adverse pair,
and blocks on outside-floor and family-missing rows.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Essentials

Run ID: 6440cdb1-b7d5-4309-a75a-10ac51c25864

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 481a602992

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +16 to +17
const covered = new Set(lane.report.coverage.filter((entry) => entry.applied >= 1).map((entry) => entry.scenarioId));
if (!bundle.policy.injectionCanaryScenarioIds.every((scenarioId) => covered.has(scenarioId))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require successful canary evaluations before passing the gate

When an applicable canary pair errors before producing a scored observation, coverage.applied is still positive, and the loader can retain the metamorphic lane as present whenever tierInvalidReason is null. The producer itself treats non-scored entries and coverage violations as failures in metamorphic-eval/report.ts, but this check ignores both and reports passed with zero hits despite never successfully observing the canary. Require scored, violation-free coverage for each policy canary before treating zero hits as a pass.

Useful? React with 👍 / 👎.

Comment on lines +116 to +119
const rates = report.evidence.scenarios
.filter((scenario) => scenario.partition === "holdout" && scenario.mode === mode)
.map((scenario) => scenario.metrics.duplicateRateAt50)
.filter((rate): rate is number => rate !== null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude lane-restricted cases from duplicate-rate slots

For reports containing diagnostic lane-restricted cases, this path includes those scenarios in the release duplicate-rate metric, while the adjacent quality-metric path calls aggregateReportQuality, which explicitly removes cases whose CaseEvidence.laneRestricted is true. A high- or low-duplicate diagnostic case can therefore change only the duplicate-rate scorecard slots even though it is excluded from the report's gate aggregates; apply the same case-level exclusion before averaging.

Useful? React with 👍 / 👎.

Comment on lines +79 to +81
const currentFamilies = new Set(current.map((row) => row.familyId));
const missingFamilies = [...new Set(baseline.familyEstimates.map((row) => row.familyId))]
.filter((familyId) => !currentFamilies.has(familyId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid classifying unavailable current evidence as regressions

When the current paired-delta lane is missing or incomplete, buildScoreFamilies supplies an empty familyEstimates array; with a present baseline, this code consequently marks every historical family as a blocking family-missing regression. That turns an evidence-availability failure into fabricated adverse deltas and inflates blockingRegressionCount, even though the lane status already records the real failure. The comparison needs the current evidence status and should emit an appropriate limitation instead of missing-family rows unless a present run actually omitted the family.

Useful? React with 👍 / 👎.

export function familyEstimateRows(report: PairedDeltaReport): FamilyEstimateRow[] {
return report.body.analysis.endpoints
.flatMap((estimate) => estimate.families.map((family): FamilyEstimateRow => ({
endpoint: estimate.endpoint as FamilyEstimateRow["endpoint"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-primary analysis endpoints instead of casting them

A parsed paired-delta report can contain any DeltaEndpoint in body.analysis.endpoints because parseEndpointEstimates accepts DELTA_ENDPOINTS, and evidence conformance only requires that the configured primary endpoint appear somewhere. This cast therefore lets a validly parsed retrieval, formation, or representation endpoint escape as a FamilyEstimateRow, although the scorecard contract accepts only PRIMARY_ENDPOINTS; the generated scorecard will later fail its own parser. Filter or reject non-primary endpoints before constructing these rows rather than hiding the wider input type with a cast.

Useful? React with 👍 / 👎.

Comment on lines +79 to +81
const currentFamilies = new Set(current.map((row) => row.familyId));
const missingFamilies = [...new Set(baseline.familyEstimates.map((row) => row.familyId))]
.filter((familyId) => !currentFamilies.has(familyId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: missing-family detection dedupes by familyId alone, not (endpoint, familyId)

The docstring above says pairing happens on the (endpoint, estimate family) key, and deltas is indeed keyed that way via estimateKey. But missingFamilies here only checks currentFamilies.has(familyId) — ignoring endpoint.

Concretely: baseline has fam-x at both mc-on-vs-mc-off and mc-on-vs-compaction. If the current release drops fam-x at mc-on-vs-compaction but still has it at mc-on-vs-mc-off, currentFamilies still contains fam-x, so no family-missing row is emitted — and the dropped pair is also absent from deltas (which only iterates current). A real per-endpoint regression (evidence silently vanishing for one endpoint) slips past blockingRegressionCount/promotionAllowed undetected.

Consider deduping missing families by estimateKey(row) (endpoint + familyId) instead of familyId alone.

.filter((rate): rate is number => rate !== null);
return ratio(mean(rates), "no-holdout-queries");
}
const aggregate = gateAggregates(aggregateReportQuality(report)).find((entry) => entry.mode === mode);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency: full macro-aggregation recomputed per slot

retrievalReading calls aggregateReportQuality(report) + gateAggregates(...) from scratch on every invocation. laneSlot calls this reader once per retrieval MetricSlotId, and 8 of the 14 retrieval ids (recall-at-10/50, reciprocal-rank, ndcg-at-10 × explicit/automatic) hit this branch — so the full scenario-grouping/aggregation pass over the report reruns 8x per scorecard build for the same input. Since the result depends only on report (not id), consider hoisting it out — compute once in buildScoreFamilies/section and pass it into the reader, or memoize per-report.

return ratio(mean(rates), "no-holdout-queries");
}
const aggregate = gateAggregates(aggregateReportQuality(report)).find((entry) => entry.mode === mode);
if (aggregate === undefined) return { reason: "no-holdout-queries" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading reason code when metric is null but holdout queries exist

This only handles the case where no aggregate exists for the mode at all. But aggregate can be found while a specific metric on it (e.g. recallAt10) is nullmeanOrNull in metrics.ts returns null when no judged-relevant documents exist within the cutoff, even though queryCount/groupCount are non-zero. That path falls through to ratio(value, "no-holdout-queries") below (line 131), so it reports reason: "no-holdout-queries" even though holdout queries did exist — just none had relevant judgments. Worth a distinct reason code (e.g. no-relevant-judgments) so consumers of reason can tell the two failure modes apart.

}

export function hardGateFailures(rows: readonly GateRow[]): GateId[] {
return rows.filter((entry) => entry.status !== "passed").map((entry) => entry.gateId).sort();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated logic, currently unused outside its own test

This is the same computation report-contract.ts's deriveOutcome already does inline: input.gates.filter((row) => row.status !== "passed").map((row) => row.gateId).sort() (report-contract.ts:332), and parseScorecardReport's cross-field check re-derives it a third time (report-contract.ts:401). Right now hardGateFailures here isn't imported anywhere outside gates.test.ts.

If the definition of a "hard" gate failure ever changes (e.g. treating errored differently from not-observed), it's easy to update one copy and miss the others, producing a report whose safetyGates rows disagree with outcome.hardGateFailures. Worth having deriveOutcome call this exported helper instead of recomputing it, or dropping the export if it's not meant to be the shared implementation yet.

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review summary

Reviewed the three new scorecard modules (gates.ts, families.ts, comparison.ts) plus their tests. Overall the code is clean, well-typed, and the pure-function-with-tests structure makes it easy to reason about. Left 4 inline comments, most significant first:

  1. comparison.ts (bug)compareWithBaseline's missing-family detection dedupes by familyId alone instead of the (endpoint, familyId) pair the function is documented to key on. A family that drops out at one endpoint while surviving at another is never flagged as missing and never appears in deltas, so a real regression can silently bypass blockingRegressionCount/promotionAllowed.
  2. families.ts (efficiency)retrievalReading recomputes the full aggregateReportQuality + gateAggregates pass from scratch on every call; since 8 of the 14 retrieval slot ids hit this path, the same report gets re-aggregated 8x per scorecard build.
  3. families.ts (minor correctness) — when a mode's aggregate exists but a specific metric on it is null (no relevant judgments), the reason code reported is "no-holdout-queries", which is misleading since holdout queries did exist.
  4. gates.ts (duplication)hardGateFailures duplicates logic already inlined (twice) in report-contract.ts (deriveOutcome and the cross-field check in parseScorecardReport), and isn't currently called from anywhere outside its own test — worth consolidating so the "what counts as a hard failure" rule lives in one place.

No security concerns identified; this is internal eval tooling with no untrusted input surface in the diff.

@kilo-code-bot

kilo-code-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (7 files)
  • packages/e2e-tests/src/scorecard/comparison.test.ts
  • packages/e2e-tests/src/scorecard/comparison.ts
  • packages/e2e-tests/src/scorecard/families.test.ts
  • packages/e2e-tests/src/scorecard/families.ts
  • packages/e2e-tests/src/scorecard/gates.test.ts
  • packages/e2e-tests/src/scorecard/gates.ts
  • packages/e2e-tests/src/scorecard/test-fixtures.ts

Reviewed by gemini-3.7-flash · Input: 298.3K · Output: 16.5K · Cached: 620.5K

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant