* 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)
160 lines
No EOL
5.8 KiB
Python
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()) |