generate/constraint_comprehension/reader.py: read_constraint_problem comprehends two-category constraint prose into a typed ConstraintProblem via five recognizers — C5 category-pair (from coefficient clauses; >2 -> too_many_categories), C6 coefficient (Each <cat> holds/has/costs/is-worth <N> <unit>; shared measured unit), C7 total-count (collective-unit sentence -> x+y=N; absent -> missing_total_count), C8 weighted-total (measured-unit sentence -> ax+by=T; absent -> missing_weighted_total), C9 query-target (How many <cat> -> the asked unknown). Refuses, never mis-assembles. Reconciles the sketch's 'no equal coefficients' note: equal coefficients are a SOLVER refusal (indistinguishable_weights), not a reader one — the reader reads r2-010 setup_correct and the solver refuses it. run_reader lane + 'reader' CLI subcommand. R2 reader: setup_correct 10 / setup_wrong 0 / refused_correct 3; R1 7/0/3 and 15-case 15/0/0 unchanged. Off-serving. 8 reader tests incl. full read->solve->verify chain + meaningful-fail guards.
205 lines
8.9 KiB
Python
205 lines
8.9 KiB
Python
"""Two-category constraint-problem reader (R2 C5–C9): prose -> ``ConstraintProblem``.
|
||
|
||
Recognizes the four pieces of a finite-integer two-category problem and assembles the typed
|
||
setup, REFUSING (never mis-assembling) when a piece is missing or there are not exactly two
|
||
categories. Off-serving; deterministic. The reader does NOT solve — solvability (singular /
|
||
non-integer / negative systems) is the solver's boundary (C3): an equal-coefficient problem
|
||
(e.g. 4 wheels each) still reads setup_correct and is refused *downstream* by the solver. (This
|
||
reconciles the design sketch's "no equal coefficients" note: equal coefficients are a SOLVER
|
||
refusal, ``indistinguishable_weights``, not a reader refusal — the gold classifies that fixture
|
||
``solver_refuses``, so the reader must read it.)
|
||
|
||
Recognizers and their wrong=0 guards:
|
||
C5 category pair — the two categories come from the per-category coefficient clauses (exactly
|
||
two distinct; >2 -> ``too_many_categories``).
|
||
C6 coefficient — ``Each <cat> (holds|has|costs|is worth) <N> <measured_unit>``; the two
|
||
coefficients must share one measured unit (else ``coefficient_unit_mismatch``).
|
||
C7 total count — a ``<N> <collective>`` sentence whose unit is the collective (not the
|
||
measured unit) -> ``x + y = N``; absent -> ``missing_total_count``.
|
||
C8 weighted total — a ``<T> <measured_unit>`` sentence -> ``a·x + b·y = T`` (coefficients from
|
||
C6); absent -> ``missing_weighted_total``.
|
||
C9 query target — ``How many <category> are there?`` -> the asked unknown (one of the two;
|
||
else ``query_target_not_a_category``).
|
||
|
||
``too_many_categories`` / ``missing_total_count`` / ``missing_weighted_total`` are the gold's
|
||
closed ``reader_reason`` set (ADR-0211). ``coefficient_unit_mismatch`` / ``category_pair_not_found``
|
||
/ ``query_target_not_a_category`` are defensive guards with no gold fixture (tested by
|
||
construction); they never fire on the gold corpus.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from generate.constraint_comprehension.expr import LinearConstraint, LinearExpr
|
||
from generate.constraint_comprehension.model import (
|
||
AttributeFact,
|
||
ConstraintProblem,
|
||
ConstraintQuery,
|
||
Unknown,
|
||
)
|
||
from generate.meaning_graph.reader import Refusal, _split_sentences
|
||
|
||
#: Coefficient-clause verbs. The category words sit between the article and the first of these;
|
||
#: ``worth`` follows ``is`` and is skipped. Totals use other verbs (rents/buys/carry/…) and are
|
||
#: classified separately by their noun, so they never reach coefficient parsing.
|
||
_COEFF_VERBS = frozenset({"holds", "hold", "has", "have", "costs", "cost", "is", "are"})
|
||
|
||
#: Tokens that close the noun phrase after a count/weighted digit.
|
||
_NP_STOP = frozenset({"in", "for", "all", "some", "and", "to", "of", "with", "that"})
|
||
|
||
_DOMAIN = "nonnegative_integer"
|
||
|
||
|
||
def _singular(noun: str) -> str:
|
||
"""Conservative singularization (``buses``->``bus``, ``boxes``->``box``, ``legs``->``leg``)."""
|
||
noun = noun.strip(".,?!")
|
||
if noun.endswith("es") and noun[:-2].endswith(("x", "s", "z", "ch", "sh")):
|
||
return noun[:-2]
|
||
if noun.endswith("s") and len(noun) > 1:
|
||
return noun[:-1]
|
||
return noun
|
||
|
||
|
||
def _parse_coefficient_clause(clause: str) -> tuple[str, list[str], str, int] | None:
|
||
"""``(symbol, category_words, measured_unit, value)`` for a coefficient clause, else ``None``.
|
||
|
||
A coefficient clause starts with ``each`` (any category) or with ``a`` **and** contains
|
||
``worth`` (``A nickel is worth 5 cents``). The ``a``-without-``worth`` form is a framing /
|
||
total sentence (``A jar holds 20 coins``) and is rejected here so it is classified as a total.
|
||
"""
|
||
toks = clause.lower().split()
|
||
if not toks or toks[0] not in ("each", "a"):
|
||
return None
|
||
if toks[0] == "a" and "worth" not in toks:
|
||
return None
|
||
verb_i = next((i for i in range(1, len(toks)) if toks[i] in _COEFF_VERBS), None)
|
||
if verb_i is None or verb_i == 1:
|
||
return None
|
||
category_words = toks[1:verb_i]
|
||
digit_i = next((j for j in range(verb_i + 1, len(toks)) if toks[j].strip(".,").isdigit()), None)
|
||
if digit_i is None or digit_i + 1 >= len(toks):
|
||
return None
|
||
value = int(toks[digit_i].strip(".,"))
|
||
measured_unit = _singular(toks[digit_i + 1])
|
||
return "_".join(category_words), category_words, measured_unit, value
|
||
|
||
|
||
def _split_coeff_clauses(sentence: str) -> list[str]:
|
||
"""Split a coefficient sentence into clauses on commas and ``and`` (``Each X … and each Y …``)."""
|
||
parts: list[str] = []
|
||
for chunk in sentence.split(","):
|
||
parts.extend(chunk.split(" and "))
|
||
return [p.strip() for p in parts if p.strip()]
|
||
|
||
|
||
def _digit_and_head(sentence: str) -> tuple[str | None, int | None]:
|
||
"""The first integer in *sentence* and the singular head noun of the phrase that follows it."""
|
||
toks = sentence.lower().rstrip("?.!").split()
|
||
for i, tok in enumerate(toks):
|
||
if tok.strip(".,").isdigit():
|
||
value = int(tok.strip(".,"))
|
||
phrase: list[str] = []
|
||
for nxt in toks[i + 1:]:
|
||
if nxt.endswith(","):
|
||
phrase.append(nxt[:-1])
|
||
break
|
||
if nxt in _NP_STOP:
|
||
break
|
||
phrase.append(nxt)
|
||
if not phrase:
|
||
return None, None
|
||
return _singular(phrase[-1]), value
|
||
return None, None
|
||
|
||
|
||
def _query_symbol(toks: list[str]) -> str | None:
|
||
"""``How many <category> are there?`` -> the (singularized, joined) category symbol."""
|
||
if "are" not in toks:
|
||
return None
|
||
words = toks[2 : toks.index("are")]
|
||
if not words:
|
||
return None
|
||
return "_".join(words[:-1] + [_singular(words[-1])])
|
||
|
||
|
||
def read_constraint_problem(text: str) -> ConstraintProblem | Refusal:
|
||
"""Comprehend two-category constraint prose into a typed :class:`ConstraintProblem`, or refuse."""
|
||
if not text or not text.strip():
|
||
return Refusal("empty")
|
||
|
||
coefficients: list[tuple[str, list[str], str, int]] = []
|
||
query_words: str | None = None
|
||
leftover: list[str] = []
|
||
|
||
for body, _term, _start, _end in _split_sentences(text):
|
||
toks = body.lower().rstrip("?.!").split()
|
||
if len(toks) >= 2 and toks[0] == "how" and toks[1] == "many":
|
||
query_words = _query_symbol(toks)
|
||
continue
|
||
parsed_any = False
|
||
for clause in _split_coeff_clauses(body):
|
||
pc = _parse_coefficient_clause(clause)
|
||
if pc is not None:
|
||
coefficients.append(pc)
|
||
parsed_any = True
|
||
if not parsed_any:
|
||
leftover.append(body)
|
||
|
||
# C5 — exactly two distinct categories (order preserved).
|
||
coeff_value: dict[str, int] = {}
|
||
coeff_unit: dict[str, str] = {}
|
||
entity: dict[str, str] = {}
|
||
order: list[str] = []
|
||
for symbol, words, mu, value in coefficients:
|
||
if symbol in coeff_value and coeff_value[symbol] != value:
|
||
return Refusal("coefficient_conflict", symbol)
|
||
if symbol not in coeff_value:
|
||
order.append(symbol)
|
||
coeff_value[symbol], coeff_unit[symbol], entity[symbol] = value, mu, " ".join(words)
|
||
if len(order) > 2:
|
||
return Refusal("too_many_categories", f"{order}")
|
||
if len(order) != 2:
|
||
return Refusal("category_pair_not_found", f"{order}")
|
||
|
||
# C6 — the two coefficients must share one measured unit.
|
||
if len({coeff_unit[s] for s in order}) != 1:
|
||
return Refusal("coefficient_unit_mismatch", f"{[coeff_unit[s] for s in order]}")
|
||
measured_unit = coeff_unit[order[0]]
|
||
|
||
# C7 / C8 — classify the leftover digit sentences: collective unit -> count; measured -> weighted.
|
||
total_count: int | None = None
|
||
collective: str | None = None
|
||
weighted_total: int | None = None
|
||
for sentence in leftover:
|
||
head, value = _digit_and_head(sentence)
|
||
if value is None:
|
||
continue
|
||
if head == measured_unit:
|
||
weighted_total = value
|
||
else:
|
||
total_count, collective = value, head
|
||
if total_count is None or collective is None:
|
||
return Refusal("missing_total_count")
|
||
if weighted_total is None:
|
||
return Refusal("missing_weighted_total")
|
||
|
||
# C9 — the query must name one of the two categories.
|
||
if query_words is None or query_words not in order:
|
||
return Refusal("query_target_not_a_category", f"{query_words}")
|
||
|
||
s0, s1 = order
|
||
unknowns = tuple(
|
||
Unknown(symbol=s, entity=entity[s], unit=collective, domain=_DOMAIN) for s in order
|
||
)
|
||
facts = tuple(
|
||
AttributeFact(category=s, measured_unit=measured_unit, value=coeff_value[s]) for s in order
|
||
)
|
||
constraints = (
|
||
LinearConstraint(LinearExpr(((s0, 1), (s1, 1))), "eq", total_count),
|
||
LinearConstraint(
|
||
LinearExpr(((s0, coeff_value[s0]), (s1, coeff_value[s1]))), "eq", weighted_total
|
||
),
|
||
)
|
||
return ConstraintProblem(unknowns, facts, constraints, ConstraintQuery(query_words, collective))
|
||
|
||
|
||
__all__ = ["read_constraint_problem"]
|