core/scripts/gsm8k_frontier_report.py
Shay 0afaa8d0fc feat(derivation): Workstream A inc 2 — frontier report + rate_with_currency apply_rate injection
- scripts/gsm8k_frontier_report.py + test (stable buckets; rate_with_currency surfaced)
- docs/recognizer-registry.md + math_candidate_graph.py comments repaired (current refusal doctrine; old skip-only marked historical)
- generate/math_roundtrip.py: add 'a','an' to RATE_ANCHORS (with doc update)
- generate/recognizer_anchor_inject.py: inject_rate_with_currency (narrow ProperName actor, Rate/apply_rate CandidateOperation, rejects unsafe); registered in _INJECTORS; module docs updated
- tests/test_*_rate_injection*.py + frontier test (8+ unit cases, confusers, synthetic wiring, real-report frontier pin)
- ratification doc (pre-code)
- lookback (post-impl, truthful)

All required local commands exercised (pytest green for new + prior extract/invariants; frontier script shows rate bucket; runner per brief; shas captured).

wrong=0 held. No sealed movement. Proxy still expected !passed (correct_min=10).

See ratification and lookback for scope, hazards, exact outputs.
2026-06-16 22:17:37 -07:00

160 lines
No EOL
5.8 KiB
Python

#!/usr/bin/env python3
"""Deterministic frontier analyzer for GSM8K train-sample proxy reports.
Reads a report.json (the exact artifact produced by
evals/gsm8k_math/train_sample/v1/runner.py) and emits a stable,
replayable bucket summary focused on the recognized-but-uninjected
frontier and other refusal classes.
Usage:
uv run python scripts/gsm8k_frontier_report.py \
evals/gsm8k_math/train_sample/v1/report.json
Output is JSON (sorted keys, deterministic) followed by a short
human-readable Markdown summary. No timestamps, no nondeterminism.
This tool is part of Workstream A Increment 2 measurement substrate.
It makes the "recognized_no_injection (category=rate_with_currency)"
class visible as a first-class, replayable artifact rather than
relying on ad-hoc reading of the raw report.
"""
from __future__ import annotations
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
# The exact refusal reason prefix emitted by math_candidate_graph
# when a recognizer match exists but the injector returned ().
_RECOGNIZED_NO_INJ = "candidate_graph: recognizer matched but produced no injection"
# Other canonical reason fragments observed in the proxy reports.
# Order here is for stable bucket priority (first match wins).
_BUCKET_PATTERNS: list[tuple[str, str]] = [
("wrong", "wrong"),
("fast-path", "fast_path_correct"),
("no admissible candidate for question", "no_admissible_question"),
("no admissible candidate for statement", "no_admissible_statement"),
("no solvable branch", "no_solvable_branch"),
("incomplete reading", "incomplete_reading"),
(_RECOGNIZED_NO_INJ, "recognized_no_injection"),
]
def _classify_reason(reason: str) -> str:
"""Map a per_case.reason string to a stable frontier bucket."""
if not reason:
return "other_refused"
r = reason.lower()
for needle, bucket in _BUCKET_PATTERNS:
if needle.lower() in r:
return bucket
if "refused" in r or not reason.strip():
return "other_refused"
return "other"
def _extract_category(reason: str) -> str | None:
"""For recognized_no_injection reasons, pull the (category=...) value."""
if _RECOGNIZED_NO_INJ not in reason:
return None
m = re.search(r"category=([a-zA-Z0-9_]+)", reason)
return m.group(1) if m else None
def analyze_report(report_path: Path | str) -> dict[str, Any]:
"""Pure function: return a deterministic summary dict for the report."""
p = Path(report_path)
data: dict[str, Any] = json.loads(p.read_text(encoding="utf-8"))
per_case = data.get("per_case", []) or []
counts: dict[str, int] = defaultdict(int)
no_inj_by_cat: dict[str, int] = defaultdict(int)
total_refused = 0
total_correct = 0
for case in per_case:
verdict = str(case.get("verdict", "")).lower()
reason = str(case.get("reason", "") or "")
if verdict == "correct":
total_correct += 1
bucket = _classify_reason(reason)
counts[bucket] += 1
continue
total_refused += 1
bucket = _classify_reason(reason)
counts[bucket] += 1
if bucket == "recognized_no_injection":
cat = _extract_category(reason)
if cat:
no_inj_by_cat[cat] += 1
# Stable ordering
ordered_counts = dict(sorted(counts.items()))
ordered_no_inj = dict(sorted(no_inj_by_cat.items()))
summary = {
"report_source": str(p),
"sample_count": data.get("sample_count", len(per_case)),
"counts": {
"correct": total_correct,
"refused": total_refused,
"total": total_correct + total_refused,
**ordered_counts,
},
"recognized_no_injection_by_category": ordered_no_inj,
"exit_criterion": data.get("exit_criterion", {}),
"adr": data.get("adr"),
"schema_version": data.get("schema_version"),
}
return summary
def render_markdown(summary: dict[str, Any]) -> str:
"""Stable human summary (no dates, sorted sections)."""
lines: list[str] = []
lines.append("# GSM8K train-sample frontier (deterministic report)")
lines.append("")
c = summary["counts"]
lines.append(f"- correct: {c.get('correct', 0)}")
lines.append(f"- refused: {c.get('refused', 0)}")
lines.append(f"- total: {c.get('total', 0)}")
lines.append("")
lines.append("## Refusal buckets (stable order)")
for k, v in summary["counts"].items():
if k in ("correct", "refused", "total"):
continue
lines.append(f"- {k}: {v}")
lines.append("")
if summary["recognized_no_injection_by_category"]:
lines.append("## recognized_no_injection by category (top frontier)")
for cat, n in summary["recognized_no_injection_by_category"].items():
lines.append(f"- {cat}: {n}")
else:
lines.append("## recognized_no_injection by category: (none)")
lines.append("")
ec = summary.get("exit_criterion", {})
lines.append(f"exit_criterion: correct_min={ec.get('correct_min')}, passed={ec.get('passed')}, wrong_max={ec.get('wrong_max')}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
if not argv:
print("Usage: scripts/gsm8k_frontier_report.py <report.json>", file=sys.stderr)
return 2
report_path = Path(argv[0])
if not report_path.exists():
print(f"ERROR: {report_path} does not exist", file=sys.stderr)
return 1
summary = analyze_report(report_path)
# Deterministic JSON to stdout first (machines)
json_out = json.dumps(summary, indent=2, sort_keys=True)
print(json_out)
print("\n---\n")
# Human MD
print(render_markdown(summary))
return 0
if __name__ == "__main__":
raise SystemExit(main())