Skip to content

feat: benchmaxxing run entry point for the stage runners - #251

Merged
sebasmos merged 0 commit into
mainfrom
feat/run-cli
Jul 29, 2026
Merged

feat: benchmaxxing run entry point for the stage runners#251
sebasmos merged 0 commit into
mainfrom
feat/run-cli

Conversation

@duckyquang

Copy link
Copy Markdown
Member

Summary

Added benchmaxxing run, one command that turns a config plus a manifest into a run directory:

benchmaxxing run --stage {pilot,solo,overlap,cascade} --manifest m.csv --config cfg.json --out runs/x
benchmaxxing run --stage cascade --manifest m.csv --out runs/x --dry-run
benchmaxxing run --stage solo --manifest m.csv --out runs/x --backend mock

The stage logic stays in experiments.py. The new benchmaxxing/runner.py is just the wiring: resolve the config and roster, load cases, inject cues, call the matching runner, write results.json, summary.md, config.json and run_manifest.json. The cascade stage also saves every shared and isolated transcript under --out/transcripts/ through the existing RunStore.

Two small adapters are the only new behaviour, and they are both plumbing. SoloAgent renders a twin payload as a lettered MCQ and parses the reply back into an option string (solo_evaluate prefers a run(payload) backend, which is the hook that lets the parse see the twin's own options). CommitteeAgent does the same for a blackboard turn with the visible board appended. I deliberately did not touch prompt design, that's #107, so these render the same shape the first real MedQA runs used.

Result

All four stages run offline with --backend mock on a 4-case synthetic manifest:

  • pilot: flip rate 0.33 over 12 twins, cues bite
  • solo: clean accuracy 1.00, contaminated 0.67, shortcut reliance 0.33
  • overlap: adds the within vs cross lineage overlap and its permutation p-value
  • cascade (3-model roster): shared adoption 1.00 vs isolated 0.00, so the shared-vs-isolated contrast shows up end to end with no key
  • --dry-run on the cascade prints models, lineages, 4 cases / 12 twins, 64 estimated calls, and writes nothing

ruff check . clean, pytest -q 594 passed. New tests drive each stage through the CLI and assert the four output files exist and parse.

Notes

Three things worth flagging:

  • Lineage is inferred from the model id (gemini-2.5-flash to gemini, qwen2.5-72b-instruct to qwen) because Config has no lineage field. It's what the overlap arm splits on, so if you'd rather have it explicit in the config, say so and I'll add the field.
  • I taught load_config to read .json as well as .yaml. YAML needs the optional config extra, and it felt wrong for the main entry point to require an extra just to pass a roster. YAML still works exactly as before.
  • extract.parse_mcq_choice only takes the letter branch when the options themselves are single letters, so with real MedQA-style options a reply of "The answer is B" comes back unparseable. I work around it by parsing against the letters first and falling back to the option text. That's the unification the TODO in extract.py (Answer extraction and abstention normalization for real model outputs #102) is about, so I left the parser alone.

The imaging lane raises with a pointer to experiments/imaging rather than pretending: it needs the images loaded and injected as arrays, which is a bigger piece of work.

Closes #101

@sebasmos

sebasmos commented Jul 23, 2026

Copy link
Copy Markdown
Member

Approving for now, solid plumbing. But let's shift effort from CLI and infra to actual experiments on real data. Park further runner polish until the paper's runs are in.

@duckyquang

Copy link
Copy Markdown
Member Author

Status update on this one, since a stack has grown on top of it.

A real-model run is still outstanding. Everything here has been exercised against --backend mock only. I don't have a GEMINI_API_KEY in this environment, so I can't produce the "unchanged against a real Gemini roster" half of the acceptance criteria. Whoever has a key can settle it cheaply:

export GEMINI_API_KEY=...
benchmaxxing run --stage pilot --manifest <medqa manifest> --limit 20 --out runs/first-real

That is roughly 40 calls on flash. If it produces a sane summary.md this PR is done; if the parse or the prompt shape is off, better to find out on 20 cases than on 200.

Follow-ups now stacked on this branch, in merge order:

Each is scoped to one issue with its base set to the previous branch, so they review bottom-up. If you'd rather have fewer PRs I can squash the later ones together, just say which grouping you want.

@sebasmos sebasmos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@duckyquang you have access to the API, could you please revise?

@duckyquang

Copy link
Copy Markdown
Member Author

@duckyquang you have access to the API, could you please revise?

On it, I'm currently on a road trip right now. Will get back by tonight.

@maximinl maximinl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Peer note on the stack base.

Wiring looks sound: stage logic stays in experiments.py, runner.py is adapters + I/O, cascade dumps shared/isolated transcripts under --out/transcripts/ (exactly what offline deference reanalysis needs).

Two things still blocking a clean merge from my side, both already flagged:

  1. Seb’s real-model pilot (GEMINI_API_KEY, ~20 cases) — mock-only acceptance is not enough for the parse/prompt path.
  2. Everything stacked on this (#252#253#257#258#260#261#262) should wait; review those bottom-up after this lands.

No new code objections beyond the outstanding real run.

@armaanvgrewal

Copy link
Copy Markdown
Collaborator

@duckyquang @maximinl

FYI, per @sebasmos, #251 should land before #239 since both touch benchmaxxing/cli.py. I’ll wait on #239 until this is merged.

@Agastya191 Agastya191 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Really clean wiring in runner.py, and driving all four stages through the CLI on the mock backend is exactly the test net this entry point needed. The one thing I'd settle before trusting the overlap arm is _lineage: it splits the family off the id at the first -/_//, so a bare gemini-2.5-flash resolves right but an org-prefixed meta-llama/llama-3.1-70b resolves to meta (and mistralai/... to mistralai), landing it in a different lineage than a plain llama-3.1-70b. Because run_holes_test splits the within- vs cross-lineage overlap on exactly that value, a roster that mixes id conventions computes a plausible but wrong cross-lineage number with nothing raising. You floated making it explicit in the config and I'd take that option: a per-model lineage on Config that falls back to _lineage when unset keeps the convenience without letting a string silently decide the split.

@duckyquang

Copy link
Copy Markdown
Member Author

@Agastya191 took the option you floated. Rather than patch _lineage in place, the fix lives one PR up in #257: lineage is declared in the config and only falls back to the id heuristic when unset. On top of that I added a run-time warning (#257, 3ed12d9) so an org-prefixed id that gets inferred — the exact meta-llama/llama-3.1-70bmeta case you hit — is loud at run time instead of only turning up in the manifest afterwards. So a mixed-convention roster can't quietly compute the wrong within/cross-lineage split anymore.

@sebasmos the real-model pilot is in progress (the ~20-case Gemini run), so the parse/prompt path gets exercised on a real backend, not just mock. Will post once it's done.

@maximinl thanks. On your two blockers: the real pilot is running now, and I'm going through the stack bottom-up (#252#262) so nothing above this lands ahead of it.

@sebasmos sebasmos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving and merging. The pilot is done and committed: 20 MedQA cases, 60 twins, 120 real Gemini calls at temperature 0, and it reproduces the paper's solo rates through a different runner (longest_option 0.05 and option_order 0.05 exact, lexical_overlap 0.10 against 0.09, overall 0.0667 against 0.0633). That satisfies the real-data ask I had been holding this on. Three tests pin the artifact as a real run rather than a mock.

@sebasmos
sebasmos merged this pull request into main Jul 29, 2026
@sebasmos
sebasmos deleted the feat/run-cli branch July 29, 2026 21:56
sebasmos added a commit that referenced this pull request Aug 4, 2026
* feat: benchmaxxing run entry point for the stage runners

* results(#251): real MedQA pilot through the run CLI, and it reproduces the paper's solo rates

This PR was held under the real-data rule: it shipped the `benchmaxxing run` entry point with no real run
behind it, and I had asked directly for the pilot. Ran it: 20 MedQA-USMLE cases, 60 twins, four cue types,
gemini-2.5-flash at temperature 0, 120 real API calls.

The result is a clean independent replication of the paper's MedQA solo susceptibility, through a different
runner than the one that produced it:

  cue              committed (reproduce.py, 300 twins)   this pilot (run CLI, 60 twins)
  longest_option   0.05                                  0.05
  option_order     0.05                                  0.05
  lexical_overlap  0.09                                  0.10
  overall          0.0633                                0.0667

Two of three shared cues match exactly, the third is within 0.01, on a disjoint sample. That is a stronger
check than the CLI merely running, because it says the entry point and reproduce.py agree on the quantity
the paper reports.

Committed the run directory with its provenance manifest (model ids, seed, cue-set and prompt versions,
library versions) plus three tests: the artifact must be a real Gemini run and not a mock, it must land on
the committed solo rates, and the cues must actually bite, so the check cannot pass vacuously.

---------

Co-authored-by: sebasmos <sebasticajas@gmail.com>
sebasmos added a commit that referenced this pull request Aug 4, 2026
* feat: benchmaxxing run entry point for the stage runners

* feat: bootstrap CIs and effect sizes in the run summary

* feat: correct the whole family of p-values in the run report

* feat: declare model lineage in the config instead of inferring it

* cascade significance: paired permutation on the adoption gap, not a McNemar sign test

Agastya's review: _cascade_tests ran the continuous shared/isolated adoption rates through
mcnemar by counting only each pair's direction, collapsing it to a sign test that drops ties
and ignores magnitude. Over four discordant cases the smallest two-sided p is 0.125, so a
unanimous planted-answer effect could never be flagged even when the shared/isolated bootstrap
CIs printed right above it are cleanly separated -- the two signals then contradict.

Add stats.paired_permutation_test (seeded sign-flip over the per-case differences, exact by
enumeration for small n, sampled otherwise) and test the same shared-minus-isolated gap the
adoption_delta CI is built on, so the p-value and the interval answer one question. Thread the
run seed through significance_tests for reproducibility.

* lineage: warn when an org-prefixed id is inferred, not just recorded

Agastya's review: the bare-id fallback routes through _lineage, which splits on the first
separator, so an org-namespaced id like 'meta-llama/llama-3.1-70b' resolves to 'meta' rather than
'llama'. A mixed-convention roster then gets a wrong within- vs cross-lineage partition in
run_holes_test and the run completes clean, with lineage_source: inferred in the manifest as the
only trace after the fact.

Keep the heuristic as the documented fallback but make the risky case loud: model_specs warns when
a lineage is inferred (not declared) from an id carrying a '/'. Declaring `lineage` in the config
-- the resolution from #251 -- silences it. Failure is now at run time, not a manifest post-mortem.

* solo CI: resample over cases, not twins

Agastya's review: the solo lane resampled flip_rate/shortcut_reliance over the flat per-twin
record list, but _build_twins emits one twin per (case, cue_type), so a case's several cue-twins
sit in that array and bootstrap_ci drew them iid. Same-case twins share the case's difficulty, so
counting them as independent understates the uncertainty exactly the way pooling models would --
the 95% CI came out tighter than the case-level evidence supports.

Add stats.cluster_bootstrap_ci (draws whole clusters, pools their observations) and resample the
solo lane by case_id, so the interval answers "does this hold on new cases", which is the claim
the arm makes. The point estimate is unchanged and, since every cluster is a singleton for
one-twin-per-case data, the pinned intervals are untouched; only real multi-cue cases widen.

---------

Co-authored-by: sebasmos <sebasticajas@gmail.com>
sebasmos added a commit that referenced this pull request Aug 4, 2026
* feat: benchmaxxing run entry point for the stage runners

* feat: bootstrap CIs and effect sizes in the run summary

* feat: correct the whole family of p-values in the run report

* feat: declare model lineage in the config instead of inferring it

* feat: noise-floor control and floor-adjusted flip rate

* cascade significance: paired permutation on the adoption gap, not a McNemar sign test

Agastya's review: _cascade_tests ran the continuous shared/isolated adoption rates through
mcnemar by counting only each pair's direction, collapsing it to a sign test that drops ties
and ignores magnitude. Over four discordant cases the smallest two-sided p is 0.125, so a
unanimous planted-answer effect could never be flagged even when the shared/isolated bootstrap
CIs printed right above it are cleanly separated -- the two signals then contradict.

Add stats.paired_permutation_test (seeded sign-flip over the per-case differences, exact by
enumeration for small n, sampled otherwise) and test the same shared-minus-isolated gap the
adoption_delta CI is built on, so the p-value and the interval answer one question. Thread the
run seed through significance_tests for reproducibility.

* lineage: warn when an org-prefixed id is inferred, not just recorded

Agastya's review: the bare-id fallback routes through _lineage, which splits on the first
separator, so an org-namespaced id like 'meta-llama/llama-3.1-70b' resolves to 'meta' rather than
'llama'. A mixed-convention roster then gets a wrong within- vs cross-lineage partition in
run_holes_test and the run completes clean, with lineage_source: inferred in the manifest as the
only trace after the fact.

Keep the heuristic as the documented fallback but make the risky case loud: model_specs warns when
a lineage is inferred (not declared) from an id carrying a '/'. Declaring `lineage` in the config
-- the resolution from #251 -- silences it. Failure is now at run time, not a manifest post-mortem.

* solo CI: resample over cases, not twins

Agastya's review: the solo lane resampled flip_rate/shortcut_reliance over the flat per-twin
record list, but _build_twins emits one twin per (case, cue_type), so a case's several cue-twins
sit in that array and bootstrap_ci drew them iid. Same-case twins share the case's difficulty, so
counting them as independent understates the uncertainty exactly the way pooling models would --
the 95% CI came out tighter than the case-level evidence supports.

Add stats.cluster_bootstrap_ci (draws whole clusters, pools their observations) and resample the
solo lane by case_id, so the interval answers "does this hold on new cases", which is the claim
the arm makes. The point estimate is unchanged and, since every cluster is a singleton for
one-twin-per-case data, the pinned intervals are untouched; only real multi-cue cases widen.

* noise floor: match each re-ask to its record by key, not by position

Agastya's review: noise_floor_pass zipped a freshly rebuilt _twins(...) against the records
run_solo_baselines produced from its own separate _build_twins(...), guarded only by a length
check. The two builders are identical today, but the length check catches a count mismatch and not
a reordering -- if they ever drift (a limit, a filter, a changed skip, a reorder), the floor would
subtract the wrong twin's noise from each flip and the headline floor-adjusted rate would be
quietly wrong with nothing failing.

Records already carry case_id and cue_type, so key the twins by (case_id, cue_type) and look each
record up instead of trusting the two builders to stay byte-for-byte in lockstep. A missing key
raises loudly. per_twin stays aligned with records, so the paired floor-adjusted resampling is
unchanged.

---------

Co-authored-by: sebasmos <sebasticajas@gmail.com>
sebasmos added a commit that referenced this pull request Aug 4, 2026
* feat: benchmaxxing run entry point for the stage runners

* feat: bootstrap CIs and effect sizes in the run summary

* feat: correct the whole family of p-values in the run report

* feat: declare model lineage in the config instead of inferring it

* feat: noise-floor control and floor-adjusted flip rate

* feat: run the imaging lane through the run entry point

* cascade significance: paired permutation on the adoption gap, not a McNemar sign test

Agastya's review: _cascade_tests ran the continuous shared/isolated adoption rates through
mcnemar by counting only each pair's direction, collapsing it to a sign test that drops ties
and ignores magnitude. Over four discordant cases the smallest two-sided p is 0.125, so a
unanimous planted-answer effect could never be flagged even when the shared/isolated bootstrap
CIs printed right above it are cleanly separated -- the two signals then contradict.

Add stats.paired_permutation_test (seeded sign-flip over the per-case differences, exact by
enumeration for small n, sampled otherwise) and test the same shared-minus-isolated gap the
adoption_delta CI is built on, so the p-value and the interval answer one question. Thread the
run seed through significance_tests for reproducibility.

* imaging lane: count dropped findings on multi-finding cases

Agastya's review: _finding did case.label.split('|')[0], so a multi-finding NIH case
(cardiomegaly|effusion) was only ever asked about its first finding and the rest were
dropped with no entry in skipped_cases -- the 'Skipped cases:' line then reported full
coverage the run never had, the exact failure the skip accounting exists to catch.

v1 is one question per case, so keep that policy but make it honest: _positive_findings
lists the real findings, image_twins asks about the first and adds len(rest) to a new
dropped_findings bucket. The skip line already renders any nonzero bucket.

* lineage: warn when an org-prefixed id is inferred, not just recorded

Agastya's review: the bare-id fallback routes through _lineage, which splits on the first
separator, so an org-namespaced id like 'meta-llama/llama-3.1-70b' resolves to 'meta' rather than
'llama'. A mixed-convention roster then gets a wrong within- vs cross-lineage partition in
run_holes_test and the run completes clean, with lineage_source: inferred in the manifest as the
only trace after the fact.

Keep the heuristic as the documented fallback but make the risky case loud: model_specs warns when
a lineage is inferred (not declared) from an id carrying a '/'. Declaring `lineage` in the config
-- the resolution from #251 -- silences it. Failure is now at run time, not a manifest post-mortem.

* solo CI: resample over cases, not twins

Agastya's review: the solo lane resampled flip_rate/shortcut_reliance over the flat per-twin
record list, but _build_twins emits one twin per (case, cue_type), so a case's several cue-twins
sit in that array and bootstrap_ci drew them iid. Same-case twins share the case's difficulty, so
counting them as independent understates the uncertainty exactly the way pooling models would --
the 95% CI came out tighter than the case-level evidence supports.

Add stats.cluster_bootstrap_ci (draws whole clusters, pools their observations) and resample the
solo lane by case_id, so the interval answers "does this hold on new cases", which is the claim
the arm makes. The point estimate is unchanged and, since every cluster is a singleton for
one-twin-per-case data, the pinned intervals are untouched; only real multi-cue cases widen.

* noise floor: match each re-ask to its record by key, not by position

Agastya's review: noise_floor_pass zipped a freshly rebuilt _twins(...) against the records
run_solo_baselines produced from its own separate _build_twins(...), guarded only by a length
check. The two builders are identical today, but the length check catches a count mismatch and not
a reordering -- if they ever drift (a limit, a filter, a changed skip, a reorder), the floor would
subtract the wrong twin's noise from each flip and the headline floor-adjusted rate would be
quietly wrong with nothing failing.

Records already carry case_id and cue_type, so key the twins by (case_id, cue_type) and look each
record up instead of trusting the two builders to stay byte-for-byte in lockstep. A missing key
raises loudly. per_twin stays aligned with records, so the paired floor-adjusted resampling is
unchanged.

---------

Co-authored-by: sebasmos <sebasticajas@gmail.com>
sebasmos added a commit that referenced this pull request Aug 4, 2026
* feat: benchmaxxing run entry point for the stage runners

* feat: bootstrap CIs and effect sizes in the run summary

* feat: correct the whole family of p-values in the run report

* feat: declare model lineage in the config instead of inferring it

* feat: noise-floor control and floor-adjusted flip rate

* feat: run the imaging lane through the run entry point

* feat: hard-case selector for the cascade arm

* cascade significance: paired permutation on the adoption gap, not a McNemar sign test

Agastya's review: _cascade_tests ran the continuous shared/isolated adoption rates through
mcnemar by counting only each pair's direction, collapsing it to a sign test that drops ties
and ignores magnitude. Over four discordant cases the smallest two-sided p is 0.125, so a
unanimous planted-answer effect could never be flagged even when the shared/isolated bootstrap
CIs printed right above it are cleanly separated -- the two signals then contradict.

Add stats.paired_permutation_test (seeded sign-flip over the per-case differences, exact by
enumeration for small n, sampled otherwise) and test the same shared-minus-isolated gap the
adoption_delta CI is built on, so the p-value and the interval answer one question. Thread the
run seed through significance_tests for reproducibility.

* imaging lane: count dropped findings on multi-finding cases

Agastya's review: _finding did case.label.split('|')[0], so a multi-finding NIH case
(cardiomegaly|effusion) was only ever asked about its first finding and the rest were
dropped with no entry in skipped_cases -- the 'Skipped cases:' line then reported full
coverage the run never had, the exact failure the skip accounting exists to catch.

v1 is one question per case, so keep that policy but make it honest: _positive_findings
lists the real findings, image_twins asks about the first and adds len(rest) to a new
dropped_findings bucket. The skip line already renders any nonzero bucket.

* lineage: warn when an org-prefixed id is inferred, not just recorded

Agastya's review: the bare-id fallback routes through _lineage, which splits on the first
separator, so an org-namespaced id like 'meta-llama/llama-3.1-70b' resolves to 'meta' rather than
'llama'. A mixed-convention roster then gets a wrong within- vs cross-lineage partition in
run_holes_test and the run completes clean, with lineage_source: inferred in the manifest as the
only trace after the fact.

Keep the heuristic as the documented fallback but make the risky case loud: model_specs warns when
a lineage is inferred (not declared) from an id carrying a '/'. Declaring `lineage` in the config
-- the resolution from #251 -- silences it. Failure is now at run time, not a manifest post-mortem.

* solo CI: resample over cases, not twins

Agastya's review: the solo lane resampled flip_rate/shortcut_reliance over the flat per-twin
record list, but _build_twins emits one twin per (case, cue_type), so a case's several cue-twins
sit in that array and bootstrap_ci drew them iid. Same-case twins share the case's difficulty, so
counting them as independent understates the uncertainty exactly the way pooling models would --
the 95% CI came out tighter than the case-level evidence supports.

Add stats.cluster_bootstrap_ci (draws whole clusters, pools their observations) and resample the
solo lane by case_id, so the interval answers "does this hold on new cases", which is the claim
the arm makes. The point estimate is unchanged and, since every cluster is a singleton for
one-twin-per-case data, the pinned intervals are untouched; only real multi-cue cases widen.

* hard-case selector: guard --hard-k the way the library guards k

Agastya's review: _select_hard_cases reimplemented the k cut as ranked[:args.hard_k] instead of
routing through select_uncertain_cases, skipping the non-negative guard the library put there for
exactly this. So --hard-k -3 silently kept every case except the three easiest -- the "restricted"
run quietly became nearly the full set at full API cost -- and --hard-k 0 exited with the
misleading "records and the manifest have to come from the same dataset" error instead of naming k.

Reject a negative --hard-k, and treat a k that selects nothing as its own error naming k, before
the manifest-intersection check can mask it. Both flags now fail loudly the way the library
already promises.

* noise floor: match each re-ask to its record by key, not by position

Agastya's review: noise_floor_pass zipped a freshly rebuilt _twins(...) against the records
run_solo_baselines produced from its own separate _build_twins(...), guarded only by a length
check. The two builders are identical today, but the length check catches a count mismatch and not
a reordering -- if they ever drift (a limit, a filter, a changed skip, a reorder), the floor would
subtract the wrong twin's noise from each flip and the headline floor-adjusted rate would be
quietly wrong with nothing failing.

Records already carry case_id and cue_type, so key the twins by (case_id, cue_type) and look each
record up instead of trusting the two builders to stay byte-for-byte in lockstep. A missing key
raises loudly. per_twin stays aligned with records, so the paired floor-adjusted resampling is
unchanged.

---------

Co-authored-by: sebasmos <sebasticajas@gmail.com>
sebasmos added a commit that referenced this pull request Aug 4, 2026
* feat: benchmaxxing run entry point for the stage runners

* feat: bootstrap CIs and effect sizes in the run summary

* feat: correct the whole family of p-values in the run report

* feat: declare model lineage in the config instead of inferring it

* feat: noise-floor control and floor-adjusted flip rate

* feat: run the imaging lane through the run entry point

* feat: hard-case selector for the cascade arm

* feat: per-run reproducibility bundle and the report command

* cascade significance: paired permutation on the adoption gap, not a McNemar sign test

Agastya's review: _cascade_tests ran the continuous shared/isolated adoption rates through
mcnemar by counting only each pair's direction, collapsing it to a sign test that drops ties
and ignores magnitude. Over four discordant cases the smallest two-sided p is 0.125, so a
unanimous planted-answer effect could never be flagged even when the shared/isolated bootstrap
CIs printed right above it are cleanly separated -- the two signals then contradict.

Add stats.paired_permutation_test (seeded sign-flip over the per-case differences, exact by
enumeration for small n, sampled otherwise) and test the same shared-minus-isolated gap the
adoption_delta CI is built on, so the p-value and the interval answer one question. Thread the
run seed through significance_tests for reproducibility.

* imaging lane: count dropped findings on multi-finding cases

Agastya's review: _finding did case.label.split('|')[0], so a multi-finding NIH case
(cardiomegaly|effusion) was only ever asked about its first finding and the rest were
dropped with no entry in skipped_cases -- the 'Skipped cases:' line then reported full
coverage the run never had, the exact failure the skip accounting exists to catch.

v1 is one question per case, so keep that policy but make it honest: _positive_findings
lists the real findings, image_twins asks about the first and adds len(rest) to a new
dropped_findings bucket. The skip line already renders any nonzero bucket.

* lineage: warn when an org-prefixed id is inferred, not just recorded

Agastya's review: the bare-id fallback routes through _lineage, which splits on the first
separator, so an org-namespaced id like 'meta-llama/llama-3.1-70b' resolves to 'meta' rather than
'llama'. A mixed-convention roster then gets a wrong within- vs cross-lineage partition in
run_holes_test and the run completes clean, with lineage_source: inferred in the manifest as the
only trace after the fact.

Keep the heuristic as the documented fallback but make the risky case loud: model_specs warns when
a lineage is inferred (not declared) from an id carrying a '/'. Declaring `lineage` in the config
-- the resolution from #251 -- silences it. Failure is now at run time, not a manifest post-mortem.

* solo CI: resample over cases, not twins

Agastya's review: the solo lane resampled flip_rate/shortcut_reliance over the flat per-twin
record list, but _build_twins emits one twin per (case, cue_type), so a case's several cue-twins
sit in that array and bootstrap_ci drew them iid. Same-case twins share the case's difficulty, so
counting them as independent understates the uncertainty exactly the way pooling models would --
the 95% CI came out tighter than the case-level evidence supports.

Add stats.cluster_bootstrap_ci (draws whole clusters, pools their observations) and resample the
solo lane by case_id, so the interval answers "does this hold on new cases", which is the claim
the arm makes. The point estimate is unchanged and, since every cluster is a singleton for
one-twin-per-case data, the pinned intervals are untouched; only real multi-cue cases widen.

* hard-case selector: guard --hard-k the way the library guards k

Agastya's review: _select_hard_cases reimplemented the k cut as ranked[:args.hard_k] instead of
routing through select_uncertain_cases, skipping the non-negative guard the library put there for
exactly this. So --hard-k -3 silently kept every case except the three easiest -- the "restricted"
run quietly became nearly the full set at full API cost -- and --hard-k 0 exited with the
misleading "records and the manifest have to come from the same dataset" error instead of naming k.

Reject a negative --hard-k, and treat a k that selects nothing as its own error naming k, before
the manifest-intersection check can mask it. Both flags now fail loudly the way the library
already promises.

* report: fail replay when the bundle lost transcripts

Agastya's review: replay_cascade silently continued past any case whose shared/isolated transcript
pair was missing from the RunStore, and _cmd_report built its verdict from the three mean
comparisons alone, never comparing the replayed n_cases to the reported n_cases. So a bundle that
lost part of its transcripts (a partial copy, a pruned directory) still printed no MISMATCH and
exited 0 whenever adoption is pinned -- the replay certified evidence that no longer covered the
run it reported.

replay_cascade now returns the case ids it skipped, and _cmd_report treats a non-empty skip list
or an n_cases shortfall as a coverage MISMATCH before it even looks at the means. A complete bundle
skips nothing, so the happy path and test_replay_reproduces_the_reported_cascade_numbers are
untouched.

* noise floor: match each re-ask to its record by key, not by position

Agastya's review: noise_floor_pass zipped a freshly rebuilt _twins(...) against the records
run_solo_baselines produced from its own separate _build_twins(...), guarded only by a length
check. The two builders are identical today, but the length check catches a count mismatch and not
a reordering -- if they ever drift (a limit, a filter, a changed skip, a reorder), the floor would
subtract the wrong twin's noise from each flip and the headline floor-adjusted rate would be
quietly wrong with nothing failing.

Records already carry case_id and cue_type, so key the twins by (case_id, cue_type) and look each
record up instead of trusting the two builders to stay byte-for-byte in lockstep. A missing key
raises loudly. per_twin stays aligned with records, so the paired floor-adjusted resampling is
unchanged.

---------

Co-authored-by: sebasmos <sebasticajas@gmail.com>
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.

Experiment entrypoint: a benchmaxxing run CLI that drives the stage runners from a config

5 participants