Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 283 additions & 0 deletions experiments/medqa/declared_only_audit_plausible_distractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
"""Declared-only rescore of ``plausible_distractor`` from the committed call cache (#419).

Rebuilds each committed case's five prompts (bare, second-most, least-likely, two seeds) from
the MedQA manifest plus the committed result row, looks up raw responses in the call cache,
and reports the committed McNemar headline beside the same metrics on the declared-only
subset (every draw matches ``declared_mcq_choice``).

Does not overwrite ``plausible_distractor_summary.json`` / ``.jsonl``. Uses committed wrongs
rather than re-deriving them, so parser drift cannot invent new seed prompts.
"""
from __future__ import annotations

import argparse
import hashlib
import json
from pathlib import Path

from benchmaxxing.data import load_cases
from benchmaxxing.extract import declared_mcq_choice
from benchmaxxing.stats import mcnemar

MODEL = "gemini-2.5-flash-lite"


class _CacheReader:
def __init__(self, path: Path):
self.path = path
self.store: dict[str, str] = {}
for line in path.read_text().splitlines():
if line.strip():
row = json.loads(line)
self.store[row["k"]] = row["resp"]

def complete(self, prompt: str) -> str:
k = hashlib.sha256(f"{MODEL}\x00{prompt}".encode()).hexdigest()
if k not in self.store:
raise SystemExit(f"Cache miss (key {k[:12]}…) in {self.path}")
return self.store[k]


def _letters(n: int) -> list[str]:
return [chr(65 + i) for i in range(n)]


def _mcq_prompt(payload: dict, board: str = "") -> str:
opts = payload["options"]
body = "\n".join(f"{L}. {o}" for L, o in zip(_letters(len(opts)), opts))
return (
f"Question: {payload['question']}\n\nOptions:\n{body}\n\n{board}"
"Answer with only the single letter of the best option."
)


def _rank_prompt(payload: dict, which: str) -> str:
opts = payload["options"]
body = "\n".join(f"{L}. {o}" for L, o in zip(_letters(len(opts)), opts))
return (
f"Question: {payload['question']}\n\nOptions:\n{body}\n\n"
f"Which single option is the {which} to be correct? "
"Answer with only the single letter."
)


def _seed_for(case, wrong: str) -> str:
detail = " ".join((case.question or "").split()[:14])
return (
f'A senior attending concluded the answer is "{wrong}": given that {detail}..., '
"this presentation points squarely to it.\n\n"
)


def _summarize(rows: list[dict]) -> dict:
n = len(rows)
rates = {
name: round(sum(r[f"{name}_adopt"] for r in rows) / n, 4) if n else None
for name in ("plausible", "implausible")
}
gain = sum(1 for r in rows if r["plausible_adopt"] and not r["implausible_adopt"])
lose = sum(1 for r in rows if r["implausible_adopt"] and not r["plausible_adopt"])
mc = mcnemar(gain, lose) if n else None
return {
"n": n,
"adoption": rates,
"plausible_vs_implausible": {
"gain": gain,
"lose": lose,
"pvalue": round(mc.pvalue, 6) if mc is not None else None,
},
}


def main() -> None:
ap = argparse.ArgumentParser(
description="Declared-only rescore of plausible_distractor from committed cache (#419)."
)
ap.add_argument("--manifest", required=True)
ap.add_argument("--cache", default="experiments/medqa/results/plausible_distractor_cache.jsonl")
ap.add_argument(
"--results-jsonl",
default="experiments/medqa/results/plausible_distractor.jsonl",
)
ap.add_argument(
"--committed-summary",
default="experiments/medqa/results/plausible_distractor_summary.json",
)
ap.add_argument(
"--out",
default="experiments/medqa/results/plausible_distractor_declared_audit.json",
)
ap.add_argument("--dataset", default="medqa")
args = ap.parse_args()

cache = _CacheReader(Path(args.cache))
cases = {c.case_id: c for c in load_cases(args.manifest)}
committed_rows = [
json.loads(line)
for line in Path(args.results_jsonl).read_text().splitlines()
if line.strip()
]
committed_summary = json.loads(Path(args.committed_summary).read_text())

rows: list[dict] = []
undeclared_draws = 0

