core/generate/combined_rate_comprehension/reader.py
Shay a4d0c4ebf3 docs(cmb): lookback review + fix single-agent-attribution hygiene hazard (H3)
The mandatory lookback before CMB-d (3+ PRs on one surface; next-phase boundary). A 5-dimension
read-only fan-out audit over the 10-item checklist found the CMB-a/b/c substrate composes cleanly:
25 solid / 4 drift / 8 gap / 1 reader hazard. Doc:
docs/analysis/cmb-lookback-review-2026-06-08.md.

Fixes the one real hazard (H3): a query attributing the answer to a SINGLE agent ('how many words
does Alice type' with a distractor second rate) wrongly claimed the substantive
combine_mode_ambiguous — a hygiene over-claim that would corrupt the shared router in CMB-d. The
combined-query gate now excludes single-agent 'does <Agent>' attribution (_SINGLE_AGENT_QUERY) and
steps aside as not_combined_rate_shaped; the genuinely-combined forms ('do they' / 'are produced' /
'does it') still yield combine_mode_ambiguous. Pinned by gold cmb-16 + a reader test.

The review also resolves the CMB-d failure-family classification (input_shape:
not_combined_rate_shaped; must_remain_refused incl. rate_unit_mismatch — DECISIVELY, the reader has
no dimension representation so it cannot tell convertible from incompatible units; proposal_allowed:
combine_mode_ambiguous, missing_second_rate) and enumerates the live CMB-d registry preconditions
(not_combined_rate_shaped mapping, rate_unit_mismatch/non_integer_solution string collisions,
non_positive_net_rate family, Organ/ALL_REASONS extension). Provenance false-alarm (stale local main
checkout) noted + verified against origin/main.

gold 19/19 (6/5/8); reader 11/0/0 + 8 refused-correct; solver 6/0 + 5/0; 0 hygiene breaches; 123
tests; serving + R1/R2/R3 unchanged.
2026-06-08 13:27:17 -07:00

222 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Combined-rate prose reader (CMB-c): explicit two-rate prose -> CombinedRateProblem.
Reads ONLY explicit combined-rate problems — two rates over one shared unit, combined by an
explicit cooperative (``sum``) or opposing-flow (``difference``) cue — and refuses everything else
with the closed CMB reader taxonomy. It does NOT solve: a non-positive net rate or a non-integer
answer is the *solver's* boundary (CMB-b), reached only after this reader produces a setup.
The wrong=0 spine is the CMB-a **2×2 domain-entry grid** (rate-count × combination cue):
```text
| combination cue | no cue
two rates | parse (sum/difference) | combine_mode_ambiguous (same unit only)
one rate | missing_second_rate | not_combined_rate_shaped <- step aside (R3 territory)
```
plus "combined-shaped but deferred / malformed" refusals that fire ONLY once a genuine two-rate
combination cue is present, so CMB never claims a substantive boundary on R1/R2/R3 text:
``three_or_more_rates``, ``rate_unit_mismatch`` (the two rates differ in unit),
``clock_interval_deferred``, ``reciprocal_work_rate_deferred``.
**Robustness (each guards a wrong=0 / hygiene failure mode an adversarial pass found):**
- cues are **whole-word** regexes, never substrings (``exempt`` is not a drain; ``bothered`` is
not ``both``);
- ``difference`` requires an explicit **fill AND drain pair**, and assigns ``rate_a`` = fill
(minuend) / ``rate_b`` = drain (subtrahend) **by role**, not by text order (a drain listed
first must still subtract);
- the duration and the time-query target are read from the **query clause** (after the last rate
clause), so a preamble number ("50 liters already in the tank …") is never mistaken for them;
- the rate regex rejects **decimals** (``3.5 pages per hour`` is not an integer rate -> step aside);
- substantive refusals are **gated behind a combination cue** — foreign two-/three-rate prose with
no cue steps aside as ``not_combined_rate_shaped`` (router-organ hygiene), never a substantive
boundary; and **sequential segments** (each rate carrying its own adjacent duration) step aside.
Off-serving (imports no ``generate.derivation`` / ``core.reliability_gate``); deterministic.
"""
from __future__ import annotations
import re
from generate.combined_rate_comprehension.model import CombinedRateProblem
from generate.combined_rate_comprehension.units import RateUnit
from generate.meaning_graph.reader import Refusal
# An integer rate ``<N> <plural> per <singular>``; the lookbehind rejects a digit that is part of a
# decimal ("3.5 pages per hour" -> no match -> the reader steps aside rather than read "5").
_RATE_VALUE = re.compile(r"(?<![\d.])(\d+)\s+([a-z]+)\s+per\s+([a-z]+)\b")
_DURATION = re.compile(r"\b(?:for|in|after)\s+(\d+)\s+([a-z]+)\b")
_DIGIT_NOUN = re.compile(r"(?<![\d.])(\d+)\s+([a-z]+)\b")
_HOW_MANY = re.compile(r"\bhow many ([a-z]+)\b")
# An effective-rate question — constrained to a direct "what … net/combined … rate" so a mid-sentence
# "at their combined rate, how many …" does NOT steal the slot from a quantity question.
_EFF_RATE_QUERY = re.compile(r"\bwhat\b[^.?!]*\b(?:net|combined)\b[^.?!]*\brate\b")
_CLOCK = re.compile(r"\b\d+\s*(?:am|pm)\b|\bo'?clock\b")
# Two conjoined agents in the premise ("Anna and Ben …") — the strong signal for missing_second_rate.
_AGENT_CONJ = re.compile(r"\b[a-z]+\s+and\s+[a-z]+\b")
# A query that attributes the answer to a SINGLE agent ("how many words does Alice type") rather
# than the combination ("how many … do they …" / "… are produced"). "does it/this/that" is the
# combined process, not a single agent, so it is excluded.
_SINGLE_AGENT_QUERY = re.compile(r"\bdoes\s+(?!it\b|this\b|that\b|they\b)[a-z]")
#: Whole-word fill / drain verbs. Detected PER RATE CLAUSE (in the clause's own lead-in), never
#: globally — an incidental "the drain stays closed" or a "draining" that governs a different clause
#: must not flip the mode. ``difference`` is a *clean opposition*: one fill clause + one drain clause.
_FILL_RE = re.compile(r"\b(?:fills?|filling|filled|adds?|adding|added|pours?|pouring|injects?|pumps?\s+in)\b")
_DRAIN_RE = re.compile(r"\b(?:drains?|draining|drained|removes?|removing|removed|leaks?|leaking|leaked|empties|emptied|emptying|siphons?|siphoning)\b")
_COOP_RE = re.compile(r"\b(?:together|combined|both)\b")
_RECIPROCAL_RE = re.compile(r"\bcan\b") # "X can do the job in N time" — the reciprocal work-rate form
#: How far before a rate's number to look for its governing fill/drain verb ("a pipe fills a tank at
#: 5", "while a drain removes 2"). Kept tight so a verb governing the OTHER clause is not captured.
_LEADIN = 25
def _singular(noun: str) -> str:
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 _clause_role(t: str, start: int) -> str:
"""``drain`` / ``fill`` / ``other`` for the rate clause at *start*, by the verb directly before
its number (within ``_LEADIN`` chars). Drain wins ties — a drain verb is the load-bearing sign."""
leadin = t[max(0, start - _LEADIN) : start]
if _DRAIN_RE.search(leadin):
return "drain"
if _FILL_RE.search(leadin):
return "fill"
return "other"
def _is_sequential(t: str, rate_spans: list[tuple[int, int]]) -> bool:
"""True iff BOTH rate clauses carry their own following duration (sequential segments — "60 mph
for 2 hours and then 40 mph for 3 hours" — which is R3.x, not a simultaneous combination). The
window tolerates a short connector ("also for", "then for")."""
return all(_DURATION.search(t[e : e + 28]) is not None for _s, e in rate_spans)
def _digit_noun_after(t: str, start: int, noun: str) -> int | None:
"""The first ``<N> <noun>`` value at/after *start* — anchored to AFTER the ``how many`` question,
so neither a premise number nor a transitional distractor inside the query clause is mistaken for
the queried quantity."""
for m in _DIGIT_NOUN.finditer(t, start):
if _singular(m.group(2)) == noun:
return int(m.group(1))
return None
def read_combined_rate_problem(text: str) -> CombinedRateProblem | Refusal:
"""Comprehend explicit combined-rate prose into a typed CombinedRateProblem, or refuse."""
if not text or not text.strip():
return Refusal("empty")
t = text.lower()
rate_clauses = _RATE_VALUE.findall(t) # [(value, plural, denom_singular), ...]
rate_spans = [m.span() for m in _RATE_VALUE.finditer(t)]
n = len(rate_clauses)
coop = _COOP_RE.search(t) is not None
n_durations = len(_DURATION.findall(t))
# --- the grid's rate-count axis ----------------------------------------------------------- #
if n == 0:
# No explicit "per" rate. A cooperative cue + ≥2 completion durations + a "can"-style
# capability is the reciprocal work-rate form (1/(1/a+1/b)); else not CMB's domain.
if coop and n_durations >= 2 and _RECIPROCAL_RE.search(t):
return Refusal("reciprocal_work_rate_deferred", "durations-to-complete, not explicit rates")
return Refusal("not_combined_rate_shaped", "no explicit rate clause")
if n >= 3:
# Substantive ONLY with a combination cue; otherwise foreign prose that merely has ≥3 rate
# patterns -> step aside (hygiene).
if coop or any(_clause_role(t, s) == "drain" for s, _e in rate_spans):
return Refusal("three_or_more_rates", f"{n} explicit rates; CMB v1 combines exactly two")
return Refusal("not_combined_rate_shaped", "three or more rates but no combination cue")
if n == 1:
# One rate is substantive (missing a second) ONLY with a cooperative cue AND two conjoined
# agents in the premise; a lone rate (or an incidental "combined"/"both") is R3 territory.
if coop and any(m.start() < rate_spans[0][0] for m in _AGENT_CONJ.finditer(t)):
return Refusal("missing_second_rate", "a cooperation cue and two agents but only one explicit rate")
return Refusal("not_combined_rate_shaped", "a single rate with no two-agent combination cue")
# --- n == 2 -------------------------------------------------------------------------------- #
u0 = (_singular(rate_clauses[0][1]), _singular(rate_clauses[0][2]))
u1 = (_singular(rate_clauses[1][1]), _singular(rate_clauses[1][2]))
if _is_sequential(t, rate_spans):
return Refusal("not_combined_rate_shaped", "two rates with their own durations are sequential segments")
# The combine mode comes from the two clauses' OWN roles: a clean fill/drain opposition is the
# only difference; a cooperative cue is the only sum. Anything else has no usable cue.
roles = [_clause_role(t, s) for s, _e in rate_spans]
if sorted(roles) == ["drain", "fill"]:
mode = "difference"
elif coop:
mode = "sum"
else:
mode = None
if mode is None:
# combine_mode_ambiguous is substantive (CMB's domain), so it fires ONLY when the question
# actually asks to COMBINE the two same-unit rates — a total quantity/time, or the net/combined
# rate. Two same-unit rates merely COMPARED ("which is faster?"), or with a different unit, or
# with no combined query, are not a CMB problem -> step aside (router hygiene).
if u0 == u1:
hm = _HOW_MANY.search(t)
asked0 = _singular(hm.group(1)) if hm else None
combined_q = asked0 in u0 or _EFF_RATE_QUERY.search(t, rate_spans[-1][1]) is not None
# A query attributing the answer to a SINGLE agent ("how many words does Alice type") is a
# single-rate question with a distractor rate, NOT a combined query -> step aside, never
# claim the substantive combine_mode_ambiguous on it (router hygiene).
if combined_q and _SINGLE_AGENT_QUERY.search(t, rate_spans[-1][1]) is None:
return Refusal("combine_mode_ambiguous", "two same-unit rates and a combined query but no mode cue")
return Refusal("not_combined_rate_shaped", "two rates that are not a clean combined-rate problem")
# A combination cue IS present — now substantive combined-rate refusals are legitimate.
if u0 != u1:
return Refusal("rate_unit_mismatch", f"two rates differ in unit: {u0} vs {u1}")
if _CLOCK.search(t):
return Refusal("clock_interval_deferred", "an elapsed clock interval is not an explicit duration")
unit = RateUnit(*u0)
if mode == "difference":
# rate_a = fill (minuend), rate_b = drain (subtrahend), BY ROLE not by text order.
drain_idx = roles.index("drain")
rate_a, rate_b = (
(int(rate_clauses[1][0]), int(rate_clauses[0][0]))
if drain_idx == 0
else (int(rate_clauses[0][0]), int(rate_clauses[1][0]))
)
else:
rate_a, rate_b = int(rate_clauses[0][0]), int(rate_clauses[1][0])
# --- query slot + assembly: read the queried slot from AFTER the "how many" question, so a
# premise number or a transitional time marker is never mistaken for it. --------------------- #
if _EFF_RATE_QUERY.search(t, rate_spans[-1][1]):
return CombinedRateProblem(rate_a, rate_b, unit, mode, None, None, "effective_rate")
how_many = _HOW_MANY.search(t)
if how_many is None:
return Refusal("not_combined_rate_shaped", "no recognizable query slot") # defensive (no gold)
asked = _singular(how_many.group(1))
q_start = how_many.end()
if asked == unit.numerator: # quantity query — needs a duration in the rate's denominator unit
dur = _DURATION.search(t, q_start)
if dur is None:
return Refusal("not_combined_rate_shaped", "quantity query without a duration") # defensive
time_value, time_unit = int(dur.group(1)), _singular(dur.group(2))
if time_unit != unit.denominator:
# CMB v1 crosses no units (R3.2 conversion is single-rate only) — refuse rather than
# silently treat a non-denominator duration as the rate's unit.
return Refusal("rate_unit_mismatch", f"duration {time_unit!r} != rate denominator {unit.denominator!r}")
return CombinedRateProblem(rate_a, rate_b, unit, mode, time_value, None, "quantity", time_unit=time_unit)
if asked == unit.denominator: # time query — needs the target quantity (in the numerator unit)
qty = _digit_noun_after(t, q_start, unit.numerator)
if qty is None:
return Refusal("not_combined_rate_shaped", "time query without a target quantity") # defensive
return CombinedRateProblem(rate_a, rate_b, unit, mode, None, qty, "time")
return Refusal("not_combined_rate_shaped", f"unrecognized query target {asked!r}") # defensive
__all__ = ["read_combined_rate_problem"]