Skip to content

fix(report): report eval, generate and pipeline use one paired arm summary (#229 P1-3) - #250

Open
napetrov wants to merge 2 commits into
intel:mainfrom
napetrov:fix/report-eval-generate-agree
Open

napetrov wants to merge 2 commits into
intel:mainfrom
napetrov:fix/report-eval-generate-agree

Conversation

@napetrov

Copy link
Copy Markdown
Contributor

Summary

Item #229 P1-3. report eval, report generate and the pipeline run summary read one eval file and printed different arm means and deltas. Each now calls one function, eval.stats.paired_summary.

eval/stats.py
  paired_summary(evaluations) -> PairedSummary      (complete pairs only, unrounded)
    ├── report/eval_report.py   stats()  -> Summary, Breakdown, Difficulty tables
    ├── report/generator.py     _compute_stats(), _arm_means()  -> Overall, topic, source
    └── orchestrator/pipeline.py step-5 summary (printed + steps.evaluation.summary)
-  report generate: with = mean(every scored context answer)
-                   without = mean(every scored baseline answer)
-                   delta = mean(stored per-record delta)
+  paired_summary: pairs = questions scored in BOTH arms
+                  with, without = means over pairs
+                  delta = with - without (= mean of paired differences)
+                  unpaired = total - pairs (counted, printed)

Rounding: values are unrounded inside PairedSummary and rounded once for display.

Paired definition (why). The delta is a per-question comparison, so both arm means must cover the same questions. The trust gate (gate/trust_gate.py) and the model-compare summaries already use complete pairs. Averaging each arm over all of its questions gives two means over different sets whose difference is not the printed delta (fixture: 73.3 − 50.0 printed as delta 10.0).

Evidence

Regression test: tests/test_report_arm_agreement.py::test_report_eval_and_report_generate_print_the_same_arm_means_and_delta. Fixture: q1 80/60, q2 40/40, q3 context 100 with no baseline (unpaired).

  • Before (old code, pytest tests/test_report_arm_agreement.py):
E       AssertionError: assert {'with': 73.3...'delta': 10.0} == {'with': 60.0...'delta': 10.0}
E         Differing items:
E         {'with': 73.3} != {'with': 60.0}
1 failed
  • Before, pipeline summary (old code, tests/test_pipeline_summary.py, extra one-arm question q4 context 100):
E         {'with_avg': 80.0} != {'with_avg': 70.0}
1 failed
  • After: the same tests pass; tests/test_report_arm_agreement.py and tests/test_pipeline_summary.py pass (6 passed).
  • Full suite on the rebased branch (on intel/main a2b787a): 8981 passed, 4 skipped. ruff check clean, mypy clean.

Consumers checked by grep: report/, orchestrator/pipeline.py, commands/benchmark.py (reads the pipeline summary with_avg), docs/quickstart.md (step 5 wording updated), docs/benchmarking-and-comparison.md, docs/methodology.md. report/model_compare.py already averages paired deltas and is unchanged.

Internal review: two rounds (author-applied lenses; the subagent review tool was not available in this session). Round 1 found the pipeline summary still used the per-arm formula; fixed in this PR with a test.

Merge Danger

Door: two-way. Code only; no stored format changes. The JSON stats block gains total_questions, paired_questions, unpaired_questions keys and the pipeline summary keys are unchanged. Reverting restores the old numbers.

Numbers change (say which, and how):

  • report generate (and the pipeline report): Overall context / baseline means, min/max, topic and source means, and the Count cell change for any eval file with an unpaired question (scored in one arm only). For a file the judge wrote, the delta does not change, because the stored delta already was the paired difference. Files where every question is paired do not change.
  • pipeline run summary (printed line, steps.evaluation.summary, and benchmark run averages read from it): same rule, complete pairs.
  • report eval: arm means print as before. The Delta is the unrounded difference rounded once, so it can move by 0.1 (rounding probe: about 22% of random cases differ by 0.1). The Count cell shows unpaired questions and a note explains the means.

Blast radius: report output and the pipeline summary for files with unpaired questions. Results recorded before this change keep their old numbers in their own reports; rerun report generate on an existing eval file to get the new ones. No judge, answer or eval-file writes change.

Out of scope, found and not changed: eval_report win-rate and degradation percentages divide by all questions while numerators are over pairs; generator top/bottom delta lists read the stored per-record delta (equal for judge-written files); Key Observations thresholds use rounded display values.

Refs #229

🤖 Generated with Claude Code

… and generate

report eval and report generate read the same eval file and printed different
arm means and deltas. report eval averaged the arms over the complete pairs;
report generate averaged each arm over every question scored in it, so the
context and baseline means could cover different questions and their
difference was not the delta printed beside them. The pipeline run summary
had the same per-arm averages.

All of them now call eval.stats.paired_summary, which takes the complete pairs
only: the arm means, min/max and delta, and the improved/degraded counts. The
delta is the difference of the paired means, which equals the mean of the
paired deltas. The trust gate and the significance test already used complete
pairs. Unpaired questions are counted (paired_questions, unpaired_questions in
the JSON; a note and the Count cell in the Markdown), so none drops silently.

report eval also rounded each arm mean before subtracting. The delta is now
the unrounded difference rounded once for display, so it can move by 0.1.

Refs intel#229 (P1-3)

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.76%. Comparing base (a2b787a) to head (ac50fc0).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #250      +/-   ##
==========================================
+ Coverage   92.74%   92.76%   +0.02%     
==========================================
  Files         254      254              
  Lines       32087    32115      +28     
  Branches     5344     5346       +2     
==========================================
+ Hits        29759    29792      +33     
+ Misses       1560     1558       -2     
+ Partials      768      765       -3     
Files with missing lines Coverage Δ
dokimi/dokimi/eval/stats.py 96.13% <100.00%> (+0.44%) ⬆️
dokimi/dokimi/orchestrator/pipeline.py 81.20% <100.00%> (-0.21%) ⬇️
dokimi/dokimi/report/eval_report.py 86.97% <100.00%> (+1.53%) ⬆️
dokimi/dokimi/report/generator.py 91.82% <100.00%> (+4.16%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@antonwolfy antonwolfy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review at ac50fc0. The change is right: paired_summary is one definition, the three consumers now agree on arm means and delta, and the changelog and quickstart match the code. Worth fixing before merge: a report page that still contradicts its own Overall table, and test gaps in the grouped tables.

What I ran, on Linux at ac50fc0:

  • make ci-local: 26 passed, 7 skipped, 2 failed. The two failures are the gitleaks download (the proxy returned 504) and the scan that needs it, not this PR; a direct gitleaks run over the PR's commit found no leaks. Test run and changed-line coverage steps pass.
  • Reproductions for the findings, with the LLM transport mocked.
  • A mutation run of 38 broken copies of the new code against the report, generator, stats, pipeline and record tests: 15 survive.

Findings outside the diff

  1. Win rate and degradation rate in Key Observations still divide by all questions. report/eval_report.py:473-476 uses all_stats["count"]. With q1 80/60, q2 40/50, q3 context-only and q4 unscored, the Summary row reads 4 (2 unpaired) | 60.0 | 55.0 | +5.0 and the Key Observations read Win rate: 25% ... (1/4); on the pairs it is 1/2. This predates the PR and the description lists it as out of scope, so it is not a blocker. Using all_stats["valid"] as the denominator would fix it.

Checked, no issues

  • Delta claim: eval/judge.py:399 writes delta = with - without, so the delta mean over pairs equals the old stored-delta mean for judge-written files, as the changelog says.
  • Bad scores: NaN, inf, bool, None, string and one-arm scores through paired_summary all end up unpaired, with no exception.
  • Pipeline consumers: commands/benchmark.py and commands/orchestrate.py handle a missing summary and still read with_avg, without_avg and delta_avg.
  • Repo rules: a changelog fragment is present, no schema or nav change is needed, and the new tests write only under tmp_path.

Comment on lines 313 to +316
f"| Min | {stats['with_docs']['min']} | {stats['without_docs']['min']} | {stats['delta']['min']:+.1f} |",
f"| Max | {stats['with_docs']['max']} | {stats['without_docs']['max']} | {stats['delta']['max']:+.1f} |",
"",
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Overall table and the top/bottom performer tables now cover different questions. The Max row above is over the complete pairs, but _top_performers and _bottom_performers (generator.py:170-194, outside this diff) still rank every question scored in the context arm, unpaired ones included.

With q1 80/60, q2 40/50 and q3 context 100 with no baseline, the Markdown report prints | Max | 80.0 | 60.0 | +20.0 |, then the note that 1 question is unpaired, and the top-performers table lists q3 | 100.0. A best answer above the stated maximum sends the reader to doubt the table. Before the PR the Max included q3, so the page was consistent.

Suggestion: restrict both lists to questions scored in both arms, as _top_deltas already does, or label them as covering all scored answers.

Comment on lines +58 to +70
def test_both_reports_say_which_questions_the_means_leave_out(tmp_path):
path = tmp_path / "eval" / "lib.json"
path.parent.mkdir()
path.write_text(json.dumps({"evaluations": EVALUATIONS}), encoding="utf-8")
md = tmp_path / "lib.md"
generate_report(str(path), str(md), str(tmp_path / "lib.qa.json"))
eval_md = md.read_text(encoding="utf-8")
assert "| **All** | 3 (1 unpaired) |" in eval_md
assert "over the 2 complete pairs; 1 unpaired (scored in one arm only) left out" in eval_md

data = {"evaluations": EVALUATIONS}
gen_md = ReportGenerator().generate_report(data, {"questions": []}, "markdown")
assert "over the 2 complete pairs; 1 unpaired (scored in one arm only) left out" in gen_md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The tests pin the Overall and All figures only; the grouped tables can go back to the old formula without a failure. Mutation check: in generator.py _arm_means, changing "with_avg": round(s.with_mean, 1) to round(s.with_mean + 7, 1) passes test_report_arm_agreement.py, test_record_readers.py and test_pipeline_summary.py (113 passed). These mutants also pass the related suites:

  • _arm_means using the old per-arm formula, or unpaired forced to 0 (topic and source rows);
  • _count_cell dropped from the cluster, source, Dynamic, Golden, by-type and by-difficulty rows (only the All row is asserted);
  • without_min and without_max swapped in paired_summary, because the fixture's baseline min and max are not asserted;
  • improvements and neutral swapped in eval_report.stats;
  • the delta computed from the rounded means in paired_summary, eval_report.stats or the pipeline, because every fixture mean is a round number.

Tests to add: render the JSON clusters and sources_summary for a fixture where q1 to q3 carry a persona and source_type, and assert with_avg == 60.0, delta_avg == 10.0, unpaired == 1; assert (1 unpaired) in the cluster and source rows of the Markdown; assert (s.without_min, s.without_max) == (40.0, 60.0); and add a pair such as context 60.04 / baseline 50.06, whose delta rounds to 10.0 while the difference of the rounded means gives 9.9.

The agreement fixture has arm means exact at one decimal, so putting back
the rounded-difference delta in report eval passed every test. Three pairs
with means 80.67/70.33 pin +10.3 in both reports, plus the Breakdown Total
row, the delta min/max and the improvement counts.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Oct 27, 2026

Copy link
Copy Markdown
Contributor
  • Ran: head merged onto current main; full test suite, ruff, mypy and bandit are clean. Also ran report generate, report eval and the dashboard aggregator on eval files that have unpaired questions.
  • The dashboard still averages each arm over every scored question. Input: 8 complete pairs plus 1 question scored only with context. Observed: report generate shows 63.8 / 57.5 / +6.2, and the dashboard snapshot shows 64.4 / 57.5 / +6.2 (on main they matched). Expected: ProductSnapshot and _compute_by_source go through paired_summary, keeping the per-arm fallback when nothing is paired. If not that, then the stats.py docstring ("Every report that prints arm means or a delta calls it") and the changelog should name the dashboard and the model-compare arm means as exceptions. The dashboard status band reads avg_with_docs, so this is your call.
  • Pushed: f063300 adds a test with pairs (80,70), (81,70), (81,71). It pins +10.3 in both reports, the Breakdown Total row, delta min/max and the improvement counts. Without it, putting back the rounded-difference delta passed every test.

@antonwolfy antonwolfy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review at f063300. The only change since my last review is a new test that pins the unrounded-mean delta (+10.3 against +10.4 from rounded means), and it does that well. Both of my earlier points are still open, so I would not merge yet.

What I ran, on Linux at f063300:

  • make ci-local: 28 passed, 0 failed, 7 skipped.
  • A mutation run of the grouped-table and paired_summary code against test_report_arm_agreement.py, test_pipeline_summary.py and test_record_readers.py: four of five mutants pass all three files.

Still open from my earlier review:

  • dokimi/dokimi/report/generator.py:316: _top_performers and _bottom_performers (generator.py:170-194, outside this diff) still rank every context-scored question, unpaired ones included, while the Max row above them is over the complete pairs. Unchanged since the first review.
  • dokimi/tests/test_report_arm_agreement.py:70: the new test closes the rounded-difference mutant only. These still pass all three suites: _arm_means with round(s.with_mean + 7, 1); _arm_means with unpaired forced to 0; _count_cell replaced by the plain count in the cluster and source rows; without_min and without_max swapped in paired_summary. The grouped tables (clusters, sources_summary) have no assertion at all.

@xaleryb's question on whether the dashboard should use paired_summary too, or be named as an exception, waits for the author.

This branch has not been deployed

No deployments
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.

4 participants