core/evals/perception/read_rate.py
Shay 472fc0a83f feat(evals,tests): Perception Arc Phase 0 — the read-rate instrument and floor ratchet (G-21, G-24)
The 1.28% is now an instrument instead of testimony. evals/perception/read_rate.py
measures the logic reader over holdout_dev/v1 deterministically (23/1,798
sentences, refusal taxonomy included, 0.57s); tests/test_read_rate_floor.py pins
it in both directions — a comprehension regression fails the gate, and an
improvement must move the recorded constants in the same reviewed commit, so
capability motion is a decision with a diff, never an accident with a story.
Non-vacuity guarded (the matcher must still say no; counts must conserve).
Two sabotages observed red. Registered in smoke (~0.8s).

This is one of exactly two old-plan items that survive the keel triage (the
other is FA-1): it is the baseline number the keel's K4 perception layer is
accountable for moving in chunks. PR-8, PR-10, Track B widening and Track C
are superseded by keel phases K6/K6/K4/K5 and are not being finished here —
finishing them would be polishing the quarry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wcw2pnMBwyvmNyQg4uPEt4
2026-07-28 13:16:57 -07:00

97 lines
3.5 KiB
Python

"""The read-rate instrument — what fraction of real English the reader actually reads.
Perception Arc Phase 0 (G-21's open ask, G-24's headline number, the keel's baseline).
The 2026-07-28 diagnosis measured, ad hoc, that ``generate.meaning_graph.reader``
reads **23 of 1,798** sentences of `holdout_dev/v1` (1.28%), with 93.16% failing as
``no_template_match`` — upstream of vocabulary, upstream of every flag. That number
is the binding constraint every substrate experiment independently hit, and it is
the metric the keel's perception layer (K4) is accountable for moving in chunks.
An ad-hoc measurement is testimony. This module is the instrument: deterministic,
committed, and pinned by ``tests/test_read_rate_floor.py`` as a floor ratchet — the
recorded counts may only be updated deliberately, and only upward for ``read``.
Deliberately narrow: the LOGIC reader at sentence granularity. The math reader's
decision rate (5/500 holdout cases, G-21) is recorded where it was measured
(`docs/analysis/relational-operator-ablation-dossier-2026-07-19.md`, split table)
and will get its own lane if it ever moves; folding a slow full-solve sweep into
this fast lane would couple two instruments that fail differently.
"""
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from generate.meaning_graph.reader import Refusal, _split_sentences, comprehend
_ROOT = Path(__file__).resolve().parents[2]
CORPUS = _ROOT / "evals" / "gsm8k_math" / "holdout_dev" / "v1" / "cases.jsonl"
@dataclass(frozen=True)
class ReadRateReport:
corpus: str
problems: int
sentences: int
read: int
refusals: tuple[tuple[str, int], ...] # (reason, count), descending
@property
def read_rate(self) -> float:
return self.read / self.sentences if self.sentences else 0.0
def as_dict(self) -> dict:
return {
"corpus": self.corpus,
"problems": self.problems,
"sentences": self.sentences,
"read": self.read,
"read_rate": round(self.read_rate, 6),
"refusals": dict(self.refusals),
}
def measure(corpus_path: Path = CORPUS) -> ReadRateReport:
"""Run the logic reader over every sentence of the corpus. Deterministic."""
problems = [json.loads(line) for line in corpus_path.read_text(encoding="utf-8").splitlines() if line.strip()]
reasons: Counter[str] = Counter()
read = 0
sentences = 0
for case in problems:
for sentence, *_rest in _split_sentences(case["problem"]):
sentence = sentence.strip()
if not sentence:
continue
sentences += 1
try:
result = comprehend(sentence)
except Exception as exc: # a crashing reader is a finding, not a skip
reasons[f"EXCEPTION:{type(exc).__name__}"] += 1
continue
if isinstance(result, Refusal):
reasons[result.reason] += 1
elif result is None:
reasons["returned_none"] += 1
else:
read += 1
return ReadRateReport(
corpus=str(corpus_path.relative_to(_ROOT)),
problems=len(problems),
sentences=sentences,
read=read,
refusals=tuple(sorted(reasons.items(), key=lambda kv: (-kv[1], kv[0]))),
)
def main() -> int:
report = measure()
print(json.dumps(report.as_dict(), indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())