diff --git a/decisiongrounding/Makefile b/decisiongrounding/Makefile index 0a3a6c6..a49ade6 100644 --- a/decisiongrounding/Makefile +++ b/decisiongrounding/Makefile @@ -17,7 +17,10 @@ real-run: real-batch: BATCH=1 ./scripts/run_real.sh # compare + the adherence-vs-N crossover over a real pool (set NS to taste; add -# BATCH=1 for the Batch API, and SEEDS=0-4 for mean +/- CI error bars): +# BATCH=1 for the Batch API, and SEEDS=0-4 for mean +/- CI error bars). The +# default arms include rac + no_grounding, so both signal-to-noise contrasts +# (rac-vs-naive_rag, rac-vs-no_grounding) populate; SEEDS>=2 is REQUIRED for the +# signal-to-noise section (noise cannot be estimated from a single seed): real-crossover: CROSSOVER=1 ./scripts/run_real.sh diff --git a/decisiongrounding/runner/cli.py b/decisiongrounding/runner/cli.py index e5261ad..5b0736b 100644 --- a/decisiongrounding/runner/cli.py +++ b/decisiongrounding/runner/cli.py @@ -46,6 +46,7 @@ from scenarios.loader import Scenario, load_pool, load_scenarios # noqa: E402 from scoring import aggregate, score # noqa: E402 from scoring.crossover import ( # noqa: E402 + DEFAULT_PAIRS, build_dataset, build_dataset_batched, build_dataset_multiseed, @@ -727,7 +728,7 @@ def _on_cell(rec: dict) -> None: pool=pool, pool_dir=pool_dir, scenarios_dir=args.scenarios, batched=batch, poll=args.poll, progress=_on_cell) dataset = merge_seed_datasets(existing, new_ps, list(arms), list(ns), - ("rac", "naive_rag")) + list(DEFAULT_PAIRS)) elif seeds is not None: if len(seeds) > 1: print(f" (multi-seed: {seeds}; reporting mean +/- 95% CI)", file=sys.stderr) @@ -735,7 +736,7 @@ def _on_cell(rec: dict) -> None: scenarios, arms=arms, ns=ns, seeds=seeds, answering_model_name=args.answering, embedder_spec=args.embedder, pool=pool, pool_dir=pool_dir, scenarios_dir=args.scenarios, - batched=batch, poll=args.poll, pair=("rac", "naive_rag"), progress=_on_cell, + batched=batch, poll=args.poll, pairs=list(DEFAULT_PAIRS), progress=_on_cell, resume=resume_cells) elif batch: dataset = build_dataset_batched( diff --git a/decisiongrounding/runner/dashboard.py b/decisiongrounding/runner/dashboard.py index bbbc014..dca26f8 100644 --- a/decisiongrounding/runner/dashboard.py +++ b/decisiongrounding/runner/dashboard.py @@ -19,6 +19,8 @@ from scoring.charts import grouped_bar_chart, line_chart from scoring.cost import cost_by_arm, dollars +from scoring.health import scenario_health +from scoring.snr import signal_to_noise _DEFAULT_TEMPLATE = Path(__file__).resolve().parent / "templates" / "dashboard.html" @@ -267,6 +269,65 @@ def _scenarios_section(run): return matrix + "".join(drill) +def _snr_txt(rec: dict) -> str: + """Plain-text SNR cell (number, or why it isn't one).""" + snr = rec.get("snr") + if snr is not None: + return f"{snr:.2f}" + (" (noise-dominated)" if rec.get("noise_dominated") else "") + return {"zero_noise_clean_separation": "clean (σ=0)", + "no_effect": "0 (tie)"}.get(rec.get("flag"), "n/a") + + +def _signal_noise_section(dataset) -> str: + """The Signal / noise tab: SNR per contrast + the scenario-health audit.""" + snr = signal_to_noise(dataset) + health = scenario_health(dataset) + if not snr["pairs"] and not health["total"]: + return '
Needs a multi-seed crossover dataset.
' + out = [] + if snr["pairs"]: + out.append("Only 1 seed — seed-to-seed noise is not '
+ 'estimable, so SNR cannot be computed. Re-run with '
+ '--seeds 0-4.
SNR = |between-arm adherence gap| ÷ ' + 'across-seed σ of that gap. SNR < 1 is ' + 'noise-dominated.
') + for key, pair in snr["pairs"].items(): + a, b = key.split("_vs_") + head = pair.get("headline") or {} + out.append(f'{_esc(a)} vs {_esc(b)} — headline SNR ' + f'at N={pair.get("headline_N")}: {_esc(_snr_txt(head))}
') + rows = "".join( + f"| N | signal | " + f"noise σ | SNR |
|---|
Of {health["total"]} scenarios: ' + f'{c["discriminating"]} discriminating, {c["broken"]} broken, ' + f'{c["contaminated"]} contaminated, {c["tie"]} tie{extra}.
') + flagged = [s for s in health["scenarios"] if s["class"] != "discriminating"] + if flagged: + rows = "".join( + f"{_esc(s['scenario_id'])}| scenario | class | " + f"max arm gap |
|---|
broken = ceiling arm never adheres; ' + 'contaminated = parametric-memory floor already adheres; ' + 'tie = no arm separates.
') + return "".join(out) + + def _run_tab(paid: bool) -> str: if paid: real = ( @@ -287,7 +348,7 @@ def _run_tab(paid: bool) -> str: "DG_UI_ALLOW_PAID=1 (and ANTHROPIC_API_KEY) to enable; "
"you'll still have to estimate and tick a confirmation before any spend.")
return (
- "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/scoring/__init__.py b/decisiongrounding/scoring/__init__.py index 8ce2072..4ae605d 100644 --- a/decisiongrounding/scoring/__init__.py +++ b/decisiongrounding/scoring/__init__.py @@ -9,11 +9,15 @@ summarize, t_critical_95, ) +from .health import scenario_health from .scorer import Score, score +from .snr import signal_to_noise __all__ = [ "Score", "score", + "signal_to_noise", + "scenario_health", "ArmMetrics", "aggregate", "adherence_variance", diff --git a/decisiongrounding/scoring/crossover.py b/decisiongrounding/scoring/crossover.py index e27db5e..3b7215e 100644 --- a/decisiongrounding/scoring/crossover.py +++ b/decisiongrounding/scoring/crossover.py @@ -617,26 +617,33 @@ def build_dataset_multiseed( scenarios_dir: str | None = None, batched: bool = False, poll: int = 20, - pair: tuple[str, str] = ("rac", "naive_rag"), + pair: "tuple[str, str] | None" = None, + pairs: "list[tuple[str, str]] | None" = None, progress: "Callable[[dict], None] | None" = None, resume: "dict[tuple[int, int, str, str], dict] | None" = None, ) -> dict: """Run the crossover over several seeds and aggregate per (arm, N) into mean +/- a t-based 95% CI. The plain fields stay the mean (backward - compatible); `_ci` / `_std` / `_values`, `n_seeds`, `seeds`, and a - paired `pair[0]`-vs-`pair[1]` adherence difference (`paired`) are added. + compatible); ` _ci` / `_std` / `_values`, `n_seeds`, `seeds`, and + paired adherence differences (`paired`) are added. + + `pairs` selects the arm contrasts for the paired diffs (default + `DEFAULT_PAIRS`: rac-vs-naive_rag and rac-vs-no_grounding); a single + `pair=(a, b)` tuple is still accepted for back-compat. A contrast whose + arms aren't both in the sweep is skipped. Calls the single-seed builders per seed, so batched + multiseed compose. Offline runs (deterministic stub + embedder) show little spread; the aggregation is exercised regardless. """ + resolved = pairs if pairs is not None else ([pair] if pair is not None else DEFAULT_PAIRS) uniq = list(dict.fromkeys(int(s) for s in seeds)) or [0] per_seed = run_seeds( scenarios, arms, ns, uniq, answering_model_name=answering_model_name, embedder_spec=embedder_spec, pool=pool, pool_dir=pool_dir, scenarios_dir=scenarios_dir, batched=batched, poll=poll, progress=progress, resume=resume) - return _aggregate_seeds(per_seed, list(arms), list(ns), pair) + return _aggregate_seeds(per_seed, list(arms), list(ns), resolved) def run_seeds( @@ -710,10 +717,28 @@ def _aggregate_arm_points(arms, ns, cols, n_seeds) -> dict: return points -def _paired(per_seed, ns, pair) -> dict | None: - """Per-N paired adherence difference pair[0]-pair[1], differenced within each - seed (common random numbers), with its own CI.""" - a, b = pair +# The arm contrasts whose paired adherence difference (and thus signal-to-noise) +# the multi-seed crossover reports by default: the pre-registered falsifier +# (rac vs naive_rag, H1) and the parametric-memory floor (rac vs no_grounding, +# H2). A pair whose arms aren't both in the sweep is silently skipped, so this +# default is safe for any arm subset (offline runs without rac emit neither). +DEFAULT_PAIRS = (("rac", "naive_rag"), ("rac", "no_grounding")) + + +def _normalize_pairs(pairs) -> list[tuple[str, str]]: + """Accept a single ``(a, b)`` tuple or a list of them; return a list of + tuples. ``None``/empty -> ``[]``.""" + if not pairs: + return [] + if isinstance(pairs[0], str): # a bare (a, b) tuple + return [(pairs[0], pairs[1])] + return [(p[0], p[1]) for p in pairs] + + +def _paired_one(per_seed, ns, a, b) -> list | None: + """Per-N paired adherence difference a-b, differenced within each seed + (common random numbers), with its own CI. None when either arm is absent + from any seed's sweep.""" if not per_seed or not all(a in ds["arms"] and b in ds["arms"] for _, ds in per_seed): return None out = [] @@ -727,7 +752,23 @@ def _paired(per_seed, ns, pair) -> dict | None: s = summarize(diffs) out.append({"N": n, "diff_mean": s["mean"], "diff_ci": s["ci"], "diff_std": s["std"], "n": s["n"], "values": s["values"]}) - return {f"{a}_vs_{b}": out} if out else None + return out or None + + +def _paired(per_seed, ns, pairs) -> dict | None: + """Per-N within-seed paired adherence differences for one or more arm pairs. + + `pairs` accepts a single ``(a, b)`` tuple (back-compat) or a list of them. + Each pair whose both arms appear in every seed's sweep contributes a + ``"{a}_vs_{b}"`` entry; pairs with a missing arm are skipped. The + across-seed std of each entry (`diff_std`) is the noise term the + signal-to-noise report divides into (see `scoring.snr`).""" + result = {} + for a, b in _normalize_pairs(pairs): + series = _paired_one(per_seed, ns, a, b) + if series: + result[f"{a}_vs_{b}"] = series + return result or None def _agg_per_scenario(per_seed, arms) -> dict: @@ -755,7 +796,7 @@ def _agg_per_scenario(per_seed, arms) -> dict: return out -def _aggregate_seeds(per_seed, arms, ns, pair) -> dict: +def _aggregate_seeds(per_seed, arms, ns, pairs) -> dict: base = dict(per_seed[0][1]) cols = _columns_from_datasets(per_seed, arms, ns) base["arms"] = _aggregate_arm_points(arms, ns, cols, len(per_seed)) @@ -769,7 +810,7 @@ def _aggregate_seeds(per_seed, arms, ns, pair) -> dict: cells = [c for _, ds in per_seed for c in (ds.get("cells") or [])] base["cells"] = cells base["stats"] = stats_by_n(cells) if cells else None - paired = _paired(per_seed, ns, pair) + paired = _paired(per_seed, ns, pairs) if paired: base["paired"] = paired return base @@ -808,13 +849,19 @@ def merge_seed_datasets(existing: dict, new_per_seed, arms, ns, pair) -> dict: base = dict(existing) base["arms"] = _aggregate_arm_points(arms, ns, cols, len(all_seeds)) - # Paired: prior per-seed diffs + the new seeds' diffs. - a, b = pair - old_paired = (existing.get("paired") or {}).get(f"{a}_vs_{b}") - new_paired = (_paired(add, ns, pair) or {}).get(f"{a}_vs_{b}") - if old_paired is not None or new_paired is not None: - old_by_n = {e["N"]: e for e in (old_paired or [])} - new_by_n = {e["N"]: e for e in (new_paired or [])} + # Paired: prior per-seed diffs + the new seeds' diffs, for every contrast — + # the requested pairs plus any already present in `existing` (so an + # under-specified merge never silently drops a contrast). Arm names contain + # underscores, but "_vs_" is a safe delimiter for the existing keys. + requested = _normalize_pairs(pair) + keys = {f"{a}_vs_{b}": (a, b) for a, b in requested} + for k in (existing.get("paired") or {}): + keys.setdefault(k, tuple(k.split("_vs_"))) + new_paired = _paired(add, ns, list(keys.values())) or {} + merged_paired = {} + for key in keys: + old_by_n = {e["N"]: e for e in (existing.get("paired") or {}).get(key, [])} + new_by_n = {e["N"]: e for e in new_paired.get(key, [])} merged = [] for n in ns: vals = list(old_by_n.get(n, {}).get("values", [])) + list(new_by_n.get(n, {}).get("values", [])) @@ -823,7 +870,9 @@ def merge_seed_datasets(existing: dict, new_per_seed, arms, ns, pair) -> dict: merged.append({"N": n, "diff_mean": s["mean"], "diff_ci": s["ci"], "diff_std": s["std"], "n": s["n"], "values": s["values"]}) if merged: - base["paired"] = {f"{a}_vs_{b}": merged} + merged_paired[key] = merged + if merged_paired: + base["paired"] = merged_paired base["per_scenario"] = _merge_per_scenario(existing.get("per_scenario", {}), add, arms, n_old) base["errors"] = list(existing.get("errors", [])) + [e for _, ds in add for e in ds["errors"]] diff --git a/decisiongrounding/scoring/health.py b/decisiongrounding/scoring/health.py new file mode 100644 index 0000000..d0f4b79 --- /dev/null +++ b/decisiongrounding/scoring/health.py @@ -0,0 +1,106 @@ +"""Per-scenario discrimination / validity audit. + +OpenAI's coding-eval analysis found ~20-30% of a popular benchmark's tasks were +broken — unsolvable, contaminated, or non-discriminating — silently capping the +ceiling. This audit surfaces the analogue for the crossover: for each scenario, +is it actually pulling its weight, or is it dead signal? + +Four classes, referencing the two controls already in the sweep — the ceiling +arm (`context_dump`, sees the whole corpus) and the floor arm (`no_grounding`, +parametric memory only): + +- **broken** — even the ceiling arm never adheres (likely mis-specified or + unsolvable from the corpus). +- **contaminated** — the floor arm adheres (the model answers from pretraining + memory, so the scenario doesn't test grounding — a real risk for public + PEPs/RFCs; synthetic scenarios are contamination-proof by construction). +- **tie** — no arm separates from any other at any N (degenerate, no signal). +- **discriminating** — otherwise (grounding separates from the floor). + +`unknown` when neither control arm is in the sweep. Pure and results-neutral: +reads only `dataset["per_scenario"]`. +""" + +from __future__ import annotations + +CEILING_ARM = "context_dump" +FLOOR_ARM = "no_grounding" +_ADHERE = 0.5 # majority; robust to multi-seed fractions and single-seed bools + + +def _adherence_by_n(per_scenario: dict, arm: str, sid: str) -> dict: + """{N: adherence} for one (arm, scenario); adherent may be a bool + (single-seed) or a fraction (multi-seed) — both coerce to float.""" + out = {} + for rec in per_scenario.get(arm, {}).get(sid, []): + v = rec.get("adherent") + if v is not None: + out[rec["N"]] = float(v) + return out + + +def _adheres_somewhere(by_n: dict) -> bool: + return any(v >= _ADHERE for v in by_n.values()) + + +def scenario_health(dataset: dict) -> dict: + """Classify every scenario in `dataset["per_scenario"]`. + + Returns:: + + {"scenarios": [{"scenario_id", "class", "ceiling_adherent", + "floor_adherent", "separates", "max_gap"}, ...], + "counts": {"discriminating", "broken", "contaminated", "tie", + "unknown"}, + "total": int, + "controls": {"ceiling": bool, "floor": bool}} + + `.get`-guarded: a dataset without `per_scenario` yields an empty audit. + """ + per = dataset.get("per_scenario") or {} + arms = list(per) + has_ceiling = CEILING_ARM in arms + has_floor = FLOOR_ARM in arms + sids = sorted({sid for arm in arms for sid in per.get(arm, {})}) + + scenarios = [] + counts = {"discriminating": 0, "broken": 0, "contaminated": 0, "tie": 0, "unknown": 0} + for sid in sids: + by_arm = {arm: _adherence_by_n(per, arm, sid) for arm in arms} + ns = sorted({n for b in by_arm.values() for n in b}) + + # Between-arm separation: the largest adherence gap at any shared N. + max_gap = 0.0 + for n in ns: + vals = [b[n] for b in by_arm.values() if n in b] + if len(vals) >= 2: + max_gap = max(max_gap, max(vals) - min(vals)) + separates = max_gap > 1e-9 + + ceiling_adherent = _adheres_somewhere(by_arm[CEILING_ARM]) if has_ceiling else None + floor_adherent = _adheres_somewhere(by_arm[FLOOR_ARM]) if has_floor else None + + if not (has_ceiling or has_floor): + cls = "unknown" + elif has_ceiling and not ceiling_adherent: + cls = "broken" + elif has_floor and floor_adherent: + cls = "contaminated" + elif not separates: + cls = "tie" + else: + cls = "discriminating" + + counts[cls] += 1 + scenarios.append({ + "scenario_id": sid, "class": cls, + "ceiling_adherent": ceiling_adherent, "floor_adherent": floor_adherent, + "separates": separates, "max_gap": max_gap, + }) + + return { + "scenarios": scenarios, + "counts": counts, + "total": len(sids), + "controls": {"ceiling": has_ceiling, "floor": has_floor}, + } diff --git a/decisiongrounding/scoring/snr.py b/decisiongrounding/scoring/snr.py new file mode 100644 index 0000000..6fcdabc --- /dev/null +++ b/decisiongrounding/scoring/snr.py @@ -0,0 +1,79 @@ +"""Signal-to-noise for the crossover. + +Ai2's "Signal and Noise" framework (and OpenAI's coding-eval analysis) make the +same point: a benchmark result is only trustworthy when the effect it measures +is large relative to the run-to-run noise. Here the **signal** is the within-seed +paired adherence difference between two arms (`diff_mean` in +`dataset["paired"]`), and the **noise** is that difference's spread across seeds +(`diff_std`, under common random numbers). Their ratio is the signal-to-noise: + + SNR = |diff_mean| / diff_std + +An SNR below 1 means the between-arm gap is smaller than the seed-to-seed +wobble — noise-dominated, not a result to lean on. Crucially, **noise cannot be +estimated from a single seed**: with `n_seeds < 2` the SNR is reported as "not +estimable", never as a fabricated number. + +Pure and results-neutral: reads only the already-computed `paired` block, adds +nothing to the dataset. +""" + +from __future__ import annotations + + +def _snr_at(diff_mean: float, diff_std: "float | None", n_seeds: int) -> dict: + """Classify one (pair, N) cell into an SNR record.""" + signal = abs(diff_mean) + if n_seeds < 2 or diff_std is None: + # No repeated runs -> noise is unmeasured. The article's core caveat. + return {"signal": signal, "noise": diff_std, "snr": None, + "noise_dominated": False, "clean_separation": False, + "flag": "noise_not_estimable"} + if diff_std == 0: + if signal > 0: + # Every seed gave the same nonzero gap: maximally clean signal. + return {"signal": signal, "noise": 0.0, "snr": None, + "noise_dominated": False, "clean_separation": True, + "flag": "zero_noise_clean_separation"} + # Every seed a perfect tie: no effect and no variance. + return {"signal": 0.0, "noise": 0.0, "snr": None, + "noise_dominated": False, "clean_separation": False, + "flag": "no_effect"} + snr = signal / diff_std + return {"signal": signal, "noise": diff_std, "snr": snr, + "noise_dominated": snr < 1.0, "clean_separation": False, "flag": None} + + +def signal_to_noise(dataset: dict) -> dict: + """Signal-to-noise per arm contrast and per N, from `dataset["paired"]`. + + Returns:: + + {"n_seeds": int, + "pairs": {"_vs_": {"by_n": [{"N", "signal", "noise", "snr", + "noise_dominated", "clean_separation", + "flag"}, ...], + "headline_N": int|None, + "headline": |None}, + ...}} + + `.get`-guarded: a dataset without `paired` (single-arm, legacy, or + single-contrast) yields an empty `pairs`. The headline is taken at the + largest N — the buried-in-distractors corpus size where the thesis is + actually adjudicated. + """ + paired = dataset.get("paired") or {} + n_seeds = int(dataset.get("n_seeds", 1) or 1) + pairs: dict[str, dict] = {} + for key, series in paired.items(): + by_n = [] + for e in series: + rec = {"N": e["N"], **_snr_at(e["diff_mean"], e.get("diff_std"), n_seeds)} + by_n.append(rec) + headline = max(by_n, key=lambda r: r["N"]) if by_n else None + pairs[key] = { + "by_n": by_n, + "headline_N": headline["N"] if headline else None, + "headline": headline, + } + return {"n_seeds": n_seeds, "pairs": pairs} diff --git a/decisiongrounding/scripts/report.py b/decisiongrounding/scripts/report.py index 8e3559a..3796a71 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"] @@ -501,6 +606,15 @@ def _mcnemar_line(dataset: dict, top) -> str | None: permit / cite / supersede), not prose quality. - **If naive RAG does not decay, the report says so.** The head-to-head verdict is computed from the numbers, not asserted. +- **Signal is reported against noise, and errors are not failures.** The + Signal-to-noise section divides the between-arm gap by its across-seed spread + so a reader can see whether the result is above run-to-run noise (it needs + ≥2 seeds; a single-seed run says so rather than inventing a ratio). Cells that + errored or exceeded the context window are excluded from adherence and + reported as coverage — an infrastructure failure is never scored as a + behavioural one. The scenario-health audit flags any scenario that is broken, + contaminated (answerable from parametric memory), or non-discriminating. + Methodology: `spec/methodology-signal-to-noise.md`. """ @@ -647,6 +761,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/scripts/run_real.sh b/decisiongrounding/scripts/run_real.sh index 4406fa9..188ed6d 100755 --- a/decisiongrounding/scripts/run_real.sh +++ b/decisiongrounding/scripts/run_real.sh @@ -116,8 +116,10 @@ python3 -m runner.cli "$HEADLINE_CMD" \ # standard price, and it runs server-side so a client/container restart can't # lose it — strongly recommended for the full N sweep). # SEEDS (e.g. SEEDS=0-4) runs the sweep over several seeds and reports -# mean +/- 95% CI with a paired rac-vs-naive_rag difference. Cost multiplies -# with the number of seeds. +# mean +/- 95% CI with paired rac-vs-naive_rag and rac-vs-no_grounding +# differences. SEEDS with >=2 seeds is required for the report's +# signal-to-noise section (noise is not estimable from one seed). Cost +# multiplies with the number of seeds. if [ "$CROSSOVER" = "1" ]; then [ -d "$POOL/corpus" ] || python3 -m ingest.peps pool build --out "$POOL" echo "== crossover (real distractors) ==" diff --git a/decisiongrounding/spec/methodology-signal-to-noise.md b/decisiongrounding/spec/methodology-signal-to-noise.md new file mode 100644 index 0000000..e897acd --- /dev/null +++ b/decisiongrounding/spec/methodology-signal-to-noise.md @@ -0,0 +1,90 @@ +# Methodology — signal-to-noise and scenario health + +*Additive methodology note (not a pre-registration amendment). It documents how +the reporting layer separates signal from noise; it changes no gold labels, no +scoring rubric, and none of the confirmatory tests in +`analysis-plan-amendment-1.md` / `-2.md` (both frozen).* + +## Why + +Two analyses of coding/LM evaluations converge on the same warning: + +- OpenAI, *["Separating signal from noise in coding evaluations"](https://openai.com/index/separating-signal-from-noise-coding-evaluations/)* — a + widely-used benchmark was found to have ~20–30% of tasks broken (unsolvable, + or verified against hidden assumptions), so failures reflected the harness, + not the model. A trustworthy eval must (a) separate genuine model failures + from harness/grading noise, and (b) know how many of its tasks actually carry + signal. +- Ai2, *["Signal and Noise"](https://allenai.org/blog/signal-noise)* — a + benchmark is only reliable when its **signal** (spread between systems) is + large relative to its **noise** (run-to-run variance). They report a + signal-to-noise ratio and select high-SNR subtasks. + +This benchmark already avoids the largest noise source — grading — by scoring +structurally and deterministically (no LLM judge). This note adds the two +missing pieces: an explicit signal-to-noise ratio, and a per-scenario validity +audit. + +## Signal-to-noise (SNR) + +For an arm contrast `(a, b)` at corpus size `N`: + + signal = |mean over seeds of (adherence_a − adherence_b)| # dataset["paired"][…]["diff_mean"] + noise = std over seeds of (adherence_a − adherence_b) # …["diff_std"] + SNR = signal / noise + +The difference is taken **within each seed** (common random numbers), so `noise` +is the genuine seed-to-seed wobble of the *gap*, not of either arm alone. + +- **SNR < 1** — the between-arm gap is smaller than the run-to-run noise: + **noise-dominated**, not a result to lean on. Flagged with `*` in the report. +- **noise cannot be estimated from one seed.** With `n_seeds < 2` the report + says "noise not estimable" and prints no ratio — the single most important + honesty guard, and the direct lesson from both articles. Run `SEEDS=0-4` + (`make real-crossover SEEDS=0-4`) to measure it. +- Edge cases: an identical nonzero gap every seed is `clean (σ=0)` (maximal + signal); an identical zero gap every seed is `0 (tie)` (no effect). + +Reported for both pre-registered contrasts: `rac`-vs-`naive_rag` (H1, the +falsifier) and `rac`-vs-`no_grounding` (H2, grounding vs the parametric-memory +floor). Headline SNR is quoted at the largest `N` — the buried-in-distractors +corpus size where the thesis is actually adjudicated. Implementation: +`scoring/snr.py`. + +## Scenario health (discrimination / validity audit) + +Each scenario is classified against the two control arms already in the sweep — +the ceiling (`context_dump`, sees the whole corpus) and the floor +(`no_grounding`, parametric memory only): + +| class | condition | meaning | +|---|---|---| +| **broken** | ceiling never adheres at any N | likely mis-specified / unsolvable from the corpus | +| **contaminated** | floor adheres | answerable from pretraining memory — doesn't test grounding | +| **tie** | no arm separates from another at any N | degenerate, contributes no signal | +| **discriminating** | otherwise | grounding separates from the floor | +| **unknown** | neither control arm in the sweep | cannot audit | + +The summary count ("X of Y discriminating") is this benchmark's analogue of +OpenAI's broken-task ceiling. Implementation: `scoring/health.py`. + +## Contamination defense + +Real PEPs/RFCs are in the answering model's pretraining, so it may "know" that +PEP 440 supersedes PEP 386 independent of the grounding it is given. Two +defenses, both already in the design and now surfaced: + +1. **Synthetic scenarios are contamination-proof by construction** — the + `scenarios/` bank invents fictional ADRs the model cannot have seen. +2. **The `no_grounding` floor detects contamination in the real scenarios** — + if parametric memory alone adheres, the scenario is flagged `contaminated` + by the health audit and its adherence is not evidence that grounding helped. + +## Relationship to the confirmatory analysis + +SNR and scenario health are **descriptive reporting**, computed from the same +`paired` / `per_scenario` data the frozen McNemar analysis already uses. They +add no hypothesis and change no gate: H1/H2 remain exactly as pre-registered in +amendment-1. They exist so a reader — or a hostile reviewer — can judge for +themselves whether a reported gap is signal or noise, and how much of the +roster is actually pulling its weight. 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", "