From 7ff042f135ac5b22acf6bb4f0ad27817a25ea281 Mon Sep 17 00:00:00 2001 From: Lukas Babaliauskas Date: Tue, 13 Oct 2026 22:39:58 +0200 Subject: [PATCH 1/2] fix(policy): count failure categories per example, not per row tool_selection stamps TOOL_SELECTION_DRIFT on both its conformance and its divergence row, so one example that picked another tool read "Different tools chosen (2 examples)" in the narrative and the report's top causes. Count each label once per distinct (prompt, example), as INVARIANT_VIOLATION already was. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++++++ DOCS.md | 2 ++ llms-full.txt | 2 ++ src/evalshift_cli/analysis/policy.py | 12 +++++++++--- tests/unit/test_policy.py | 21 +++++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3766589..c02349c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Failure categories count examples, not scored rows. `tool_selection` stamps + `TOOL_SELECTION_DRIFT` on both its conformance and its divergence row, so one example + that picked another tool read "Different tools chosen (2 examples)" in the narrative + and 2 in the report's top regression causes. Each label is now counted once per + distinct example, as `INVARIANT_VIOLATION` already was. + ## [1.4.2] - 2026-10-12 ### Fixed diff --git a/DOCS.md b/DOCS.md index d0d9f41..73f61fe 100644 --- a/DOCS.md +++ b/DOCS.md @@ -533,6 +533,8 @@ Pairwise A/B comparison per criterion: the judge sees the two outputs anonymised Regressions carry machine-readable labels that the report and hosted diff group by: `FORMAT_FAILURE`, `SEMANTIC_REGRESSION`, `TOOL_SELECTION_DRIFT`, `TOOL_GROUND_TRUTH_MISS` (both models missed the same recorded tool ground truth — a broken-harness signal, not a migration finding; see [Migration policy and CI gating](#migration-policy-and-ci-gating)), `ARGUMENT_VALUE_DRIFT`, `TOOL_TRACE_STRUCTURE_DRIFT`, `TOOL_ORDER_DRIFT`, `DANGEROUS_ACTION_DRIFT`, `MISSING_VERIFICATION_STEP`, `UNNECESSARY_TOOL_CALL`, `REFUSAL_REGRESSION`, and `INVARIANT_VIOLATION` (the target broke a hand-written [trace rule](#trace-invariants), stamped whatever the source did). That is the complete set — every label the evaluators emit is declared in `evaluators/failures.py`. The machine labels live in `scores.jsonl`, `report.json` and the bundle; every rendered surface (the HTML report, decision prose, the run narrative) shows the plain-language display name instead — `TOOL_SELECTION_DRIFT` renders as "Different tools chosen" — with the mapping declared beside the labels in `evaluators/failures.py`. +The decision's `failure_categories` (the report's **Top regression causes** panel, and the narrative's "Most common failure … (N examples)") count each label once per **distinct example**, not per scored row: `tool_selection` stamps `TOOL_SELECTION_DRIFT` on both its conformance and its divergence row, so one example that picked another tool is one, not two. `INVARIANT_VIOLATION` is further narrowed to blocking entries — see [Trace invariants](#trace-invariants). + `ARGUMENT_VALUE_DRIFT` counts **regressions**: it is stamped only when the target scored below the source. Under `against: expected` both models can miss the same recorded expectation by the same margin — a zero delta, and a fact about your ground truth rather than a migration defect, already reported as such. Policy budgets are unaffected by the label: `max_tool_argument_drift` counts calls whose *target* score fell below `tool_argument_drift_floor`. ### Cost diff --git a/llms-full.txt b/llms-full.txt index 708f239..c3b7dee 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -228,6 +228,8 @@ evalshift report RUN_ID [-c CONFIG] [--open] [--insights/--no-insights (default as their labels (max_tool_divergence -> "Tool-selection divergence"; map in analysis/policy.py BUDGET_LABELS) with the yaml field kept as a secondary code hint in the budget-failures table only. report.json and the bundle keep the machine labels. + decision.failure_categories counts each label per distinct (prompt, example), never per + row (tool_selection stamps TOOL_SELECTION_DRIFT on its conformance and divergence rows). evalshift compare [run flags] [--gate SEVS] [--policy-gate] [--open] [--push] [--insights/--no-insights (default on)] doctor -> run -> evaluate -> analyze -> report under one live progress display, for one diff --git a/src/evalshift_cli/analysis/policy.py b/src/evalshift_cli/analysis/policy.py index 7370967..b0526fa 100644 --- a/src/evalshift_cli/analysis/policy.py +++ b/src/evalshift_cli/analysis/policy.py @@ -1534,7 +1534,12 @@ def _blocking_regressions(comparisons: list[ComparisonResult]) -> list[BlockingR def _failure_categories(records: list[EvalRecord]) -> list[FailureCategoryCount]: - """Count each failure category over the rows that carry it. + """Count each failure category over the distinct examples that carry it. + + Per example, not per row: one example can write several rows carrying the + same category -- ``tool_selection`` flags ``TOOL_SELECTION_DRIFT`` on its + conformance *and* its divergence row -- and the narrative built on these + counts reads them as "(N examples)". ``INVARIANT_VIOLATION`` is the exception: every ``trace_invariants`` row carries it when the target broke a rule, advisory or not, source too or @@ -1544,13 +1549,14 @@ def _failure_categories(records: list[EvalRecord]) -> list[FailureCategoryCount] :func:`_invariant_violation_counts` over the blocking rows, so the two can never disagree. """ - counter: Counter[str] = Counter() + examples: dict[str, set[tuple[str, str]]] = {} for r in records: raw = r.metadata.get("failure_categories", []) if isinstance(raw, list): for item in raw: if isinstance(item, str) and item and item != INVARIANT_VIOLATION: - counter[item] += 1 + examples.setdefault(item, set()).add((r.prompt_id, r.example_id)) + counter: Counter[str] = Counter({category: len(ids) for category, ids in examples.items()}) violated, _ = _invariant_violation_counts([r for r in records if r.blocking]) if violated: counter[INVARIANT_VIOLATION] = violated diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 6bea8d6..8935e1b 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -3016,6 +3016,27 @@ def test_advisory_breaks_alone_write_no_category(self) -> None: assert INVARIANT_VIOLATION not in {c.category for c in decision.failure_categories} +class TestFailureCategoryCountsExamples: + def test_one_example_flagged_by_two_axes_counts_once(self) -> None: + """The narrative reads "Different tools chosen (N examples)". + + ``tool_selection`` writes a conformance *and* a divergence row per + example, and both carry ``TOOL_SELECTION_DRIFT`` when the target picked + another tool -- counting rows read "2 examples" for one example. + """ + decision = _decide( + [ + _conformance_record("e1", 1.0, 0.0), + _divergence_record("e1", 0.0), + _divergence_record("e2", 0.0), + _conformance_record("e3", 1.0, 1.0), + _divergence_record("e3", 1.0), + ], + ) + counts = {c.category: c.count for c in decision.failure_categories} + assert counts[TOOL_SELECTION_DRIFT] == 2 + + _SKIPPED_SEMANTIC = SkippedEvaluator( evaluator_name="semantic.cosine", kind="semantic", From 2142d0f34de19ff552ccedc2d13965e0dd9bcc4c Mon Sep 17 00:00:00 2001 From: Lukas Babaliauskas Date: Tue, 13 Oct 2026 22:56:58 +0200 Subject: [PATCH 2/2] fix(insights): key the narrative cache on the facts it describes insights.json was keyed on state.config_hash, fixed when the run is created, while evaluate and analyze re-read evalshift.yaml every time. Loosening a budget and re-running analyze + report rendered a PASS verdict beside a cached "FAIL under the configured policy" paragraph. Fold a digest of the FACTS block into the key so a change to scores, analysis or the persisted decision regenerates it; a plain re-render still hits. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++ DOCS.md | 4 +-- docs/configuration.md | 7 +++-- llms-full.txt | 9 ++++-- src/evalshift_cli/insights/stage.py | 45 ++++++++++++++++++++++------- tests/unit/test_insights_stage.py | 30 +++++++++++++++++++ 6 files changed, 85 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c02349c..be5474b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that picked another tool read "Different tools chosen (2 examples)" in the narrative and 2 in the report's top regression causes. Each label is now counted once per distinct example, as `INVARIANT_VIOLATION` already was. +- The cached run narrative no longer outlives the figures it describes. `insights.json` + was keyed on the run's `config_hash`, which is fixed when the run is created, while + `evaluate` and `analyze` re-read `evalshift.yaml` every time: loosening a budget and + re-running `analyze` + `report` rendered a PASS verdict beside a cached "FAIL under the + configured policy" paragraph. The key now also covers the narrative's facts (scores, + analysis and the persisted decision), so any change to them regenerates it on the next + `report`; a plain re-render still reuses it. Existing caches miss once and regenerate. ## [1.4.2] - 2026-10-12 diff --git a/DOCS.md b/DOCS.md index 73f61fe..7035ba4 100644 --- a/DOCS.md +++ b/DOCS.md @@ -785,14 +785,14 @@ CI wiring (on `analyze` and `compare`): **A FAIL's cause is read off the facts, not inferred.** Every gating budget, overall and slice rows alike, enters the prompt with its status beside its limit — `budget "