core/scripts/gsm8k_frontier_report.py
Shay 5f6e9f5e08
feat(derivation): Workstream A inc 2 — frontier report + rate_with_currency apply_rate injection (#797)
* 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.

* fix(derivation): dollar grounding, roundtrip proofs, stale comment, lookback commit, a/an containment, duplicate import

- math_roundtrip: add explicit 'dollar'/'dollars' + '$' branch in _unit_grounds (symmetric to cent) + doc alignment.
- tests: assert roundtrip_admissible True for the emitted rate cands; add a/an containment/confuser test (dollar unit only grounds with $ present); add lower-level _apply_rate solver reach test; remove conditional happy-paths that tolerated refusal; clean duplicate __future__.
- math_candidate_graph: update active stale comment (injector-empty now leads to explicit refusal, not drop/preserve-zero).
- Commit the Inc2 lookback (was local-only).

All per lead block list. New head will be pushed.

* fix(derivation): address remaining Inc2 blockers (locate_rate_verb via matcher token, dispatch test, lookback accuracy, narrow symbols, tests)

- recognizer_match: updated _CURRENCY_AMOUNT_RE + parsing to populate 'rate_anchor_token' localized to the rate span (prevents whole-sentence 'a' hazard from 'a lemonade stand').
- recognizer_anchor_inject: use 'rate_anchor_token' from anchor (with allowed-set check); fallback only if absent. Added Alexa-style confuser coverage.
- injector: narrowed _CURRENCY_SYMBOL_TO_UNIT to $ only for Inc2.
- tests: strengthened dispatch to require live-registry non-empty + roundtrip_admissible; added rate_anchor_token confuser test proving 'per' (not earlier 'a') is used; roundtrip asserts already present.
- lookback: updated to 10 files, 10+6 test counts, current head reality, shas/runner status exact, no rebaseline.
- math_candidate_graph comment already corrected in prior.

All Semantic Rigor / wrong=0 invariants preserved. New tip after push.

* fix(inc2): remove rate fallback to _locate_rate_verb; mandatory rate_anchor_token; hard 'for one cup' confuser; strengthen per-span test; update lookback

- injector: for currency_per_unit_rate, rate_anchor_token is now mandatory. Absent or invalid → immediate return ().
  No call to _locate_rate_verb for these anchors.
- tests: _rate_anchor helper now passes rate_anchor_token.
- Added hard confuser test for 'Alexa ...  for one cup.' — live registry + inject_from_match must emit ().
- Strengthened distracting-a ' per' test to unconditional: m not None, RATE_WITH_CURRENCY, len==1, matched_verb=='per', roundtrip_admissible True.
- lookback updated for 11 files, include matcher.py, rate_anchor_token centrality, exact counts after this.

* docs: pin exact final SHA in Inc2 lookback

* test(inc2): strengthen for_one_cup confuser to unconditional asserts on m and category

* docs(inc2): update lookback with exact test counts 18/6, 11 files, remove 9 files line, note final SHA after push

* docs(inc2): pin exact final SHA c4e83399... in lookback

* docs(inc2): pin exact final SHA 1129c502e6e8... in lookback

* docs(inc2): note no PR/CI yet in lookback

* docs(inc2): pin ultimate final SHA 7df0cac4... in lookback

* docs(inc2): final pin of SHA f0766b3b65... in lookback

* docs(inc2): ultimate pin of head deaa1a1e3fe0...

* docs(inc2): final pin of head ec1b5f32...

* docs(inc2): pin head 011e1e17... (the fixes tip)
2026-06-17 06:38:45 -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())