## Summary PR 11B in the Brief 11 sequence. Closes the missing-operator inference gap left by 11A (#343) and ships the per-case audit artifact that Brief 11 §Gate 2 identifies as "the main Brief 11 artifact." ## Why this PR does NOT touch the reader runtime The naive closure fix for `pre_frame_filler_sentence` (drain `statement_terminator` at pre-frame) lifts 2 cases from refused → admitted but creates a `wrong > 0` hazard on `gsm8k-train-sample-v1-0050`: ``` Mark does a gig every other day for 2 weeks. For each gig, he plays 3 songs. ... How many minutes did he play? ``` With the drain enabled, the reader admits `Operation(mark, add, 3, songs)` with unknown unit `minute` and would project to a wrong answer. The stricter variant (`pending_entity_ref is None` + no quantities) fires on 0 of the 11 candidate cases. Per Brief 11 §"Failure modes to avoid §1 — Correct-count greed," this PR rejects both variants and routes the closure fix to a follow-up that adds the required verb vocabulary or sentence-intent classifier. ## Deliverables - `generate/comprehension/audit.py` — three new missing-operator labels: - `pre_frame_filler_sentence` (8 cases) - `descriptive_frame_question` (2 cases) - `question_frame_slot` (1 case) Closes the 11-case `None`-operator gap left by 11A. - `evals/gsm8k_math/train_sample/v1/audit_brief_11.json` — per-case audit artifact pinned by tests. - `evals/gsm8k_math/train_sample/v1/audit_brief_11.md` — narrative summary including the rejected-fix design tension and ranked Brief 11B-step-2 backlog. - `tests/test_brief_11b_audit_artifact.py` — 12 tests pinning the new labels, the per-case artifact, the wrong=0 invariant, and the refusal taxonomy. ## Bottleneck taxonomy (after Brief 11B labelling) | missing_operator | count | category | |-------------------------------|------:|------------------------| | quantity_extraction | 9 | incomplete_operation | | lexicon_entry | 9 | unknown_word | | multi_quantity_composition | 8 | incomplete_operation | | pre_frame_filler_sentence | 8 | unexpected_category | | pronoun_resolution | 3 | unresolved_pronoun | | fraction_percentage_literal | 3 | unexpected_category | | unit_binding | 3 | unattached_quantity | | descriptive_frame_question | 2 | unexpected_category | | (others, 1 each) | 5 | various | ## Test plan - 12 new tests in `tests/test_brief_11b_audit_artifact.py` pass - 23 existing 11A tests in `tests/test_brief_11_audit.py` pass - No runtime changes; reader byte-identical to main ## Hard invariants preserved - `wrong == 0` — no runtime change, no new admissions - ADR-0166 — no new canonical eval lanes added; existing `evals/gsm8k_math/train_sample/v1/` artifact set extended - No teaching store / pack mutation ## Follow-up - **11B-step-2** — verb-vocabulary expansion or sentence-intent classifier for `pre_frame_filler_sentence` (8 cases). See audit_brief_11.md §"design tension" for the rejected one-line variants and why they fail wrong=0. - **11C** — existing-lane capability snapshot (still gated on 11B-step-2 or another closure pass).
425 lines
14 KiB
Python
425 lines
14 KiB
Python
"""Brief 11 / PR 11A — reader closure audit helpers.
|
|
|
|
Provides:
|
|
|
|
* :class:`AuditRow` — typed record for a recognized-but-skipped statement.
|
|
* :func:`audit_problem` — runs the Phase 2 reader over a single raw problem
|
|
string and returns (result, audit_rows). ``result`` is either a
|
|
``MathProblemGraph`` (success), a ``ReaderRefusal`` (first refusal), or
|
|
``None`` (regex fallback — reader was not attempted for this case).
|
|
* :func:`assert_graph_complete` — raises ``AssertionError`` with a descriptive
|
|
message if any structural requirement of a ``MathProblemGraph`` is unmet.
|
|
Intended for use inside tests and measurement scripts.
|
|
|
|
These helpers are *pure audit instruments* — they do not mutate any pack,
|
|
teaching store, or runtime state. They operate solely on the reader path
|
|
defined by ADR-0164.3 and ADR-0164.4.
|
|
|
|
ADR-0166 invariant: these helpers produce diagnostic output only. No
|
|
capability claim is made by their existence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from generate.math_problem_graph import MathProblemGraph
|
|
|
|
from generate.comprehension.lifecycle import (
|
|
apply_word,
|
|
begin_sentence,
|
|
end_sentence,
|
|
finalize,
|
|
)
|
|
from generate.comprehension.state import ProblemReadingState, ReaderRefusal
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Audit row
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AuditRow:
|
|
"""One recognized-but-skipped or refused statement.
|
|
|
|
Columns match the Brief 11 audit row shape::
|
|
|
|
case_id | sentence_index | recognized_terms | skipped_frame
|
|
| missing_operator | refusal_reason
|
|
"""
|
|
|
|
case_id: str
|
|
"""Caller-supplied identifier (e.g. GSM8K row index as string)."""
|
|
|
|
sentence_index: int
|
|
"""0-based sentence index at which the refusal occurred."""
|
|
|
|
token_index: int
|
|
"""0-based token index within the sentence (from ReaderRefusal)."""
|
|
|
|
token_text: str
|
|
"""Surface form of the token that triggered the refusal."""
|
|
|
|
recognized_terms: tuple[str, ...]
|
|
"""Words successfully classified before the refusal in this sentence."""
|
|
|
|
skipped_frame: str | None
|
|
"""Frame kind that was open when the refusal occurred, or None."""
|
|
|
|
missing_operator: str | None
|
|
"""Derived missing-operator label (see :func:`_infer_missing_operator`)."""
|
|
|
|
refusal_reason: str
|
|
"""ReaderRefusal.reason string verbatim."""
|
|
|
|
refusal_detail: str
|
|
"""ReaderRefusal.detail string verbatim."""
|
|
|
|
def as_tsv_row(self) -> str:
|
|
"""Single tab-separated line for the audit artifact."""
|
|
terms = ", ".join(self.recognized_terms) if self.recognized_terms else "(none)"
|
|
return "\t".join(
|
|
[
|
|
self.case_id,
|
|
str(self.sentence_index),
|
|
terms,
|
|
self.skipped_frame or "(pre-frame)",
|
|
self.missing_operator or "(unknown)",
|
|
self.refusal_reason,
|
|
]
|
|
)
|
|
|
|
@staticmethod
|
|
def tsv_header() -> str:
|
|
return "\t".join(
|
|
[
|
|
"case_id",
|
|
"sentence_index",
|
|
"recognized_terms",
|
|
"skipped_frame",
|
|
"missing_operator",
|
|
"refusal_reason",
|
|
]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Missing-operator inference
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Map refusal_reason + detail patterns → missing operator label.
|
|
# Ordered: first match wins.
|
|
_OPERATOR_INFERENCE_RULES: list[tuple[str, re.Pattern[str], str]] = [
|
|
# Multi-quantity ops
|
|
(
|
|
"incomplete_operation",
|
|
re.compile(r"multi-quantity", re.IGNORECASE),
|
|
"multi_quantity_composition",
|
|
),
|
|
# No-quantity operation frame
|
|
(
|
|
"incomplete_operation",
|
|
re.compile(r"no quantity", re.IGNORECASE),
|
|
"quantity_extraction",
|
|
),
|
|
# Subject-dropped (no entity in operation/initial_state frame)
|
|
(
|
|
"incomplete_operation",
|
|
re.compile(r"no subject entity", re.IGNORECASE),
|
|
"subject_entity_recovery",
|
|
),
|
|
# Unattached quantity
|
|
(
|
|
"unattached_quantity",
|
|
re.compile(r"."),
|
|
"unit_binding",
|
|
),
|
|
# Compound numeric ("hundred", "million" etc.)
|
|
(
|
|
"unknown_word",
|
|
re.compile(r"hundred|thousand|million|billion", re.IGNORECASE),
|
|
"compound_numeric_literal",
|
|
),
|
|
# Temporal compound ("one-hour", "two-day")
|
|
(
|
|
"unknown_word",
|
|
re.compile(r"one-|two-|three-|four-|five-|six-|seven-|eight-|nine-|ten-", re.IGNORECASE),
|
|
"compound_time_literal",
|
|
),
|
|
# Generic unknown word (lexicon gap)
|
|
(
|
|
"unknown_word",
|
|
re.compile(r"."),
|
|
"lexicon_entry",
|
|
),
|
|
# Fraction / percentage
|
|
(
|
|
"unexpected_category",
|
|
re.compile(r"fraction|percentage", re.IGNORECASE),
|
|
"fraction_percentage_literal",
|
|
),
|
|
# Multi-subject sentence
|
|
(
|
|
"unexpected_category",
|
|
re.compile(r"multi-subject|second entity", re.IGNORECASE),
|
|
"multi_subject_sentence",
|
|
),
|
|
# Unresolved pronoun
|
|
(
|
|
"unresolved_pronoun",
|
|
re.compile(r"."),
|
|
"pronoun_resolution",
|
|
),
|
|
# Ambiguous pronoun
|
|
(
|
|
"ambiguous_pronoun_referent",
|
|
re.compile(r"."),
|
|
"pronoun_disambiguation",
|
|
),
|
|
# No question target
|
|
(
|
|
"no_question_target",
|
|
re.compile(r"."),
|
|
"question_target_slot",
|
|
),
|
|
# Graph construction failure
|
|
(
|
|
"graph_construction_failure",
|
|
re.compile(r"."),
|
|
"graph_construction",
|
|
),
|
|
# Brief 11B: pre-frame statement_terminator — sentence ended without
|
|
# opening any frame. May be context filler or a verb the reader cannot
|
|
# admit yet. Distinct from multi_subject because no second entity.
|
|
(
|
|
"unexpected_category",
|
|
re.compile(r"'statement_terminator'.*pre-frame", re.IGNORECASE),
|
|
"pre_frame_filler_sentence",
|
|
),
|
|
# Brief 11B: pre-frame "?" reached in a descriptive_frame — question
|
|
# target slot was missed before the terminator arrived.
|
|
(
|
|
"unexpected_category",
|
|
re.compile(r"'question_terminator'.*descriptive_frame", re.IGNORECASE),
|
|
"descriptive_frame_question",
|
|
),
|
|
# Brief 11B: question_frame opened but a required slot (e.g. unit_class)
|
|
# never arrived. Labelled separately from generic incomplete_operation.
|
|
(
|
|
"incomplete_operation",
|
|
re.compile(r"question_frame missing required slot", re.IGNORECASE),
|
|
"question_frame_slot",
|
|
),
|
|
]
|
|
|
|
|
|
def _infer_missing_operator(reason: str, detail: str) -> str | None:
|
|
"""Infer the missing-operator label from a ReaderRefusal."""
|
|
for target_reason, pattern, label in _OPERATOR_INFERENCE_RULES:
|
|
if reason == target_reason and pattern.search(detail):
|
|
return label
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sentence splitter (minimal — matches the adapter's split logic)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
|
|
|
|
|
|
def _split_sentences(text: str) -> list[str]:
|
|
"""Split problem text into sentences. Mirrors adapter behaviour."""
|
|
return [s.strip() for s in _SENTENCE_SPLIT_RE.split(text.strip()) if s.strip()]
|
|
|
|
|
|
def _tokenise(sentence: str) -> list[str]:
|
|
"""Minimal whitespace tokeniser that preserves punctuation tokens."""
|
|
tokens: list[str] = []
|
|
for raw in sentence.split():
|
|
# Strip leading/trailing punctuation but keep internal (e.g. "$3.50")
|
|
stripped_left = raw.lstrip()
|
|
# Separate trailing punctuation
|
|
if stripped_left and stripped_left[-1] in ".!?,":
|
|
body = stripped_left[:-1]
|
|
tail = stripped_left[-1]
|
|
if body:
|
|
tokens.append(body)
|
|
tokens.append(tail)
|
|
else:
|
|
if stripped_left:
|
|
tokens.append(stripped_left)
|
|
return tokens
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core audit function
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
AuditResult = "MathProblemGraph | ReaderRefusal | None"
|
|
|
|
|
|
def audit_problem(
|
|
problem_text: str,
|
|
*,
|
|
case_id: str = "unknown",
|
|
) -> tuple["AuditResult", list[AuditRow]]:
|
|
"""Run the Phase 2 reader over *problem_text* and return audit data.
|
|
|
|
Returns
|
|
-------
|
|
result :
|
|
``MathProblemGraph`` on full admission,
|
|
``ReaderRefusal`` on the first refusal,
|
|
``None`` if the text produced no sentences.
|
|
audit_rows :
|
|
One :class:`AuditRow` per refusal encountered (at most one per sentence
|
|
in the current single-refusal-stops-processing model). On success,
|
|
``audit_rows`` is empty.
|
|
"""
|
|
sentences = _split_sentences(problem_text)
|
|
if not sentences:
|
|
return None, []
|
|
|
|
problem_state = ProblemReadingState(
|
|
entity_registry=(),
|
|
accumulated_initial_state=(),
|
|
accumulated_operations=(),
|
|
unknown_target_slot=None,
|
|
pronoun_resolution_history=(),
|
|
sentence_index=0,
|
|
source_text_offset=0,
|
|
)
|
|
audit_rows: list[AuditRow] = []
|
|
|
|
for sentence in sentences:
|
|
tokens = _tokenise(sentence)
|
|
sentence_state = begin_sentence(problem_state, source_text_offset=0)
|
|
recognized: list[str] = []
|
|
|
|
for word in tokens:
|
|
result = apply_word(sentence_state, problem_state, word)
|
|
if isinstance(result, ReaderRefusal):
|
|
row = AuditRow(
|
|
case_id=case_id,
|
|
sentence_index=result.sentence_index,
|
|
token_index=result.token_index,
|
|
token_text=result.token_text,
|
|
recognized_terms=tuple(recognized),
|
|
skipped_frame=sentence_state.frame,
|
|
missing_operator=_infer_missing_operator(
|
|
result.reason, result.detail
|
|
),
|
|
refusal_reason=result.reason,
|
|
refusal_detail=result.detail,
|
|
)
|
|
audit_rows.append(row)
|
|
return result, audit_rows
|
|
sentence_state = result
|
|
recognized.append(word)
|
|
|
|
end_result = end_sentence(sentence_state, problem_state)
|
|
if isinstance(end_result, ReaderRefusal):
|
|
row = AuditRow(
|
|
case_id=case_id,
|
|
sentence_index=end_result.sentence_index,
|
|
token_index=end_result.token_index,
|
|
token_text=end_result.token_text,
|
|
recognized_terms=tuple(recognized),
|
|
skipped_frame=sentence_state.frame,
|
|
missing_operator=_infer_missing_operator(
|
|
end_result.reason, end_result.detail
|
|
),
|
|
refusal_reason=end_result.reason,
|
|
refusal_detail=end_result.detail,
|
|
)
|
|
audit_rows.append(row)
|
|
return end_result, audit_rows
|
|
problem_state = end_result
|
|
|
|
graph_result = finalize(problem_state)
|
|
if isinstance(graph_result, ReaderRefusal):
|
|
row = AuditRow(
|
|
case_id=case_id,
|
|
sentence_index=graph_result.sentence_index,
|
|
token_index=graph_result.token_index,
|
|
token_text=graph_result.token_text,
|
|
recognized_terms=(),
|
|
skipped_frame=None,
|
|
missing_operator=_infer_missing_operator(
|
|
graph_result.reason, graph_result.detail
|
|
),
|
|
refusal_reason=graph_result.reason,
|
|
refusal_detail=graph_result.detail,
|
|
)
|
|
audit_rows.append(row)
|
|
return graph_result, audit_rows
|
|
|
|
return graph_result, audit_rows
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph completeness assertion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def assert_graph_complete(graph: "MathProblemGraph") -> None:
|
|
"""Assert structural completeness of a :class:`MathProblemGraph`.
|
|
|
|
Checks (per Brief 11 Gate 3):
|
|
|
|
1. At least one entity.
|
|
2. At least one initial possession OR at least one operation.
|
|
3. Every initial possession has a non-empty entity and a non-None quantity
|
|
with a non-empty unit.
|
|
4. Every operation has actor, kind, operand (with unit); transfer ops have
|
|
a non-None target.
|
|
5. Unknown has a non-empty entity (or None) and a non-empty unit.
|
|
6. No entity name is empty or whitespace-only.
|
|
|
|
Raises ``AssertionError`` with a descriptive message on the first failure.
|
|
Does not return a value — callers should wrap in ``pytest.raises`` or a
|
|
plain ``try/except`` depending on usage context.
|
|
"""
|
|
# 1. Entities.
|
|
assert graph.entities, "graph.entities is empty"
|
|
for i, name in enumerate(graph.entities):
|
|
assert name and name.strip(), f"graph.entities[{i}] is blank"
|
|
|
|
# 2. At least some math content.
|
|
assert graph.initial_state or graph.operations, (
|
|
"graph has no initial_state and no operations"
|
|
)
|
|
|
|
# 3. Initial possessions.
|
|
for i, ip in enumerate(graph.initial_state):
|
|
assert ip.entity, f"initial_state[{i}].entity is blank"
|
|
assert ip.quantity is not None, f"initial_state[{i}].quantity is None"
|
|
assert ip.quantity.unit, f"initial_state[{i}].quantity.unit is blank"
|
|
|
|
# 4. Operations.
|
|
for i, op in enumerate(graph.operations):
|
|
assert op.actor, f"operations[{i}].actor is blank"
|
|
assert op.kind, f"operations[{i}].kind is blank"
|
|
assert op.operand is not None, f"operations[{i}].operand is None"
|
|
assert op.operand.unit, f"operations[{i}].operand.unit is blank"
|
|
if op.kind == "transfer":
|
|
assert op.target is not None, (
|
|
f"operations[{i}] is a transfer but target is None"
|
|
)
|
|
|
|
# 5. Unknown.
|
|
assert graph.unknown is not None, "graph.unknown is None"
|
|
assert graph.unknown.unit, "graph.unknown.unit is blank"
|
|
|
|
|
|
__all__ = [
|
|
"AuditRow",
|
|
"assert_graph_complete",
|
|
"audit_problem",
|
|
]
|