From 98d6c48fa2d3034f9cd60be0dc25501c4439305d Mon Sep 17 00:00:00 2001
From: Tom Ballard Needs a multi-seed crossover dataset. Only 1 seed — seed-to-seed noise is not '
+ 'estimable, so SNR cannot be computed. Re-run with '
+ ' SNR = |between-arm adherence gap| ÷ '
+ 'across-seed σ of that gap. SNR < 1 is '
+ 'noise-dominated. {_esc(a)} vs {_esc(b)} — headline SNR '
+ f'at N={pair.get("headline_N")}: {_esc(_snr_txt(head))} Of {health["total"]} scenarios: '
+ f'{c["discriminating"]} discriminating, {c["broken"]} broken, '
+ f'{c["contaminated"]} contaminated, {c["tie"]} tie{extra}. broken = ceiling arm never adheres; '
+ 'contaminated = parametric-memory floor already adheres; '
+ 'tie = no arm separates.Signal-to-noise
")
+ if snr["n_seeds"] < 2:
+ out.append('--seeds 0-4. "
+ for r in pair["by_n"])
+ out.append("{r['N']} {_f(r.get('signal'))} "
+ f"{_f(r.get('noise'))} {_esc(_snr_txt(r))}
")
+ if health["total"]:
+ c = health["counts"]
+ out.append(" {rows}"
+ "N signal "
+ f"noise σ SNR Scenario health
")
+ extra = f", {c['unknown']} unknown" if c["unknown"] else ""
+ out.append(f' "
+ for s in flagged)
+ out.append(" "
+ f"{_esc(s['scenario_id'])}{_esc(s['class'])} {_f(s['max_gap'])}
")
+ out.append(' {rows}scenario class "
+ f"max arm gap DG_UI_ALLOW_PAID=1 (and ANTHROPIC_API_KEY) to enable; "
"you'll still have to estimate and tick a confirmation before any spend.
Triggers the CLI on the server; the page refreshes when the run finishes.
" " " " " @@ -407,8 +468,10 @@ def render_main(run, dataset, cost_curve=None, *, live=False, paid_enabled=False secs.append('Needs both rac and naive_rag in a crossover dataset.
' + # 6 Signal / noise (SNR + scenario-health audit) + secs.append(f'{_signal_noise_section(dataset) if dataset else " ') + # 7 Reproduce + secs.append('Needs a crossover dataset.
"}Reproduce
' 'make real-crossover # headline + adherence-vs-N over the real pool\n' 'make real-batch # via the Batch API (~50% cost)\n\n' 'make ui # serve this dashboard live at 127.0.0.1:8099\n' @@ -424,9 +487,10 @@ def render_main(run, dataset, cost_curve=None, *, live=False, paid_enabled=False return "".join(secs) -# Tab labels, in section order (s0..s6, +s7 Run when live). Kept beside the +# Tab labels, in section order (s0..s7, +s8 Run when live). Kept beside the # renderer so the nav and the sections stay in lockstep. -_TABS = ["Overview", "Leaderboard", "Curves", "Cost", "rac vs RAG", "Scenarios", "Reproduce"] +_TABS = ["Overview", "Leaderboard", "Curves", "Cost", "rac vs RAG", "Scenarios", + "Signal / noise", "Reproduce"] def build_dashboard(run, dataset, cost_curve=None, *, live=False, paid_enabled=False, template=None): diff --git a/decisiongrounding/runner/templates/dashboard.html b/decisiongrounding/runner/templates/dashboard.html index 0251096..badf509 100644 --- a/decisiongrounding/runner/templates/dashboard.html +++ b/decisiongrounding/runner/templates/dashboard.html @@ -8,7 +8,7 @@ [CHIPS] - the config chips (answering model, embedder, scenarios) [BODY] - the tab radios + nav + main, with the data-bound sections Point the server / static generator at a custom copy with --template or the - DG_UI_TEMPLATE env var. Tabs are pure CSS (#t0..#t7); the JS only activates + DG_UI_TEMPLATE env var. Tabs are pure CSS (#t0..#t8); the JS only activates when a live Run tab (#run-status) is present, so static snapshots stay inert. --> @@ -43,6 +43,7 @@ #t5:checked~nav label[for=t5]{color:var(--fg);border-bottom-color:var(--fg);font-weight:600}#t5:checked~main #s5{display:block} #t6:checked~nav label[for=t6]{color:var(--fg);border-bottom-color:var(--fg);font-weight:600}#t6:checked~main #s6{display:block} #t7:checked~nav label[for=t7]{color:var(--fg);border-bottom-color:var(--fg);font-weight:600}#t7:checked~main #s7{display:block} +#t8:checked~nav label[for=t8]{color:var(--fg);border-bottom-color:var(--fg);font-weight:600}#t8:checked~main #s8{display:block} table{border-collapse:collapse;width:100%;margin:8px 0 18px;font-size:14px} th,td{border:1px solid var(--line);padding:7px 10px;text-align:right} th:first-child,td:first-child{text-align:left} diff --git a/decisiongrounding/scripts/report.py b/decisiongrounding/scripts/report.py index 8e3559a..807c970 100644 --- a/decisiongrounding/scripts/report.py +++ b/decisiongrounding/scripts/report.py @@ -29,6 +29,8 @@ from scoring.charts import grouped_bar_chart, line_chart # noqa: E402 from scoring.cost import cost_by_arm, dollars # noqa: E402 +from scoring.health import scenario_health # noqa: E402 +from scoring.snr import signal_to_noise # noqa: E402 from scoring.stats import paired_significance # noqa: E402 _ARM_DESC = { @@ -350,6 +352,109 @@ def _per_scenario_section(dataset: dict) -> list[str]: return parts +def _snr_cell(rec: dict) -> str: + """Render one SNR record: a number (·* when noise-dominated), or the + reason it isn't a number.""" + snr = rec.get("snr") + if snr is not None: + return f"{snr:.2f}" + ("*" if rec.get("noise_dominated") else "") + flag = rec.get("flag") + if flag == "zero_noise_clean_separation": + return "clean (σ=0)" + if flag == "no_effect": + return "0 (tie)" + return "n/a" + + +def _snr_section(dataset: dict) -> list[str]: + """Signal-to-noise for each arm contrast: |paired Δ| / across-seed σ. The + honest headline of whether the crossover result is above run-to-run noise. + + `.get`-guarded; needs the `paired` block. With <2 seeds, noise is not + estimable and the section says so rather than inventing a ratio. + """ + snr = signal_to_noise(dataset) + if not snr["pairs"]: + return [] + parts = ["## Signal-to-noise", ""] + if snr["n_seeds"] < 2: + parts += [ + f"Only {snr['n_seeds']} seed — seed-to-seed noise is not estimable, so " + "signal-to-noise cannot be computed (you cannot measure noise from a " + "single run). Re-run with e.g. `--seeds 0-4` to report it.", + "", + ] + return parts + parts.append( + "SNR = |between-arm adherence gap| ÷ across-seed standard deviation of " + "that gap. **SNR < 1 (·\\*) is noise-dominated** — the gap is within the " + "seed-to-seed wobble and should not be leaned on.", + ) + parts.append("") + for key, pair in snr["pairs"].items(): + a, b = key.split("_vs_") + head = pair.get("headline") or {} + hn = pair.get("headline_N") + parts.append(f"**`{a}` vs `{b}`** — headline SNR at N={hn}: {_snr_cell(head)}") + parts.append("") + parts.append("| N | signal \\|Δ\\| | noise σ | SNR |") + parts.append("|---|--:|--:|--:|") + for rec in pair["by_n"]: + parts.append( + f"| {rec['N']} | {_fmt(rec.get('signal'))} | " + f"{_fmt(rec.get('noise'))} | {_snr_cell(rec)} |" + ) + parts.append("") + parts.append( + "\\* noise-dominated (SNR < 1). `clean (σ=0)` = an identical gap every " + "seed; `0 (tie)` = no gap and no variance." + ) + parts.append("") + return parts + + +def _scenario_health_section(dataset: dict) -> list[str]: + """Per-scenario discrimination/validity audit — how many scenarios actually + carry signal vs. are broken, contaminated, or ties (OpenAI's broken-task + ceiling analysis, applied here). `.get`-guarded on `per_scenario`. + """ + health = scenario_health(dataset) + if not health["total"]: + return [] + c = health["counts"] + parts = ["## Scenario health (discrimination audit)", ""] + bits = [f"**{c['discriminating']} discriminating**", f"{c['broken']} broken", + f"{c['contaminated']} contaminated", f"{c['tie']} tie"] + if c["unknown"]: + bits.append(f"{c['unknown']} unknown") + parts.append(f"Of {health['total']} scenarios: " + ", ".join(bits) + ".") + if not health["controls"]["ceiling"]: + parts.append("_(no `context_dump` ceiling arm in the sweep — 'broken' " + "cannot be detected.)_") + if not health["controls"]["floor"]: + parts.append("_(no `no_grounding` floor arm in the sweep — 'contaminated' " + "cannot be detected.)_") + flagged = [s for s in health["scenarios"] if s["class"] != "discriminating"] + if flagged: + parts += ["", "| scenario | class | ceiling adheres | floor adheres | max arm gap |", + "|---|---|:--:|:--:|--:|"] + _yn = {True: "yes", False: "no", None: "—"} + for s in flagged: + parts.append( + f"| `{s['scenario_id']}` | {s['class']} | " + f"{_yn[s['ceiling_adherent']]} | {_yn[s['floor_adherent']]} | " + f"{_fmt(s['max_gap'])} |" + ) + parts.append("") + parts.append( + "_broken = the see-everything arm never adheres (likely mis-specified); " + "contaminated = the parametric-memory floor already adheres (doesn't test " + "grounding); tie = no arm separates from another._" + ) + parts.append("") + return parts + + def _head_to_head(dataset: dict, run: dict) -> str: """rac vs naive_rag only — the comparison that actually adjudicates the thesis.""" arms = dataset["arms"] @@ -647,6 +752,12 @@ def build_report(run: dict, dataset: dict | None, cost_curve: dict | None, chart if dataset: parts += _per_scenario_section(dataset) + # 4a-bis. Signal-to-noise + scenario-health audit — is the result above the + # run-to-run noise, and how many scenarios actually carry signal? + if dataset: + parts += _snr_section(dataset) + parts += _scenario_health_section(dataset) + # 4b. Pre-registered paired significance (when the artifacts carry stats). parts += _stats_section(run, dataset) diff --git a/decisiongrounding/tests/test_dashboard.py b/decisiongrounding/tests/test_dashboard.py index 9844c9f..60d21c8 100644 --- a/decisiongrounding/tests/test_dashboard.py +++ b/decisiongrounding/tests/test_dashboard.py @@ -64,12 +64,33 @@ def test_build_dashboard_renders_all_sections(): out = build_dashboard(_run(), _dataset()) _assert_html(out) for marker in ("Decision Grounding Bench", "Leaderboard", "Adherence vs N", - "rac vs naive RAG", "Scenarios", "Reproduce", "