Skip to content

feat: MIMIC-CXR text adapter + solo susceptibility real-API results (n=35, n=600) - #336

Merged
felipeocampoos merged 3 commits into
mainfrom
feat/mimic-cxr-text-adapter
Jul 29, 2026
Merged

feat: MIMIC-CXR text adapter + solo susceptibility real-API results (n=35, n=600)#336
felipeocampoos merged 3 commits into
mainfrom
feat/mimic-cxr-text-adapter

Conversation

@felipeocampoos

Copy link
Copy Markdown
Collaborator

Summary

Results (n=600)

model overall flip noise floor flip-above-noise
gemini-2.5-flash 0.079 0.000 +0.079
gemini-2.5-flash-lite 0.124 0.000 +0.124

Consistent with the n=35 smoke run (0.095 / 0.124). Full detail in experiments/mimic_cxr_text/RESULTS.md.

Closes #316. Part of #296. Related: #330, #334.

Test plan

  • pytest tests/ — 611 passed, 10 skipped (full suite, python 3.12)
  • ruff check clean on all changed files
  • Smoke-tested build_manifest against the real credentialed MIMIC-CXR data at /orcd/pool/006/lceli_shared/data/mimic-cxr-{reports,labels}
  • Real Gemini API run at n=35 and n=600 (results committed)

@sebasmos — flagging for review given the #330 design decisions and the credentialed-data handling call (gitignoring manifest/call_cache); let me know if that split should be different.

…316)