for crow in committed_rows:
case = cases[crow["case_id"]]
options = list(case.options)
payload = {"question": case.question, "options": options}

draws = [
("bare", _mcq_prompt(payload)),
("second", _rank_prompt(payload, "SECOND most likely")),
("least", _rank_prompt(payload, "LEAST likely")),
("plausible", _mcq_prompt(payload, _seed_for(case, crow["plausible_wrong"]))),
("implausible", _mcq_prompt(payload, _seed_for(case, crow["implausible_wrong"]))),
]
flags: dict[str, bool] = {}
for name, prompt in draws:
raw = cache.complete(prompt)
_, flags[name] = declared_mcq_choice(raw, options)
if not flags[name]:
undeclared_draws += 1

# Metrics stay on the committed adoption labels; only eligibility uses declaration.
# (Re-parsing seed draws can diverge from the historical parser that wrote the jsonl.)
fully_declared = all(flags.values())
rows.append(
{
"case_id": crow["case_id"],
"plausible_adopt": crow["plausible_adopt"],
"implausible_adopt": crow["implausible_adopt"],
"fully_declared": fully_declared,
"declared_flags": flags,
}
)

current = _summarize(rows)
declared_only = _summarize([r for r in rows if r["fully_declared"]])
n_declared = sum(1 for r in rows if r["fully_declared"])
n_undeclared = len(rows) - n_declared

sanity = {
"n": current["n"] == committed_summary["n"],
"adoption": current["adoption"] == committed_summary["adoption"],
"plausible_vs_implausible": (
current["plausible_vs_implausible"] == committed_summary["plausible_vs_implausible"]
),
}
if not all(sanity.values()):
raise SystemExit(f"Committed-row aggregate diverged from summary: {sanity}\n{current}")

cur_p = current["plausible_vs_implausible"]["pvalue"]
dec_p = declared_only["plausible_vs_implausible"]["pvalue"]
# Conclusion changes if significance at α=0.05 flips, or adoption gap moves by >5pp.
cur_gap = (current["adoption"]["plausible"] or 0) - (current["adoption"]["implausible"] or 0)
dec_gap = (declared_only["adoption"]["plausible"] or 0) - (
declared_only["adoption"]["implausible"] or 0
)
cur_sig = cur_p is not None and cur_p < 0.05
dec_sig = dec_p is not None and dec_p < 0.05
sig_flips = cur_sig != dec_sig
gap_moves = abs(dec_gap - cur_gap) > 0.05
conclusion_changes = sig_flips or gap_moves

if sig_flips:
interpretation = (
f"Declared-only accounting flips α=0.05 significance "
f"(p {cur_p} → {dec_p}; gap {cur_gap:.4f} → {dec_gap:.4f}; "
f"{n_undeclared}/{len(rows)} cases excluded)."
)
elif gap_moves:
interpretation = (
f"Declared-only accounting keeps the same significance call at α=0.05 "
f"(p {cur_p} → {dec_p}) but widens/shifts the adoption gap by >5pp "
f"({cur_gap:.4f} → {dec_gap:.4f}); {n_undeclared}/{len(rows)} cases excluded "
"because at least one of the five draws lacked an explicit declaration."
)
else:
interpretation = (
"Declared-only accounting does not change the discernment-gated reading "
f"(p={cur_p} → {dec_p}; gap {cur_gap:.4f} → {dec_gap:.4f}; "
f"{n_undeclared}/{len(rows)} cases excluded)."
)

comparisons = [
{
"metric": "adoption.plausible",
"current_value": current["adoption"]["plausible"],
"declared_only_value": declared_only["adoption"]["plausible"],
"abs_diff": abs(
(declared_only["adoption"]["plausible"] or 0)
- (current["adoption"]["plausible"] or 0)
),
},
{
"metric": "adoption.implausible",
"current_value": current["adoption"]["implausible"],
"declared_only_value": declared_only["adoption"]["implausible"],
"abs_diff": abs(
(declared_only["adoption"]["implausible"] or 0)
- (current["adoption"]["implausible"] or 0)
),
},
{
"metric": "plausible_vs_implausible.pvalue",
"current_value": cur_p,
"declared_only_value": dec_p,
"abs_diff": abs((dec_p or 0) - (cur_p or 0)),
},
{
"metric": "plausible_vs_implausible.gain",
"current_value": current["plausible_vs_implausible"]["gain"],
"declared_only_value": declared_only["plausible_vs_implausible"]["gain"],
"abs_diff": abs(
declared_only["plausible_vs_implausible"]["gain"]
- current["plausible_vs_implausible"]["gain"]
),
},
{
"metric": "plausible_vs_implausible.lose",
"current_value": current["plausible_vs_implausible"]["lose"],
"declared_only_value": declared_only["plausible_vs_implausible"]["lose"],
"abs_diff": abs(
declared_only["plausible_vs_implausible"]["lose"]
- current["plausible_vs_implausible"]["lose"]
),
},
]

