diff --git a/core/comprehension_attempt/__init__.py b/core/comprehension_attempt/__init__.py index bd7ea247..5ad7b22b 100644 --- a/core/comprehension_attempt/__init__.py +++ b/core/comprehension_attempt/__init__.py @@ -6,7 +6,13 @@ over — uniform across the R1 and R2 setup compilers. Off-serving; imports no ` from __future__ import annotations -from core.comprehension_attempt.classify import classify_r1, classify_r2, classify_r3 +from core.comprehension_attempt.classify import ( + classify_cmb, + classify_r1, + classify_r2, + classify_r3, + cmb_reason, +) from core.comprehension_attempt.failure_family import ( REGISTRY, FailureFamily, @@ -20,7 +26,12 @@ from core.comprehension_attempt.proposal import ( build_proposal, emit_proposal, ) -from core.comprehension_attempt.router import RouteResult, RouteStatus, route_setup +from core.comprehension_attempt.router import ( + RouteResult, + RouteStatus, + cmb_is_authoritative, + route_setup, +) __all__ = [ "REGISTRY", @@ -33,9 +44,12 @@ __all__ = [ "Outcome", "RouteResult", "RouteStatus", + "classify_cmb", "classify_r1", "classify_r2", "classify_r3", + "cmb_is_authoritative", + "cmb_reason", "enrich_family", "family_by_name", "family_for_reason", diff --git a/core/comprehension_attempt/classify.py b/core/comprehension_attempt/classify.py index f0b66159..7342066e 100644 --- a/core/comprehension_attempt/classify.py +++ b/core/comprehension_attempt/classify.py @@ -12,6 +12,8 @@ from __future__ import annotations from typing import Any from core.comprehension_attempt.model import ComprehensionAttempt +from generate.combined_rate_comprehension.model import CombinedRateProblem +from generate.combined_rate_comprehension.reader import read_combined_rate_problem from generate.constraint_comprehension.model import ConstraintProblem from generate.constraint_comprehension.reader import read_constraint_problem from generate.meaning_graph.reader import Refusal @@ -19,6 +21,19 @@ from generate.quantitative_comprehension import comprehend_quantitative, to_rela from generate.rate_comprehension.model import RateProblem from generate.rate_comprehension.reader import read_rate_problem +#: CMB reader/solver reasons that are namespaced ``cmb_*`` before the (reason-string-keyed) failure +#: registry sees them, so a CMB boundary never inherits R2/R3's family for the SAME bare string +#: (R3 ``rate_unit_mismatch`` is a growth surface; R2/R3 ``non_integer_solution`` carry other +#: owners). The two ``input_shape`` reasons stay bare so they map to the cross ``input_shape`` +#: family (router hygiene). A ``cmb_``-prefixed attempt reason is also the signal that CMB +#: *substantively* recognized the text (used by the CMB-over-R3 precedence rule in the router). +_CMB_BARE_REASONS = frozenset({"not_combined_rate_shaped", "empty"}) + + +def cmb_reason(reason: str) -> str: + """Namespace a CMB refusal reason for the failure registry (see :data:`_CMB_BARE_REASONS`).""" + return reason if reason in _CMB_BARE_REASONS else f"cmb_{reason}" + def _r1_signature(relations: list[dict[str, Any]]) -> str: """Deterministic, order-independent string signature of the projected R1 relations.""" @@ -109,4 +124,36 @@ def classify_r3(text: str, *, case_id: str | None = None) -> ComprehensionAttemp ) -__all__ = ["classify_r1", "classify_r2", "classify_r3"] +def _cmb_signature(problem: CombinedRateProblem) -> str: + """Deterministic string signature of an R4 combined-rate setup. The leading ``"cmb"`` tag + guarantees it never coincides with an R1/R2/R3 signature (cross-organ distinctness). ``sum`` is + commutative, so its two rates are sorted; ``difference`` keeps order (which rate is the drain).""" + rates = (problem.rate_a, problem.rate_b) + if problem.combine_mode == "sum": + rates = tuple(sorted(rates)) + return repr( + ( + "cmb", + problem.combine_mode, + (problem.rate_unit.numerator, problem.rate_unit.denominator), + rates, + ("time", problem.time), + ("quantity", problem.quantity), + problem.query, + ) + ) + + +def classify_cmb(text: str, *, case_id: str | None = None) -> ComprehensionAttempt: + """Attempt the R4 combined-rate setup compiler on *text* (refusal reasons namespaced ``cmb_*``).""" + problem = read_combined_rate_problem(text) + if isinstance(problem, Refusal): + return ComprehensionAttempt( + "r4_combined_rate", "setup_refused", case_id=case_id, refusal_reason=cmb_reason(problem.reason) + ) + return ComprehensionAttempt( + "r4_combined_rate", "setup_correct", case_id=case_id, setup_signature=_cmb_signature(problem) + ) + + +__all__ = ["classify_cmb", "classify_r1", "classify_r2", "classify_r3", "cmb_reason"] diff --git a/core/comprehension_attempt/failure_family.py b/core/comprehension_attempt/failure_family.py index 4430951d..f6bd80e7 100644 --- a/core/comprehension_attempt/failure_family.py +++ b/core/comprehension_attempt/failure_family.py @@ -29,7 +29,7 @@ from typing import Literal from core.comprehension_attempt.model import ComprehensionAttempt -Owner = Literal["r1", "r2", "r3", "cross"] +Owner = Literal["r1", "r2", "r3", "r4", "cross"] @dataclass(frozen=True, slots=True) @@ -55,7 +55,7 @@ REGISTRY: tuple[FailureFamily, ...] = ( "empty", "no_quantity_template", "non_digit_quantity", "non_identifier_name", "unreadable_quantity_query", "invalid_binding_graph", "query_target_not_a_category", "unprojectable", "category_pair_not_found", "query_target_unrecognized", "no_query", - "not_rate_shaped", + "not_rate_shaped", "not_combined_rate_shaped", ), ), FailureFamily( @@ -161,6 +161,54 @@ REGISTRY: tuple[FailureFamily, ...] = ( "fire on non-rate text, so temporal_state is not reliably rate-like.", refusal_reasons=("temporal_state",), ), + # --- R4 combined-rate organ (reasons namespaced cmb_*; CMB owns them over R3's same-string ---- # + # reasons via the CMB-over-R3 precedence rule in the router) ------------------------------ # + FailureFamily( + "cmb_unit_mismatch", "r4", True, False, + "refuse — the two rates' units are incompatible. must_remain_refused UNTIL a dimension " + "registry exists: CMB v1 cannot distinguish a convertible pair (gallons/min vs gallons/hour) " + "from a dimensionally-incompatible one (rooms/hour vs liters/minute), so a 'try conversion' " + "proposal would be wrong=0-unsafe. NOT 'forever impossible' — a convertible split is future work.", + refusal_reasons=("cmb_rate_unit_mismatch",), + ), + FailureFamily( + "cmb_combine_ambiguous", "r4", True, False, + "refuse — two same-unit rates but no licensed sum/difference cue. An ambiguity, not a " + "coverage gap: no fixture can teach which mode the text intends.", + refusal_reasons=("cmb_combine_mode_ambiguous",), + ), + FailureFamily( + "cmb_underdetermined", "r4", True, False, + "refuse — combined-shaped but a contributing rate is unstated (under-specified input); no " + "reader enhancement can infer the missing rate.", + refusal_reasons=("cmb_missing_second_rate",), + ), + FailureFamily( + "cmb_non_positive_net", "r4", True, False, + "refuse — non-positive net rate; a quantity/time query cannot make progress (solver boundary).", + refusal_reasons=("cmb_non_positive_net_rate",), + ), + FailureFamily( + "cmb_non_integer", "r4", True, False, + "refuse — no exact integer answer; never round (solver boundary, exact-integer v1).", + refusal_reasons=("cmb_non_integer_solution",), + ), + # --- R4 growth surfaces (proposal allowed) — emitted ONLY after positive combined recognition -- # + FailureFamily( + "cmb_unsupported_rate_count", "r4", False, True, + "propose a combined-rate fixture for ≥3 contributing rates (future capability)", + proposal_target="cmb_gold_fixture", refusal_reasons=("cmb_three_or_more_rates",), + ), + FailureFamily( + "cmb_unsupported_reciprocal", "r4", False, True, + "propose a reciprocal work-rate fixture (1/(1/a+1/b); reciprocal rates + rational arithmetic)", + proposal_target="cmb_gold_fixture", refusal_reasons=("cmb_reciprocal_work_rate_deferred",), + ), + FailureFamily( + "cmb_unsupported_clock_interval", "r4", False, True, + "propose a clock-interval fixture (elapsed-clock-time duration in a combined-rate problem)", + proposal_target="cmb_gold_fixture", refusal_reasons=("cmb_clock_interval_deferred",), + ), ) _BY_NAME: dict[str, FailureFamily] = {f.name: f for f in REGISTRY} diff --git a/core/comprehension_attempt/model.py b/core/comprehension_attempt/model.py index 181fd8cc..2b813fd8 100644 --- a/core/comprehension_attempt/model.py +++ b/core/comprehension_attempt/model.py @@ -22,7 +22,7 @@ from typing import Literal from generate.binding_graph.model import SourceSpanLink -Organ = Literal["r1_quantitative", "r2_constraints", "r3_rate"] +Organ = Literal["r1_quantitative", "r2_constraints", "r3_rate", "r4_combined_rate"] Outcome = Literal[ "setup_correct", # an admissible setup was produced (produce-mode) / matches gold (eval-mode) diff --git a/core/comprehension_attempt/router.py b/core/comprehension_attempt/router.py index cd843f33..52a7fde3 100644 --- a/core/comprehension_attempt/router.py +++ b/core/comprehension_attempt/router.py @@ -22,7 +22,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal -from core.comprehension_attempt.classify import classify_r1, classify_r2, classify_r3 +from core.comprehension_attempt.classify import classify_cmb, classify_r1, classify_r2, classify_r3 from core.comprehension_attempt.model import ComprehensionAttempt RouteStatus = Literal["routed", "all_refused", "ambiguous"] @@ -38,14 +38,33 @@ class RouteResult: status: RouteStatus +def cmb_is_authoritative(attempts: tuple[ComprehensionAttempt, ...]) -> bool: + """True when the R4 combined-rate organ **positively** recognized combined-rate shape — a setup, + or a substantive ``cmb_*`` refusal (NOT the bare ``not_combined_rate_shaped`` / ``empty`` + step-aside). When it does, R3's broader single-rate read of the SAME text is inadmissible: a more + specific positive recognition beats a broader partial one ("Anna and Ben paint together; Anna + paints 3 rooms/hour" must not be answered as a single-rate 12). This is the narrow CMB↔R3 + instance of a future general domain-specificity adjudication; it does NOT mean "CMB always wins" + (on ``input_shape`` CMB cedes to R3, e.g. a plain single-rate car problem).""" + cmb = next((a for a in attempts if a.organ == "r4_combined_rate"), None) + if cmb is None: + return False + return cmb.is_setup_correct or (cmb.refusal_reason or "").startswith("cmb_") + + def route_setup(text: str, *, case_id: str | None = None) -> RouteResult: """Route *text* to the single organ that admits an honest setup, or refuse.""" attempts = ( classify_r1(text, case_id=case_id), classify_r2(text, case_id=case_id), classify_r3(text, case_id=case_id), + classify_cmb(text, case_id=case_id), + ) + # CMB-over-R3 domain precedence: a substantive CMB recognition vetoes R3's single-rate over-read. + vetoed = cmb_is_authoritative(attempts) + correct = tuple( + a for a in attempts if a.is_setup_correct and not (vetoed and a.organ == "r3_rate") ) - correct = tuple(a for a in attempts if a.is_setup_correct) if len(correct) == 1: return RouteResult(attempts, correct[0], "routed") if not correct: @@ -55,4 +74,4 @@ def route_setup(text: str, *, case_id: str | None = None) -> RouteResult: return RouteResult(attempts, None, "ambiguous") -__all__ = ["RouteResult", "RouteStatus", "route_setup"] +__all__ = ["RouteResult", "RouteStatus", "cmb_is_authoritative", "route_setup"] diff --git a/generate/contemplation/pass_manager.py b/generate/contemplation/pass_manager.py index 6dcb4067..9afd0d6b 100644 --- a/generate/contemplation/pass_manager.py +++ b/generate/contemplation/pass_manager.py @@ -32,12 +32,16 @@ from typing import Any from core.comprehension_attempt import ( ComprehensionAttempt, + cmb_is_authoritative, + cmb_reason, emit_proposal, enrich_family, family_for_reason, route_setup, ) from generate.answer_choices.verify import verify_answer_choice +from generate.combined_rate_comprehension.reader import read_combined_rate_problem +from generate.combined_rate_comprehension.solver import solve_combined_rate from generate.constraint_comprehension.reader import read_constraint_problem from generate.constraint_comprehension.solver import answer_constraint_problem from generate.rate_comprehension.reader import read_rate_problem @@ -110,6 +114,8 @@ def contemplate( return _solve_and_verify_r2(text, options, answer_key, findings, attempts) if route.selected.organ == "r3_rate": return _solve_and_verify_r3(text, options, answer_key, findings, attempts) + if route.selected.organ == "r4_combined_rate": + return _solve_and_verify_cmb(text, options, answer_key, findings, attempts) findings.append(Finding("solve", "r1 admissible setup (numeric answer is the eval lane in v0)")) findings.append(Finding("terminal", Terminal.SOLVED_VERIFIED.value)) return ContemplationResult( @@ -206,13 +212,65 @@ def _solve_and_verify_r3( ) +def _solve_and_verify_cmb( + text: str, + options: dict[str, Any] | None, + answer_key: str | None, + findings: list[Finding], + attempts: tuple[ComprehensionAttempt, ...], +) -> ContemplationResult: + problem = read_combined_rate_problem(text) + assert not isinstance(problem, Refusal) # routed => the reader admitted a setup + value = solve_combined_rate(problem) + if isinstance(value, Refusal): + # The prose WAS understood (reader setup_correct); the resulting math is outside v1's + # answerable boundary. A solver refusal is a terminal boundary, never a proposal — and the + # reason is namespaced cmb_* so it resolves to the CMB solver family, not R2/R3's. + findings.append(Finding("solve", f"solver refused: {value.reason}")) + findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) + return ContemplationResult( + Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, + selected_organ="r4_combined_rate", family=_family_name(cmb_reason(value.reason)), + ) + if options is not None: + verdict = verify_answer_choice(value, options, answer_key) + if isinstance(verdict, Refusal): + findings.append(Finding("verify", f"answer-choice refused: {verdict.reason}")) + findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) + return ContemplationResult( + Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, + selected_organ="r4_combined_rate", answer=value, + ) + if verdict.status == "contradiction": + findings.append(Finding("verify", verdict.message)) + findings.append(Finding("terminal", Terminal.CONTRADICTION_DETECTED.value)) + return ContemplationResult( + Terminal.CONTRADICTION_DETECTED, tuple(findings), attempts, + selected_organ="r4_combined_rate", answer=value, + family="answer_key_contradiction", message=verdict.message, + ) + findings.append(Finding("verify", verdict.message)) + findings.append(Finding("solve", f"value={value}")) + findings.append(Finding("terminal", Terminal.SOLVED_VERIFIED.value)) + return ContemplationResult( + Terminal.SOLVED_VERIFIED, tuple(findings), attempts, + selected_organ="r4_combined_rate", answer=value, + ) + + def _classify_all_refused( text: str, attempts: tuple[ComprehensionAttempt, ...], findings: list[Finding], proposal_root: Path | None, ) -> ContemplationResult: - families = [(a, family_for_reason(a.refusal_reason)) for a in attempts] + # CMB-over-R3 precedence (family side): when CMB substantively recognized the text, R3's broader + # partial classification is suppressed, so CMB's sharper diagnosis owns the terminal/proposal + # (e.g. a reciprocal combined-rate text proposes a CMB fixture, not R3's rate_underdetermined). + considered = attempts + if cmb_is_authoritative(attempts): + considered = tuple(a for a in attempts if a.organ != "r3_rate") + families = [(a, family_for_reason(a.refusal_reason)) for a in considered] # Boundary-first: a substantive recognized boundary blocks any proposal. for _attempt, family in families: diff --git a/tests/test_cmb_router_contemplation.py b/tests/test_cmb_router_contemplation.py new file mode 100644 index 00000000..34efd762 --- /dev/null +++ b/tests/test_cmb_router_contemplation.py @@ -0,0 +1,165 @@ +"""Wiring CMB (R4 combined-rate) into the multi-organ router + bounded contemplation (CMB-d). + +Pins the integration matrix and the CMB-over-R3 domain-precedence rule: + - supported CMB setup -> SOLVED_VERIFIED (organ r4) + - CMB solver boundary -> REFUSED_KNOWN_BOUNDARY (no proposal) + - CMB substantive reader refusal -> REFUSED_KNOWN_BOUNDARY (cmb_* family) + - CMB deferred-capability refusal -> PROPOSAL_EMITTED (cmb_unsupported_*, owner r4, proposal-only) + - cmb-11 (missing_second_rate) -> R3's single-rate over-read is VETOED; REFUSED, never a wrong 12 + - cmb-15 (genuine single-rate) -> CMB cedes (input_shape); R3 SOLVES 180 + - R1/R2/R3 gold unaffected; no gold routes ambiguous. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from core.comprehension_attempt import classify_cmb, cmb_reason, route_setup +from evals.combined_rate_oracle.runner import _load_combined_rate_gold +from evals.constraint_oracle.runner import _load_r2_gold +from evals.rate_oracle.runner import _load_rate_gold +from evals.setup_oracle.runner import _load_r1_gold +from generate.contemplation import Terminal, contemplate + +_REFUSE_FAMILY = { # CMB substantive reader refusals -> REFUSED_KNOWN_BOUNDARY via these families + "rate_unit_mismatch": "cmb_unit_mismatch", + "combine_mode_ambiguous": "cmb_combine_ambiguous", + "missing_second_rate": "cmb_underdetermined", +} +_PROPOSAL_FAMILY = { # CMB deferred-capability refusals -> PROPOSAL_EMITTED via these families + "three_or_more_rates": "cmb_unsupported_rate_count", + "reciprocal_work_rate_deferred": "cmb_unsupported_reciprocal", + "clock_interval_deferred": "cmb_unsupported_clock_interval", +} + + +def _by_id(fid: str) -> dict: + return next(f for f in _load_combined_rate_gold() if f["id"] == fid) + + +# --- classification + routing -------------------------------------------------------------- # + + +def test_classify_cmb_matches_gold_with_namespaced_reasons() -> None: + for fx in _load_combined_rate_gold(): + att = classify_cmb(fx["text"], case_id=fx["id"]) + assert att.organ == "r4_combined_rate" + if fx["expect"] in ("solved", "solver_refuses"): + assert att.outcome == "setup_correct" and att.setup_signature is not None + else: + assert att.outcome == "setup_refused" + assert att.refusal_reason == cmb_reason(fx["reader_reason"]) + + +def test_router_routes_combined_to_cmb_vetoes_r3_overread_and_cedes_single_rate() -> None: + routed_cmb = 0 + for fx in _load_combined_rate_gold(): + r = route_setup(fx["text"]) + assert len(r.attempts) == 4 and r.status != "ambiguous" + if fx["expect"] in ("solved", "solver_refuses"): + assert r.selected is not None and r.selected.organ == "r4_combined_rate", fx["id"] + routed_cmb += 1 + assert routed_cmb == 11 # 6 solved + 5 solver_refuses + # the veto: a missing-second-rate combined problem must NOT route to R3 (which would solve it wrong) + assert route_setup(_by_id("cmb-11-missing-second-rate")["text"]).selected is None + # the cede: a genuine single-rate problem CMB stepped aside on routes to R3 + assert route_setup(_by_id("cmb-15-not-combined-shaped")["text"]).selected.organ == "r3_rate" + + +def test_r1_r2_r3_gold_not_stolen_by_cmb_and_never_ambiguous() -> None: + for fx in _load_r1_gold() + _load_r2_gold() + _load_rate_gold(): + r = route_setup(fx["text"]) + assert r.status != "ambiguous", fx.get("id") + if r.selected is not None: + assert r.selected.organ != "r4_combined_rate", fx.get("id") + + +# --- contemplation terminals --------------------------------------------------------------- # + + +def test_cmb_contemplation_terminal_matrix(tmp_path: Path) -> None: + for fx in _load_combined_rate_gold(): + kw = {"options": fx["options"], "answer_key": fx["answer"]} if fx["expect"] == "solved" else {} + r = contemplate(fx["text"], proposal_root=tmp_path, case_id=fx["id"], **kw) + if fx["expect"] == "solved": + assert r.terminal == Terminal.SOLVED_VERIFIED and r.selected_organ == "r4_combined_rate" + assert r.answer == fx["gold"], fx["id"] + elif fx["expect"] == "solver_refuses": + assert r.terminal == Terminal.REFUSED_KNOWN_BOUNDARY and r.answer is None, fx["id"] + expected = "cmb_non_positive_net" if fx["solver_reason"] == "non_positive_net_rate" else "cmb_non_integer" + assert r.family == expected, fx["id"] + else: + reason = fx["reader_reason"] + if reason in _REFUSE_FAMILY: + assert r.terminal == Terminal.REFUSED_KNOWN_BOUNDARY and r.family == _REFUSE_FAMILY[reason], fx["id"] + assert r.answer is None + elif reason in _PROPOSAL_FAMILY: + assert r.terminal == Terminal.PROPOSAL_EMITTED and r.family == _PROPOSAL_FAMILY[reason], fx["id"] + + +def test_cmb11_veto_yields_refusal_not_a_wrong_answer(tmp_path: Path) -> None: + r = contemplate(_by_id("cmb-11-missing-second-rate")["text"], proposal_root=tmp_path) + assert r.terminal == Terminal.REFUSED_KNOWN_BOUNDARY + assert r.family == "cmb_underdetermined" and r.answer is None + assert not list(tmp_path.glob("*.json")) # an under-specified input is not a growth proposal + + +def test_cmb15_cedes_to_r3_which_solves_the_single_rate(tmp_path: Path) -> None: + r = contemplate(_by_id("cmb-15-not-combined-shaped")["text"], proposal_root=tmp_path) + assert r.terminal == Terminal.SOLVED_VERIFIED and r.selected_organ == "r3_rate" and r.answer == 180 + + +def test_cmb_solver_boundaries_never_propose(tmp_path: Path) -> None: + # non_positive_net_rate / non_integer_solution: prose understood, math outside v1 -> terminal + # refusal, NEVER a growth proposal (protects against a future registry change). + for fid in ("cmb-07-tank-non-positive-net", "cmb-08-paint-non-integer-time"): + r = contemplate(_by_id(fid)["text"], proposal_root=tmp_path) + assert r.terminal == Terminal.REFUSED_KNOWN_BOUNDARY and r.selected_organ == "r4_combined_rate" + assert not list(tmp_path.glob("*.json")) + + +def test_cmb_deferred_capabilities_emit_cmb_owned_proposal_only_artifacts(tmp_path: Path) -> None: + from core.comprehension_attempt import family_by_name + + for fid, family in ( + ("cmb-12-three-rates", "cmb_unsupported_rate_count"), + ("cmb-13-reciprocal-work-rate", "cmb_unsupported_reciprocal"), + ("cmb-14-clock-interval", "cmb_unsupported_clock_interval"), + ): + r = contemplate(_by_id(fid)["text"], proposal_root=tmp_path, case_id=fid) + assert r.terminal == Terminal.PROPOSAL_EMITTED and r.family == family, fid + fam = family_by_name(family) + assert fam.owner == "r4" and fam.proposal_target == "cmb_gold_fixture" and not fam.must_remain_refused + assert r.proposal_path is not None + artifact = json.loads(Path(r.proposal_path).read_text()) + # proposal-only: never mounted, requires review (the N5 emitter contract). + assert artifact.get("mounted") is False and artifact.get("requires_review") is True + + +def test_cmb_wiring_modules_are_off_serving() -> None: + # classify_cmb + the contemplation pass that runs it must import no serving path — substring + # scans false-negative on docstring mentions, so check actual imports via AST. + import ast + + import core.comprehension_attempt.classify as classify_mod + import generate.contemplation.pass_manager as pass_mod + + forbidden = ("generate.derivation", "core.reliability_gate") + for mod in (classify_mod, pass_mod): + for node in ast.walk(ast.parse(Path(str(mod.__file__)).read_text(encoding="utf-8"))): + names = ( + [a.name for a in node.names] if isinstance(node, ast.Import) + else [node.module or ""] if isinstance(node, ast.ImportFrom) + else [] + ) + for name in names: + assert not any(name.startswith(t) for t in forbidden), f"{mod.__name__} imports {name}" + + +def test_no_combined_rate_gold_routes_ambiguous() -> None: + # AMBIGUOUS_ORGAN is a router invariant for two organs admitting with conflicting signatures. The + # readers are mutually exclusive on shape (R3 refuses >=2 rates; CMB refuses <2), and signatures + # are organ-tagged, so this never occurs naturally — proven here across the whole CMB corpus. + for fx in _load_combined_rate_gold(): + assert route_setup(fx["text"]).status != "ambiguous", fx["id"] diff --git a/tests/test_failure_family.py b/tests/test_failure_family.py index 8136a0b3..5457e5b3 100644 --- a/tests/test_failure_family.py +++ b/tests/test_failure_family.py @@ -38,6 +38,12 @@ ALL_REASONS = { # R3 rate reader "rate_unit_mismatch", "combined_rates", "missing_rate", "missing_time", "missing_quantity", "temporal_state", "query_target_unrecognized", "no_query", "not_rate_shaped", + # R4 combined-rate (reader + solver reasons namespaced cmb_*; not_combined_rate_shaped is the + # bare step-aside reason -> the cross input_shape family). + "not_combined_rate_shaped", + "cmb_rate_unit_mismatch", "cmb_combine_mode_ambiguous", "cmb_missing_second_rate", + "cmb_three_or_more_rates", "cmb_reciprocal_work_rate_deferred", "cmb_clock_interval_deferred", + "cmb_non_positive_net_rate", "cmb_non_integer_solution", } @@ -66,7 +72,10 @@ def test_only_precise_missing_totals_are_reachable_growth_surfaces() -> None: # Only the PRECISE R2 gaps are reachable growth surfaces. category_pair_not_found is too broad # (fires on any non-R2 text), so it maps to input_shape, and missing_category_pair is reserved. growth = {f.name for f in REGISTRY if f.proposal_allowed and f.refusal_reasons} - assert growth == {"missing_total_count", "missing_weighted_total", "unsupported_rate_duration"} + assert growth == { + "missing_total_count", "missing_weighted_total", "unsupported_rate_duration", + "cmb_unsupported_rate_count", "cmb_unsupported_reciprocal", "cmb_unsupported_clock_interval", + } assert family_for_reason("category_pair_not_found").name == "input_shape" for f in REGISTRY: if f.proposal_allowed: diff --git a/tests/test_r3_router_contemplation.py b/tests/test_r3_router_contemplation.py index 82a01cea..5e2c1fdf 100644 --- a/tests/test_r3_router_contemplation.py +++ b/tests/test_r3_router_contemplation.py @@ -30,7 +30,7 @@ def test_router_routes_rate_to_r3_and_stays_exclusive() -> None: routed = 0 for fx in _load_rate_gold(): r = route_setup(fx["text"]) - assert len(r.attempts) == 3 and r.status != "ambiguous" + assert len(r.attempts) == 4 and r.status != "ambiguous" if r.selected is not None: assert r.selected.organ == "r3_rate" routed += 1 diff --git a/tests/test_router_organ_hygiene.py b/tests/test_router_organ_hygiene.py index a36d38fb..500ab5a8 100644 --- a/tests/test_router_organ_hygiene.py +++ b/tests/test_router_organ_hygiene.py @@ -18,6 +18,7 @@ to ``_GOLD`` and its classifier to ``_ORGANS``, and this test enforces the rule from __future__ import annotations from core.comprehension_attempt import ( + classify_cmb, classify_r1, classify_r2, classify_r3, @@ -27,10 +28,17 @@ from evals.constraint_oracle.runner import _load_r2_gold from evals.rate_oracle.runner import _load_rate_gold from evals.setup_oracle.runner import _load_r1_gold +# CMB (r4) is tested as an ORGAN against R1/R2/R3 foreign gold — it MUST step aside as input_shape. +# It is deliberately NOT added to _GOLD: a CMB problem is not *foreign* to R3 — R3 genuinely +# co-recognizes the rate clauses (refusing `combined_rates`, a growth surface), so the strict +# "must be input_shape" rule does not apply in that direction. That asymmetry is governed instead by +# the CMB-over-R3 domain-precedence rule (`cmb_is_authoritative`), verified end-to-end in +# tests/test_cmb_router_contemplation.py. _ORGANS = { "r1_quantitative": classify_r1, "r2_constraints": classify_r2, "r3_rate": classify_r3, + "r4_combined_rate": classify_cmb, } _GOLD = { "r1_quantitative": _load_r1_gold, diff --git a/tests/test_setup_router.py b/tests/test_setup_router.py index f28736ff..1544062c 100644 --- a/tests/test_setup_router.py +++ b/tests/test_setup_router.py @@ -49,4 +49,4 @@ def test_router_never_selects_a_refusal() -> None: result = route_setup(fx["text"]) if result.selected is not None: assert result.selected.outcome == "setup_correct" - assert len(result.attempts) == 3 # always one R1 + one R2 + one R3 attempt + assert len(result.attempts) == 4 # always one R1 + R2 + R3 + R4(combined-rate) attempt