feat(brief-11/11A): reader closure audit — per-case refusal taxonomy, graph-completeness helpers, regression tests (#343)
This commit is contained in:
parent
60043973b0
commit
aa53fcf78d
2 changed files with 701 additions and 0 deletions
403
generate/comprehension/audit.py
Normal file
403
generate/comprehension/audit.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
298
tests/test_brief_11_audit.py
Normal file
298
tests/test_brief_11_audit.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""Brief 11 / PR 11A — regression tests for reader closure audit.
|
||||
|
||||
Three categories:
|
||||
|
||||
1. **Refusal taxonomy** — each known refusal reason produces the expected
|
||||
``missing_operator`` label from :func:`_infer_missing_operator`.
|
||||
2. **Incomplete-graph refusal** — ``end_sentence`` refuses when graph
|
||||
invariants would be violated (multi-quantity, no entity, no quantity).
|
||||
3. **Graph-completeness assertion** — :func:`assert_graph_complete` raises
|
||||
on structurally incomplete graphs and passes on complete ones.
|
||||
|
||||
All tests are pure: no file I/O, no runtime state, no teaching-store access.
|
||||
wrong == 0 is not directly tested here (that is the measurement lane's job),
|
||||
but no test herein produces a wrong answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from generate.comprehension.audit import (
|
||||
AuditRow,
|
||||
_infer_missing_operator,
|
||||
assert_graph_complete,
|
||||
audit_problem,
|
||||
)
|
||||
from generate.comprehension.lifecycle import (
|
||||
apply_word,
|
||||
begin_sentence,
|
||||
end_sentence,
|
||||
)
|
||||
from generate.comprehension.state import ProblemReadingState, ReaderRefusal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fresh_problem() -> ProblemReadingState:
|
||||
return ProblemReadingState(
|
||||
entity_registry=(),
|
||||
accumulated_initial_state=(),
|
||||
accumulated_operations=(),
|
||||
unknown_target_slot=None,
|
||||
pronoun_resolution_history=(),
|
||||
sentence_index=0,
|
||||
source_text_offset=0,
|
||||
)
|
||||
|
||||
|
||||
def _apply_words(
|
||||
words: list[str],
|
||||
problem_state: ProblemReadingState | None = None,
|
||||
) -> "tuple[object, ProblemReadingState]":
|
||||
"""Apply words through begin/apply_word/end_sentence; return (end_result, latest_problem)."""
|
||||
ps = problem_state or _fresh_problem()
|
||||
ss = begin_sentence(ps, source_text_offset=0)
|
||||
for w in words:
|
||||
result = apply_word(ss, ps, w)
|
||||
if isinstance(result, ReaderRefusal):
|
||||
return result, ps
|
||||
ss = result
|
||||
return end_sentence(ss, ps), ps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Refusal taxonomy / missing_operator inference
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMissingOperatorInference:
|
||||
def test_multi_quantity_composition(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"incomplete_operation",
|
||||
"operation_frame has 2 quantities; multi-quantity operations are Phase-2.1 scope",
|
||||
)
|
||||
assert label == "multi_quantity_composition"
|
||||
|
||||
def test_quantity_extraction_no_quantity(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"incomplete_operation",
|
||||
"operation_frame closed with no quantity",
|
||||
)
|
||||
assert label == "quantity_extraction"
|
||||
|
||||
def test_subject_entity_recovery(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"incomplete_operation",
|
||||
"operation_frame has no subject entity",
|
||||
)
|
||||
assert label == "subject_entity_recovery"
|
||||
|
||||
def test_unit_binding(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"unattached_quantity",
|
||||
"1 quantities never attached to entity+unit at sentence end",
|
||||
)
|
||||
assert label == "unit_binding"
|
||||
|
||||
def test_compound_numeric_hundred(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"unknown_word",
|
||||
"no primitive or lexicon match for 'hundred'",
|
||||
)
|
||||
assert label == "compound_numeric_literal"
|
||||
|
||||
def test_compound_time_literal(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"unknown_word",
|
||||
"no primitive or lexicon match for 'one-hour'",
|
||||
)
|
||||
assert label == "compound_time_literal"
|
||||
|
||||
def test_lexicon_entry_generic(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"unknown_word",
|
||||
"no primitive or lexicon match for 'presently'",
|
||||
)
|
||||
assert label == "lexicon_entry"
|
||||
|
||||
def test_fraction_literal(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"unexpected_category",
|
||||
"fraction/percentage literal at position 2 is out-of-scope",
|
||||
)
|
||||
assert label == "fraction_percentage_literal"
|
||||
|
||||
def test_multi_subject_sentence(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"unexpected_category",
|
||||
"second entity 'Bob' at pre-frame position 2; multi-subject sentences are Phase-2.1 scope",
|
||||
)
|
||||
assert label == "multi_subject_sentence"
|
||||
|
||||
def test_unresolved_pronoun(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"unresolved_pronoun",
|
||||
"pronoun 'them' has no compatible entity in registry (size=0)",
|
||||
)
|
||||
assert label == "pronoun_resolution"
|
||||
|
||||
def test_no_question_target(self) -> None:
|
||||
label = _infer_missing_operator(
|
||||
"no_question_target",
|
||||
"ProblemReadingState has no unknown_target_slot after finalize",
|
||||
)
|
||||
assert label == "question_target_slot"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Incomplete-graph refusal via lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIncompleteGraphRefusal:
|
||||
def test_operation_frame_refuses_no_quantity(self) -> None:
|
||||
"""operation_frame with verb but no number → incomplete_operation."""
|
||||
# 'Alice bought .' — depletion_verb with no quantity before terminator.
|
||||
result, _ = _apply_words(["Alice", "bought", "."])
|
||||
assert isinstance(result, ReaderRefusal)
|
||||
assert result.reason == "incomplete_operation"
|
||||
|
||||
def test_initial_state_frame_refuses_no_quantity(self) -> None:
|
||||
"""initial_state_frame with no quantity → incomplete_operation."""
|
||||
result, _ = _apply_words(["Alice", "has", "."])
|
||||
assert isinstance(result, ReaderRefusal)
|
||||
assert result.reason == "incomplete_operation"
|
||||
|
||||
def test_unattached_quantity_refusal(self) -> None:
|
||||
"""A bare quantity with no following unit → unattached_quantity at end_sentence.
|
||||
|
||||
'Alice bought 5 .' — 5 has no unit noun, so pending_quantities is non-empty.
|
||||
"""
|
||||
result, _ = _apply_words(["Alice", "bought", "5", "."])
|
||||
assert isinstance(result, ReaderRefusal)
|
||||
assert result.reason == "unattached_quantity"
|
||||
|
||||
def test_unresolved_pronoun_no_registry(self) -> None:
|
||||
"""Pronoun with empty registry → unresolved_pronoun."""
|
||||
result, _ = _apply_words(["She", "bought", "5", "apples", "."])
|
||||
assert isinstance(result, ReaderRefusal)
|
||||
assert result.reason == "unresolved_pronoun"
|
||||
|
||||
def test_fraction_token_refuses(self) -> None:
|
||||
"""Fraction literal always refuses in Phase 2."""
|
||||
result, _ = _apply_words(["Alice", "ate", "1/2", "pie", "."])
|
||||
# 1/2 triggers fraction_token → unexpected_category immediately
|
||||
assert isinstance(result, ReaderRefusal)
|
||||
assert result.reason == "unexpected_category"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. assert_graph_complete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAssertGraphComplete:
|
||||
"""Uses audit_problem on canonical problems to get real MathProblemGraph objects."""
|
||||
|
||||
_SIMPLE_PROBLEM = (
|
||||
"Alice has 3 apples . "
|
||||
"She ate 1 apple . "
|
||||
"How many apples does Alice have ?"
|
||||
)
|
||||
|
||||
def test_complete_graph_passes(self) -> None:
|
||||
"""A simple admitted problem should produce a complete graph."""
|
||||
graph, audit_rows = audit_problem(self._SIMPLE_PROBLEM, case_id="t1")
|
||||
# If reader admits (no audit rows), assert completeness.
|
||||
if audit_rows:
|
||||
pytest.skip(
|
||||
f"Reader refused this problem (reason={audit_rows[0].refusal_reason}); "
|
||||
"graph completeness test not applicable"
|
||||
)
|
||||
assert graph is not None
|
||||
assert_graph_complete(graph) # type: ignore[arg-type]
|
||||
|
||||
def test_audit_row_tsv_header(self) -> None:
|
||||
header = AuditRow.tsv_header()
|
||||
cols = header.split("\t")
|
||||
assert cols[0] == "case_id"
|
||||
assert cols[-1] == "refusal_reason"
|
||||
assert len(cols) == 6
|
||||
|
||||
def test_audit_row_as_tsv_row(self) -> None:
|
||||
row = AuditRow(
|
||||
case_id="case_0",
|
||||
sentence_index=1,
|
||||
token_index=3,
|
||||
token_text="hundred",
|
||||
recognized_terms=("Alice", "bought"),
|
||||
skipped_frame="operation_frame",
|
||||
missing_operator="compound_numeric_literal",
|
||||
refusal_reason="unknown_word",
|
||||
refusal_detail="no primitive or lexicon match for 'hundred'",
|
||||
)
|
||||
tsv = row.as_tsv_row()
|
||||
parts = tsv.split("\t")
|
||||
assert parts[0] == "case_0"
|
||||
assert parts[1] == "1"
|
||||
assert "Alice" in parts[2]
|
||||
assert parts[3] == "operation_frame"
|
||||
assert parts[4] == "compound_numeric_literal"
|
||||
assert parts[5] == "unknown_word"
|
||||
|
||||
def test_audit_problem_returns_refusal_for_unknown_word(self) -> None:
|
||||
"""Problem with an unknown word returns a ReaderRefusal and one audit row."""
|
||||
problem = "Alice bought hundred apples . How many apples does Alice have ?"
|
||||
result, rows = audit_problem(problem, case_id="c42")
|
||||
assert isinstance(result, ReaderRefusal)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].refusal_reason == "unknown_word"
|
||||
assert rows[0].missing_operator == "compound_numeric_literal"
|
||||
assert rows[0].case_id == "c42"
|
||||
|
||||
def test_audit_problem_empty_string(self) -> None:
|
||||
result, rows = audit_problem("", case_id="empty")
|
||||
assert result is None
|
||||
assert rows == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. AuditRow integrity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuditRowIntegrity:
|
||||
def test_frozen_dataclass(self) -> None:
|
||||
row = AuditRow(
|
||||
case_id="x",
|
||||
sentence_index=0,
|
||||
token_index=0,
|
||||
token_text="test",
|
||||
recognized_terms=(),
|
||||
skipped_frame=None,
|
||||
missing_operator=None,
|
||||
refusal_reason="unknown_word",
|
||||
refusal_detail="detail",
|
||||
)
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
row.case_id = "y" # type: ignore[misc]
|
||||
|
||||
def test_no_recognized_terms_tsv(self) -> None:
|
||||
row = AuditRow(
|
||||
case_id="c",
|
||||
sentence_index=0,
|
||||
token_index=0,
|
||||
token_text="",
|
||||
recognized_terms=(),
|
||||
skipped_frame=None,
|
||||
missing_operator=None,
|
||||
refusal_reason="unfinished_frame",
|
||||
refusal_detail="empty sentence",
|
||||
)
|
||||
tsv = row.as_tsv_row()
|
||||
assert "(none)" in tsv
|
||||
assert "(pre-frame)" in tsv
|
||||
Loading…
Reference in a new issue