arborist/bench/scripts/nli_shadow_sweep.py
russell@unturf.com 70ecda3d6c
#000049 Phase 2: NLI shadow scaffold in arborist (§7 #19)
arborist/qa/nli/ — SHADOW ONLY (never an audit_mode input; manifest not
yet in governance_policy_hash per §7 #2). manifest.json pins
cross-encoder/nli-MiniLM2-L6-H768 @ a fixed HF revision + the
bench-validated θc 0.5/θe 0.9 + 2 alternates + the Phase-3 TODO;
shadow.py = ShadowNLI/shadow_check (lazy transformers+torch behind a new
[nli] extra, clauses() segmenter, the §7 #5 clause-level Demote()
decision, degrades to available=False when [nli] absent);
bench/scripts/nli_shadow_sweep.py + make bootstrap-nli / bench-nli-shadow
(the gate-item-4 instrument); 16 tests.

First sweep (116 records — 5f-falsification packs + the arborist-nli-bench
eval sets): 28/28 synth recombination demoted, 0/26 FP on legit summaries,
0/9 fires on already-STRICT_SPAN records, 25/50 on UNGROUNDED (the
contradiction half; quiet on non-sequiturs). Gate items 1/2/3/5/6 clear
on available data; item 4 — shadow FP rate on a real live-bench-qa
sample — remains the open measurement. Production verifier unchanged;
falsification-hard stays 10/12.
2026-05-12 14:14:21 -04:00

158 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""NLI shadow sweep — #000049 Phase 2 / §7 #12 gate item 4 instrument.
Runs the clause-level shadow check (``arborist.qa.nli.shadow_check`` —
the §7 #5 algorithm) over a set of (answer, context) records and reports
the *would-demote* rate, bucketed by the verifier label the record
carries. SHADOW ONLY: writes nothing to any shard, touches no
``audit_mode``.
Two record shapes are accepted (auto-detected per line):
- 5f-fixture shape: ``{"answer_text": …, "context": …,
"expected_reason": …}`` (e.g. ``bench/fixtures/5f/*.jsonl``;
``_meta`` lines are skipped). ``expected_reason`` is the bucket.
- nli-bench eval shape: ``{"claim": …, "source": …, "want": …}``
(e.g. ``~/git/arborist-nli-bench/eval/*.jsonl``). ``want`` is the
bucket; ``want="not_contradiction"`` rows are the false-positive
probe — any would_demote on those is a shadow FP.
With no ``--input`` it sweeps the 5f falsification packs that have
``answer_text``/``context`` columns. Requires the ``[nli]`` extra to
produce real numbers — without it the report still renders, marked
``available: false`` (so the harness/CI never breaks).
Usage:
python3 bench/scripts/nli_shadow_sweep.py
python3 bench/scripts/nli_shadow_sweep.py --input path/to/records.jsonl --input more.jsonl
python3 bench/scripts/nli_shadow_sweep.py --out bench/results/nli-shadow-sweep.json
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
_DEFAULT_INPUTS = [
REPO / "bench" / "fixtures" / "5f" / "falsification-hard-v1.jsonl",
REPO / "bench" / "fixtures" / "5f" / "falsification-live-v1.jsonl",
REPO / "bench" / "fixtures" / "5f" / "falsification-v1.jsonl",
]
def _records(path: Path):
for ln in path.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
obj = json.loads(ln)
if "_meta" in obj:
continue
claim = obj.get("answer_text") or obj.get("claim")
source = obj.get("context") or obj.get("source")
if not claim or not source:
continue
bucket = obj.get("expected_reason") or obj.get("want") or "unlabeled"
is_fp_probe = (obj.get("want") == "not_contradiction")
yield {"id": obj.get("id", path.stem), "claim": claim, "source": source,
"bucket": bucket, "is_fp_probe": is_fp_probe, "src_file": path.name}
def main(argv=None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", type=Path, help="JSONL record file(s); repeatable")
ap.add_argument("--out", type=Path, default=REPO / "bench" / "results" / "nli-shadow-sweep.json")
args = ap.parse_args(argv)
inputs = args.input or [p for p in _DEFAULT_INPUTS if p.exists()]
recs = []
for p in inputs:
if not p.exists():
print(f"[nli-shadow] skip (missing): {p}", file=sys.stderr)
continue
n0 = len(recs)
recs.extend(_records(p))
print(f"[nli-shadow] {p.name}: {len(recs) - n0} records", flush=True)
if not recs:
print("[nli-shadow] no usable records", file=sys.stderr)
return 2
from arborist.qa.nli import ShadowNLI
nli = ShadowNLI()
by_bucket: dict[str, dict] = defaultdict(lambda: {"n": 0, "would_demote": 0})
fp_probe = {"n": 0, "would_demote": 0}
rows = []
n_available = 0
t0 = time.time()
for r in recs:
res = nli.check(r["claim"], r["source"])
if res.available:
n_available += 1
b = by_bucket[r["bucket"]]
b["n"] += 1
if res.would_demote:
b["would_demote"] += 1
if r["is_fp_probe"]:
fp_probe["n"] += 1
if res.would_demote:
fp_probe["would_demote"] += 1
rows.append({**{k: r[k] for k in ("id", "bucket", "is_fp_probe", "src_file")},
**res.as_dict()})
elapsed = time.time() - t0
available = n_available > 0
reason = "ok" if available else (rows[0]["reason"] if rows else "no rows")
total_demote = sum(b["would_demote"] for b in by_bucket.values())
report = {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"available": available,
"reason": reason,
"model_version": nli.model_version,
"theta_contra": nli.theta_contra,
"theta_entail": nli.theta_entail,
"n_records": len(recs),
"n_available": n_available,
"elapsed_seconds": round(elapsed, 1),
"would_demote_total": total_demote,
"would_demote_rate": round(total_demote / len(recs), 4) if recs else 0.0,
"by_bucket": {k: {**v, "rate": round(v["would_demote"] / v["n"], 4) if v["n"] else 0.0}
for k, v in sorted(by_bucket.items())},
"false_positive_probe": {**fp_probe,
"rate": round(fp_probe["would_demote"] / fp_probe["n"], 4) if fp_probe["n"] else None,
"note": "would_demote on records labeled want=not_contradiction — these are shadow FALSE POSITIVES; this is §7 #12 gate item 4 when the input is a real legit-answer sample"},
"rows": rows,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, indent=2))
print(f"[nli-shadow] wrote {args.out}")
print("\n" + "=" * 72)
print("NLI SHADOW SWEEP — #000049 Phase 2 (would-demote rate, shadow only)")
print("=" * 72)
if not available:
print(f" model UNAVAILABLE — {reason}")
print(" (install the runtime to get real numbers: pip install 'arborist[nli]')")
print(f" swept {len(recs)} records structurally; would-demote rate not measured.")
return 0
print(f" model {nli.model_version} · θc={nli.theta_contra} θe={nli.theta_entail} · {len(recs)} records · {elapsed:.1f}s")
print(f" would_demote overall: {total_demote}/{len(recs)} = {report['would_demote_rate']:.3f}")
print(f" {'bucket':<28} {'n':>5} {'would_demote':>13} {'rate':>7}")
print(" " + "-" * 56)
for k, v in report["by_bucket"].items():
print(f" {k:<28} {v['n']:>5} {v['would_demote']:>13} {v['rate']:>7.3f}")
print(" " + "-" * 56)
fpp = report["false_positive_probe"]
if fpp["n"]:
print(f" false-positive probe (want=not_contradiction): {fpp['would_demote']}/{fpp['n']} = {fpp['rate']:.3f} ← lower is better; 0 clears §7 #12 item 2")
else:
print(" (no want=not_contradiction rows in this input — point --input at a legit-answer sample for the real gate-item-4 number)")
print(" NB: on falsification-* packs every record is a FALSE claim, so a high would_demote rate there is the model working as intended — not a false positive.")
return 0
if __name__ == "__main__":
raise SystemExit(main())