Add benchmaxxing/datasets/mimic_cxr_text.py (Lane B, MCQ over CheXpert findings from
report text, per the #330 design: single confirmed-positive answer, distractors from
confirmed-negative findings only, "No Finding" studies excluded). Reuse
experiments/medqa/reproduce.py unchanged (per #316) for real Gemini API solo
susceptibility + noise-floor runs at n=35 (smoke) then n=600, per #334.

Raw manifests and call caches are gitignored: report text is credentialed PhysioNet
data under a DUA and isn't redistributed, even in this private repo. Only aggregate
results are committed.

Closes #316.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@felipeocampoos felipeocampoos added the priority: high Do this soon; unblocks the paper or other work label Jul 26, 2026

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@armaanvgrewal

Copy link
Copy Markdown
Collaborator

@felipeocampoos @sebasmos

Tested locally on macOS Apple Silicon in a .venv. ruff check . failed with 6 lint errors, all in assets/figures/figure1_gen.py.

Environment/details:

python --version: Python 3.14.6
ruff --version: ruff 0.15.22
branch: pr-336-mimic-cxr-text
git status: clean working tree
install used: pip install -e ".[dev]"

Commands run:

pip install -e ".[dev]"
ruff check .
pytest -q

Results:

pip install -e ".[dev]"  # completed successfully
pytest -q                # 680 passed, 6 skipped

ruff check . failed with 6 lint errors, all in assets/figures/figure1_gen.py:

assets/figures/figure1_gen.py:36:3: F541 f-string without any placeholders
assets/figures/figure1_gen.py:65:14: E702 Multiple statements on one line (semicolon)
assets/figures/figure1_gen.py:139:3: F541 f-string without any placeholders
assets/figures/figure1_gen.py:140:3: F541 f-string without any placeholders
assets/figures/figure1_gen.py:150:27: E702 Multiple statements on one line (semicolon)
assets/figures/figure1_gen.py:151:27: E702 Multiple statements on one line (semicolon)

So the test suite passes locally, but the PR does not currently pass ruff check . on my setup.

@felipeocampoos

Copy link
Copy Markdown
Collaborator Author

@armaanvgrewal thanks for testing! Confirmed this is pre-existing on `main`, not something this PR introduces: our branch has zero commits/diff touching `assets/figures/figure1_gen.py`, and `ruff check` fails identically against a clean checkout of `main` (tried in an isolated worktree, same file, same errors — f-strings without placeholders + a missing-executable-bit shebang warning). Likely a ruff version/config difference between your local setup and whatever ran clean before (some of these rules, like EXE001, are newer). Not blocking this PR — happy to fix it here anyway if useful, or it's better as its own small cleanup PR against main. Let me know which you'd prefer.

@felipeocampoos

Copy link
Copy Markdown
Collaborator Author

Fixed in #341 (small, separate PR against main since it's unrelated to this diff). Once that merges, a rebase here will pick it up.

@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.

Good work on mimic_cxr_text.py and on the #330 filtering — keeping only single-confirmed-positive studies and drawing distractors solely from confirmed negatives is the right call, and the DUA split holds up: I read run_solo and solo_results.json plus call_cache.jsonl really is everything it writes, both of which your .gitignore covers. The problem is that build_manifest puts the whole report into question instead of report, and lexical_overlap_bias in cues/text.py appends every salient question token to a distractor. I can't run it against the credentialed text, but on a typical-length report that is ~67 tokens, so the distractor becomes a ~540-character blob that names the correct finding verbatim and is also ~45x longer than every other option — which means the lexical_overlap rate that RESULTS.md calls the strongest cue for both tiers (0.093 and 0.148) is partly counting models that read the answer out of the report pasted into option A, while longest_option fires on the same case.

You have a few options here. I would put the report in Case.report and leave question as the short stem, which is what pubmedqa.py and mimic_cxr.py already do — _mcq_prompt renders report as "Clinical context:", so the model sees identical text, but the cue then draws from the stem alone (I checked: the distractor drops to Cardiomegaly primary finding described report). That does mean re-running the n=600 solo pass.

@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.

Nice work getting this running end to end. Agastya's catch is a good one though: report should go in Case.report, not question, since the cue is pulling the whole report into a distractor right now. That's probably why lexical_overlap looks strongest. Worth fixing and re-running the n=600 pass before we trust that number.

sebasmos pushed a commit that referenced this pull request Jul 27, 2026
Fixes the 6 ruff errors reported on PR #336 (unrelated to that PR's diff):
3 f-strings without placeholders (F541) and 3 semicolon-chained statements
(E702). Made the file executable to match its shebang (EXE001). Pure
lint fix: figure1.svg regenerates byte-identical.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

@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 review — agree with Agastya and Seb; requesting the schema fix + n=600 re-run.

What’s solid

  • #330 filtering (single confirmed-positive answer, distractors from confirmed negatives only, drop No Finding / multi-positive / underdetermined) looks correct, and the tests pin those exclusions well.
  • DUA split is right: gitignore manifests + call caches, commit only aggregates.
  • Reusing experiments/medqa/reproduce.py unchanged matches #316’s replicate-don’t-rewrite rule.
  • Zero noise floor on both tiers is a clean control signal.

Blocker: report text in Case.question
build_manifest does:

question=f"{report_path.read_text(...)}\n\n{_QUESTION_PROMPT}"

lexical_overlap_bias in cues/text.py appends salient question tokens onto a distractor. Putting the full report in question therefore (a) pastes the correct finding into the contaminated option and (b) makes that option ~orders of magnitude longer, so longest_option co-fires. That is a plausible explanation for why RESULTS.md ranks lexical_overlap as the strongest cue (0.093 / 0.148) — part of that rate may be models reading the answer out of the option, not cue susceptibility.

Put the report in Case.report and leave question as the short stem (same pattern as pubmedqa / mimic_cxr). _mcq_prompt still surfaces the report as clinical context, so the model sees the same text; the cue only draws from the stem. Then re-run the n=600 solo pass before those numbers are citable.

(Armaan’s ruff note on figure1_gen.py is orthogonal and already fixed in #341.)

…(review on #336)

Two bugs caught in review by @Agastya191, @sebasmos, @maximinl:

1. mimic_cxr_text.py put the whole report into Case.question instead of
   Case.report, letting lexical_overlap_bias quote report text (including
   the correct finding) into a distractor and co-activate longest_option.
   Fixed to match the existing pubmedqa.py report/question split.

2. benchmaxxing/data.py's _text_case never read `report` back off a
   manifest CSV (unlike _image_case). Pre-existing bug in shared code,
   newly triggered because this is the first Lane B experiment to run a
   Case.report through a real manifest round-trip. Effect: the real API
   run was answering blind, with ~35-60% of gemini-2.5-flash responses a
   refusal ("please provide the report"). Fixed with a one-line read-back
   plus a regression test.

Re-ran n=35 and n=600 for real against Gemini with both fixes; added a
refusal-aware cross-check (experiments/mimic_cxr_text/refusal_aware_reanalysis.py,
no new API calls) confirming ~0% abstention and consistent flip rates.
Corrected numbers land in the same band as MedQA (flash 0.089 vs 0.063,
flash-lite 0.114 vs 0.117). See experiments/mimic_cxr_text/RESULTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@felipeocampoos

Copy link
Copy Markdown
Collaborator Author

@Agastya191 @sebasmos @maximinl — fixed, and found a second bug along the way. Pushed in 1907034.

Bug 1 (your catch): moved report into Case.report, question is now just the fixed short stem, matching pubmedqa.py. Confirmed with a direct build_text_twin spot-check that the lexical_overlap distractor no longer quotes report text.

Bug 2 (found while re-running): re-running with just bug 1 fixed produced a wildly different, much higher flip rate (~0.6-0.9) and a ~60% abstention rate on gemini-2.5-flash (lots of "please provide the report" replies). Traced it to benchmaxxing/data.py's _text_case, which never reads report back off a manifest CSV (unlike _image_case, which does) — a pre-existing bug in shared code, not in this adapter. Since reproduce.py's run_solo loads cases via load_cases(), every real API call was silently missing the report entirely; the model was guessing cold among 4 similar finding names. Fixed with a one-line read-back + a regression test (test_text_case_report_round_trips).

With both fixes, re-ran n=35 and n=600 for real:

{"gemini-2.5-flash": {"flip": 0.089, "noise_floor": 0.067},
 "gemini-2.5-flash-lite": {"flip": 0.114, "noise_floor": 0.0}}

Also added refusal_aware_reanalysis.py — re-derives flip rate from the already-cached responses using benchmaxxing.extract's proper abstention-aware parser (no new API calls), as a cross-check that the numbers aren't still contaminated by unhandled refusals: abstention rate is ~0% at both n=35 and n=600, and it matches reproduce.py's own numbers closely.

Full writeup in experiments/mimic_cxr_text/RESULTS.md. Ready for re-review.

@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.

Good work on the fix, and on running down the data.py bug. I checked it: _text_case really never read report back while _case_to_row had been writing the column all along, and no committed manifest under experiments/ has a populated report column, so the one-line read-back can't move any existing MedQA number. One problem, though: now that question is the constant _QUESTION_PROMPT, lexical_overlap_bias in cues/text.py has no case-specific text left to draw from, since _content_tokens on that stem returns ['primary', 'finding', 'described', 'report'] for every study, so the cue appends the identical four-word suffix to option 0 on all 600 cases. That makes lexical_overlap the same manipulation as longest_option with a different filler, rather than the per-case lexical signal it is on medqa.py and pubmedqa.py where question carries the case, so the two columns converging isn't a finding and RESULTS.md's cross-dataset read of that column isn't comparing the same construct.

You have a few options. I would have lexical_overlap_bias fall back to case.report tokens minus any token appearing in the options when the stem yields nothing case-specific, which restores a per-case signal without putting the report's naming of the answer back into the distractor. If you'd rather not touch shared cue code on this PR, a sentence in RESULTS.md saying the cue degenerates to a constant suffix here would be enough to stop that number being read as comparable to MedQA's. Separately, that same paragraph calls lexical_overlap strongest for both tiers, but results_n600/solo_results.json has flash-lite at longest_option 0.130 against lexical_overlap 0.118.

@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.

Bug 2 is genuinely fixed, I round-tripped a manifest and confirmed the report renders as Clinical context in the built prompt. Good catch finding it while re-running rather than shipping the first fix.

One new problem though. Now that the report is out of question, lexical_overlap_bias draws its tokens from the fixed 8-word stem, so it appends the same constant suffix to the same distractor that longest_option pads, on all 600 cases. Those two columns are one manipulation, not two, so the per-cue split is not comparable to MedQA/PubMedQA where the overlap is case-specific.

Also RESULTS.md says lexical_overlap is the strongest cue for both tiers at n=600, but the committed results_n600/solo_results.json has flash-lite longest_option 0.13 above lexical_overlap 0.1183.

Flagging that I have provisionally put your n=600 cross-dataset row in the paper with that cell marked on hold, so resolving this unblocks it.

…336)

Third bug caught in review by @Agastya191 and @sebasmos, found on the
re-run after moving report out of Case.question: lexical_overlap_bias
draws its perturbation tokens from case.question, which is now a fixed
8-word stem identical across every MIMIC-CXR text case, so it appended
the same four words to the same distractor on all 600 cases -- the same
manipulation as longest_option with different filler, not a per-case
lexical signal, making the "lexical_overlap is strongest" read invalid.

Fixed: the cue now draws from case.report when present (falls back to
case.question exactly as before for MedQA/etc, which have no separate
report field -- unaffected), and excludes any token appearing in ANY
option, not just the target, so it can no longer echo another option's
wording -- in particular the correct answer -- into a distractor.

Re-ran n=35 and n=600 for real with the fix; lexical_overlap and
longest_option now diverge per-model as expected of two genuinely
different manipulations. No single cue is "strongest" for both tiers
anymore. Updated experiments/mimic_cxr_text/RESULTS.md accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@felipeocampoos

Copy link
Copy Markdown
Collaborator Author

@Agastya191 @sebasmos — good catch, and confirmed exactly as described: with question now a
constant stem, lexical_overlap_bias was appending the same 4-word suffix on every case,
collapsing it into longest_option with different filler. Fixed in benchmaxxing/cues/text.py
(190703434c20a7):

  • The cue now draws its perturbation tokens from case.report when present, falling back to
    case.question unchanged for datasets with no separate report field (MedQA etc. — confirmed
    unaffected, case.report is None there so this is a no-op for them).
  • Also excludes any token appearing in any option (not just the target), not only the ones
    already in the code — so the injected text can't echo another option's wording, most
    importantly the correct answer, back into a distractor.
  • Two regression tests added (test_lexical_overlap_draws_from_report_not_a_fixed_question_stem,
    test_lexical_overlap_never_injects_another_options_wording) covering exactly this failure
    mode so it can't regress silently again.

Re-ran n=35 and n=600 for real. lexical_overlap and longest_option now diverge per model as
expected of two genuinely different manipulations:

{"gemini-2.5-flash": {"lexical_overlap": 0.093, "longest_option": 0.105, "option_order": 0.043},
 "gemini-2.5-flash-lite": {"lexical_overlap": 0.175, "longest_option": 0.13, "option_order": 0.095}}

@Agastya191 also right that RESULTS.md's "lexical_overlap strongest for both tiers" line was
wrong even on its own (pre-fix) numbers — flash-lite already had longest_option 0.130 >
lexical_overlap 0.118. Corrected: no single cue is strongest for both tiers now, and RESULTS.md
says so explicitly instead of overclaiming. Full writeup updated in
experiments/mimic_cxr_text/RESULTS.md.

Ready for re-review.

@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.

Verified the post-fix n=600 numbers on the branch: flash 0.043 / 0.105 / 0.093 and flash-lite 0.095 / 0.130 / 0.175, so the two cues genuinely diverge now. Drawing from the report and excluding tokens present in any option is the right fix, and the two regression tests pin exactly the failure mode. Dropping the overclaim rather than defending it is appreciated. Approving.

@felipeocampoos
felipeocampoos merged this pull request into main Jul 29, 2026
@felipeocampoos
felipeocampoos deleted the feat/mimic-cxr-text-adapter branch July 29, 2026 18:54
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
…(review on #336)

Two bugs caught in review by @Agastya191, @sebasmos, @maximinl:

1. mimic_cxr_text.py put the whole report into Case.question instead of
   Case.report, letting lexical_overlap_bias quote report text (including
   the correct finding) into a distractor and co-activate longest_option.
   Fixed to match the existing pubmedqa.py report/question split.

2. benchmaxxing/data.py's _text_case never read `report` back off a
   manifest CSV (unlike _image_case). Pre-existing bug in shared code,
   newly triggered because this is the first Lane B experiment to run a
   Case.report through a real manifest round-trip. Effect: the real API
   run was answering blind, with ~35-60% of gemini-2.5-flash responses a
   refusal ("please provide the report"). Fixed with a one-line read-back
   plus a regression test.

Re-ran n=35 and n=600 for real against Gemini with both fixes; added a
refusal-aware cross-check (experiments/mimic_cxr_text/refusal_aware_reanalysis.py,
no new API calls) confirming ~0% abstention and consistent flip rates.
Corrected numbers land in the same band as MedQA (flash 0.089 vs 0.063,
flash-lite 0.114 vs 0.117). See experiments/mimic_cxr_text/RESULTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
)

Reuse experiments/medqa/reproduce.py --stage cascade as-is for the bare
baseline-relative seed (null, same as MedQA: a wrong peer with no
rationale does not spread). Copy experiments/medqa/push_c.py into
experiments/mimic_cxr_text/push_c.py for the case-anchored plausibility
dose-response, adapted for this dataset's report/question split (#336):
_mcq now renders Case.report as clinical context (missing entirely in
the original, which would answer blind), and the anchor detail is pulled
from Case.report instead of Case.question (the latter is now a fixed
stem, identical across cases, which would make every "anchored"
rationale generic).

build_solo_records.py reconstructs push_c.py's --solo-records input from
the already-cached n=600 solo run, no new API calls.

Real n=20 (bare) and n=60 (anchored) runs against Gemini. Cascade
transcripts are gitignored (they embed report text via committee turn
content, same DUA policy as manifests/call caches). See
experiments/mimic_cxr_text/RESULTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
…336)

Third bug caught in review by @Agastya191 and @sebasmos, found on the
re-run after moving report out of Case.question: lexical_overlap_bias
draws its perturbation tokens from case.question, which is now a fixed
8-word stem identical across every MIMIC-CXR text case, so it appended
the same four words to the same distractor on all 600 cases -- the same
manipulation as longest_option with different filler, not a per-case
lexical signal, making the "lexical_overlap is strongest" read invalid.

Fixed: the cue now draws from case.report when present (falls back to
case.question exactly as before for MedQA/etc, which have no separate
report field -- unaffected), and excludes any token appearing in ANY
option, not just the target, so it can no longer echo another option's
wording -- in particular the correct answer -- into a distractor.

Re-ran n=35 and n=600 for real with the fix; lexical_overlap and
longest_option now diverge per-model as expected of two genuinely
different manipulations. No single cue is "strongest" for both tiers
anymore. Updated experiments/mimic_cxr_text/RESULTS.md accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
…318)