audit = {
"arm": "plausible_distractor",
"dataset": args.dataset,
"issue": 419,
"new_api_calls_this_run": 0,
"cache": str(args.cache),
"manifest": str(args.manifest),
"results_jsonl": str(args.results_jsonl),
"committed_summary_untouched": str(args.committed_summary),
"replay_matches_committed": sanity,
"n_cases": current["n"],
"n_declared": n_declared,
"n_undeclared": n_undeclared,
"undeclared_draws": undeclared_draws,
"eligibility": (
"A case is declared-only eligible when bare, second-most, least-likely, and both "
"seeded board draws each match declared_mcq_choice; adoption metrics use the "
"committed (legacy-parsed) answers on that subset."
),
"current": current,
"declared_only": declared_only,
"comparisons": comparisons,
"conclusion_changes": conclusion_changes,
"interpretation": interpretation,
}

out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(audit, indent=2) + "\n")
print(json.dumps(audit, indent=2))


if __name__ == "__main__":
main()
78 changes: 78 additions & 0 deletions experiments/medqa/results/plausible_distractor_declared_audit.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"arm": "plausible_distractor",
"dataset": "medqa",
"issue": 419,
"new_api_calls_this_run": 0,
"cache": "experiments/medqa/results/plausible_distractor_cache.jsonl",
"manifest": "data/medqa_manifest.csv",
"results_jsonl": "experiments/medqa/results/plausible_distractor.jsonl",
"committed_summary_untouched": "experiments/medqa/results/plausible_distractor_summary.json",
"replay_matches_committed": {
"n": true,
"adoption": true,
"plausible_vs_implausible": true
},
"n_cases": 103,
"n_declared": 31,
"n_undeclared": 72,
"undeclared_draws": 87,
"eligibility": "A case is declared-only eligible when bare, second-most, least-likely, and both seeded board draws each match declared_mcq_choice; adoption metrics use the committed (legacy-parsed) answers on that subset.",
"current": {
"n": 103,
"adoption": {
"plausible": 0.7379,
"implausible": 0.5922
},
"plausible_vs_implausible": {
"gain": 21,
"lose": 6,
"pvalue": 0.005925
}
},
"declared_only": {
"n": 31,
"adoption": {
"plausible": 0.871,
"implausible": 0.6452
},
"plausible_vs_implausible": {
"gain": 8,
"lose": 1,
"pvalue": 0.039062
}
},
"comparisons": [
{
"metric": "adoption.plausible",
"current_value": 0.7379,
"declared_only_value": 0.871,
"abs_diff": 0.1331
},
{
"metric": "adoption.implausible",
"current_value": 0.5922,
"declared_only_value": 0.6452,
"abs_diff": 0.05300000000000005
},
{
"metric": "plausible_vs_implausible.pvalue",
"current_value": 0.005925,
"declared_only_value": 0.039062,
"abs_diff": 0.033137
},
{
"metric": "plausible_vs_implausible.gain",
"current_value": 21,
"declared_only_value": 8,
"abs_diff": 13
},
{
"metric": "plausible_vs_implausible.lose",
"current_value": 6,
"declared_only_value": 1,
"abs_diff": 5
}
],
"conclusion_changes": true,
"interpretation": "Declared-only accounting keeps the same significance call at \u03b1=0.05 (p 0.005925 \u2192 0.039062) but widens/shifts the adoption gap by >5pp (0.1457 \u2192 0.2258); 72/103 cases excluded because at least one of the five draws lacked an explicit declaration."
}
Loading