Copy experiments/medqa/break_it.py's arm A into
experiments/mimic_cxr_text/break_it_a.py, adapted for this dataset's
report/question split (_mcq renders Case.report as clinical context, same
fix pattern as #336/#348) and per #318's explicit instruction to choose the
flagged wrong answer per-model so it differs from that model's own clean
baseline (MedQA's original picked one wrong per case regardless of model
and was noted as confounded there).

Real n=20 run against Gemini: a wrong automated pre-screen flag is adopted
by both tiers (flash +0.40, flash-lite +0.50), comparable to MedQA's own
system-flag finding (+0.26/+0.30). See experiments/mimic_cxr_text/RESULTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
Port experiments/blind_metric/blind_metric.py (not break_it.py's simpler
arm D) into experiments/mimic_cxr_text/blind_metric.py: #320 explicitly
asks whether the model names the rubric, which only blind_metric.py's
three-condition design (baseline/blind/test_aware + a naming regex on the
justification) measures. Adapted the prompt builder to render Case.report
as clinical context, same fix pattern as #336/#348/#353.

Also widened the DUA gitignore rule: this script writes its cache under a
different filename (blind_metric_call_cache.jsonl) than the existing
call_cache.jsonl pattern covered, and its cached responses embed quoted
report text in the model's reasoning, so the narrower glob would have let
it slip through.

Real n=40 run (gemini-2.5-flash-lite, matching the MedQA original):
decoy uptake blind 0.05 vs baseline 0.0, but test_aware (0.175) is HIGHER
than blind, the reverse of MedQA's own suppression direction. Traced to a
genuine model reasoning failure (confirmed by inspecting the raw
completions, not a prompt bug): flash-lite's chain-of-thought misreads
"the rubric may be misaligned, answer clinically" as "assume the rubric
is correct, justify it." Reported honestly in RESULTS.md with the n=40
sample-size caveat rather than forced to match the expected direction.
Where the datasets do agree: both decoy-drifters named the rubric
explicitly (2/2), consistent with MedQA's text-lane self-declared drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
Fixes the 6 ruff errors reported on PR #336 (unrelated to that PR's diff):
3 f-strings without placeholders (F541) and 3 semicolon-chained statements
(E702). Made the file executable to match its shebang (EXE001). Pure
lint fix: figure1.svg regenerates byte-identical.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
feat: MIMIC-CXR text adapter + solo susceptibility real-API results (n=35, n=600)
sebasmos pushed a commit that referenced this pull request Aug 4, 2026
…udge (#357)

* feat: MIMIC-CXR text cascade contagion, bare vs case-anchored seed (#317)

Reuse experiments/medqa/reproduce.py --stage cascade as-is for the bare
baseline-relative seed (null, same as MedQA: a wrong peer with no
rationale does not spread). Copy experiments/medqa/push_c.py into
experiments/mimic_cxr_text/push_c.py for the case-anchored plausibility
dose-response, adapted for this dataset's report/question split (#336):
_mcq now renders Case.report as clinical context (missing entirely in
the original, which would answer blind), and the anchor detail is pulled
from Case.report instead of Case.question (the latter is now a fixed
stem, identical across cases, which would make every "anchored"
rationale generic).

build_solo_records.py reconstructs push_c.py's --solo-records input from
the already-cached n=600 solo run, no new API calls.

Real n=20 (bare) and n=60 (anchored) runs against Gemini. Cascade
transcripts are gitignored (they embed report text via committee turn
content, same DUA policy as manifests/call caches). See
experiments/mimic_cxr_text/RESULTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: case-anchored rationale pulled report header boilerplate, not findings (#348 review)

@sebasmos caught it on review: the anchor detail took the first 14/30 words of
the whole report, which for MIMIC-CXR is always fixed boilerplate ("FINAL
REPORT", "EXAMINATION: CHEST (PA AND LAT)", "INDICATION: ..."), not clinical
content. That made "anchored" and "generic" prompts near-identical, so the
anchored-equals-generic null was measuring the boilerplate, not anchoring.

Fixed with _findings_text(), extracting from the first FINDINGS:/IMPRESSION:
header onward (falls back to the whole report if neither is present).
Spot-checked against real reports to confirm real clinical sentences are
captured.

Also added two paired tests sebasmos had computed independently on the old
(buggy) run: anchored_strong vs generic (p=0.29, correctly not a
dose-response) and anchored vs anchored_solo (the majority-vs-single-peer
contrast, p=0.0039 -- the actually-significant effect, now foregrounded in
RESULTS.md instead of the null anchored-vs-generic comparison).

Re-ran n=60 for real with the fix. RESULTS.md rewritten to report both new
contrasts and stop reading the null as a dose-response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat: MIMIC-CXR text Break-it A, contaminated system pre-screen flag (#318)

Copy experiments/medqa/break_it.py's arm A into
experiments/mimic_cxr_text/break_it_a.py, adapted for this dataset's
report/question split (_mcq renders Case.report as clinical context, same
fix pattern as #336/#348) and per #318's explicit instruction to choose the
flagged wrong answer per-model so it differs from that model's own clean
baseline (MedQA's original picked one wrong per case regardless of model
and was noted as confounded there).

Real n=20 run against Gemini: a wrong automated pre-screen flag is adopted
by both tiers (flash +0.40, flash-lite +0.50), comparable to MedQA's own
system-flag finding (+0.26/+0.30). See experiments/mimic_cxr_text/RESULTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat: MIMIC-CXR text blind-metric substitution probe (#320)

Port experiments/blind_metric/blind_metric.py (not break_it.py's simpler
arm D) into experiments/mimic_cxr_text/blind_metric.py: #320 explicitly
asks whether the model names the rubric, which only blind_metric.py's
three-condition design (baseline/blind/test_aware + a naming regex on the
justification) measures. Adapted the prompt builder to render Case.report
as clinical context, same fix pattern as #336/#348/#353.

Also widened the DUA gitignore rule: this script writes its cache under a
different filename (blind_metric_call_cache.jsonl) than the existing
call_cache.jsonl pattern covered, and its cached responses embed quoted
report text in the model's reasoning, so the narrower glob would have let
it slip through.

Real n=40 run (gemini-2.5-flash-lite, matching the MedQA original):
decoy uptake blind 0.05 vs baseline 0.0, but test_aware (0.175) is HIGHER
than blind, the reverse of MedQA's own suppression direction. Traced to a
genuine model reasoning failure (confirmed by inspecting the raw
completions, not a prompt bug): flash-lite's chain-of-thought misreads
"the rubric may be misaligned, answer clinically" as "assume the rubric
is correct, justify it." Reported honestly in RESULTS.md with the n=40
sample-size caveat rather than forced to match the expected direction.
Where the datasets do agree: both decoy-drifters named the rubric
explicitly (2/2), consistent with MedQA's text-lane self-declared drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat: MIMIC-CXR text referee detection, naive gate vs deployable vs judge (#321)

Port experiments/referee/referee_deployable.py and referee_judge.py into
experiments/mimic_cxr_text/. Both are self-contained (each builds its own
anchored-cascade committee run per case with two colluding peers + a real
holdout), so this doesn't depend on #317/#355's cascade transcripts.
Same report/question adaptation as #336/#348/#353/#320: _mcq renders
Case.report as clinical context, and the anchor detail reuses push_c.py's
_findings_text.

Real n=40 runs against Gemini, 14 real adoptions:
- naive_gate: precision 0.35, recall 1.0, FPR 1.0 (over-fires, as expected)
- deployable (peer-modal + private re-query, no key): precision/recall 1.0,
  FPR 0.0, matching the oracle upper bound
- same-lineage judge: precision 1.0, recall 0.93, FPR 0.0

Closely matches MedQA's own referee finding (naive over-fires, deployable
recovers the oracle, judge is viable but noisier). Completes the #296
battery (#316-#321), modulo the anchored-seed contagion gap tracked in #355.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: close #355, anchored-seed contagion from already-committed referee data

#317's "contagion = shared minus isolated" was only measured for the bare
seed in #348 (n=20, 0.0). referee_deployable.jsonl (#357, n=40) already
contains the same pair for the ANCHORED seed at zero extra API cost: its
board answer (shared, anchored rationale) vs bare answer (private,
unseeded re-query) is exactly a shared-vs-isolated comparison, since
`wrong` is chosen to differ from bare by construction (same
baseline-relative convention used throughout the project).

contagion = 0.35 - 0.0 = 0.35, notably higher than the bare seed's null
(0.0), consistent with the battery's overall finding that MIMIC-CXR text
holdouts propagate a minimally-plausible seed but not a content-free one.

Documented the one honest caveat: this isolated condition is a cold,
seedless re-query, not MedQA's original isolated-committee mechanics
(same seed planted per-agent, no board-sharing) -- a stronger, slightly
different control, judged sufficient given it's the same private-re-query
convention used everywhere else in this battery (push_c.py, break_it_a.py,
both referee scripts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: naming regex and prompt asymmetry in blind_metric.py (#356 review)

Two bugs caught by @Agastya191 on PR #356:

1. _NAMING was only word-bounded on its first/last alternative, and had a
   substring fallback treating a bare decoy_letter ("A"/"B") appearing
   anywhere in the first 120 chars as evidence of naming -- true of
   almost any sentence, so `named` could never come back False for a
   drifter. n_drifted_silently: 0 was a guaranteed artifact, not an
   observation. Fixed: proper \b boundaries on every alternative,
   substring fallback removed.

2. blind_p asked for a justification before the letter; aware_p asked
   for a bare letter only, confounding test_awareness_suppression with
   the justification requirement itself. Fixed: aware_p now requests
   the same one-sentence justification.

Re-ran n=40 for real with both fixes. Both changed the reading
substantially: test_aware dropped from 0.175 to 0.075 (most of the
"reversed suppression" was the justification-requirement artifact, not a
model reasoning failure), and the naming result flipped from 2/2 to 0/2
-- confirmed by reading the raw justifications, which cite the report's
own clinical language, not the rubric. So MIMIC-CXR text's blind-metric
drift is silent here (matching the imaging lane, opposite of MedQA's own
11/11 self-declared text-lane finding), the honest number rather than
the artifact the buggy regex reported. base_is_decoy=0-by-construction
now documented explicitly (same convention as #317/#355).

Also registers mimic_cxr_text in benchmaxxing/datasets/status.py and
staging.py, added to main by an unrelated merge (infra this branch
predates) that now requires every registered adapter to have both
entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: high Do this soon; unblocks the paper or other work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Solo shortcut susceptibility + noise floor (MIMIC-CXR text)

7 participants