diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index 2a86fec1..425e9cec 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -469,7 +469,7 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment: missing.append("state_entity_continuity_unproven") scale = quantities.get(scale_id) if scale_id is not None else None - if scale is not None and not (0 < scale.value < 1): + if scale is not None and not _is_valid_fraction_decrease_scale(scale.value): missing.append("scale_out_of_range") categories = {hazard.category for hazard in frame.hazards} @@ -881,11 +881,30 @@ def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment: ) +def _is_valid_fraction_decrease_scale(scale_value: object) -> bool: + """Canonical semantic domain for fraction-decrease scale: finite ``0 < k < 1``. + + Shared by obligation assessment (``scale_out_of_range``) and geometric + VersorBinding admission so the geometric path cannot grant permission the + obligation layer denies. Accepts ``Fraction`` and numeric types via + ``float()``; rejects non-finite, zero, negative, one, and greater-than-one. + """ + try: + k = float(scale_value) # Fraction implements __float__ + except (TypeError, ValueError): + return False + return bool(np.isfinite(k) and 0.0 < k < 1.0) + + def _dilation_versor_payload(scale: float) -> np.ndarray: """CGA dilation versor for multiplicative scale ``k`` (cosh/sinh on log-ratio). Pure construction boundary — produces a 32-float payload with ``versor_condition < 1e-6`` by construction for positive finite ``k``. + + Note: mathematical dilation admits any positive finite ``k``. Organ-level + semantic domain for *fraction decrease* is stricter (``0 < k < 1``) and is + enforced by ``_is_valid_fraction_decrease_scale`` before binding. """ k = float(scale) if not np.isfinite(k) or k <= 0.0: @@ -929,6 +948,10 @@ def _versor_binding_from_scale_value( This is the post-pivot construction path: scale arrives as a grounded scalar fact on ProblemFrame — never re-parsed from "decrease to N/M of" prose. CGA dilation is a pure construction-boundary op on that number. + + Callers that represent *fraction-decrease* organ admission must gate with + ``_is_valid_fraction_decrease_scale`` first; this helper only enforces the + algebraic dilation precondition (positive finite scale). """ try: k = float(scale_value) # Fraction implements __float__ @@ -953,6 +976,10 @@ def _fraction_decrease_scale_binding(frame: ProblemFrame) -> VersorBinding | Non 2. Optional pack geometric_signature on the scale *surface* (named fractions / pack-ratified lemmas) when present — still identity = surface token, not a phrase regex. + + Domain: only ``0 < scale < 1`` (finite) may produce a VersorBinding. This + matches ``assess_fraction_decrease`` / ``scale_out_of_range`` so geometric + admission cannot bypass the obligation semantic domain. """ relations = [ relation @@ -980,24 +1007,36 @@ def _fraction_decrease_scale_binding(frame: ProblemFrame) -> VersorBinding | Non span = role_spans[0] source_span = (span.start, span.end) - # Prefer exact kernel scalar (slash_fraction / named fraction already - # canonicalized to Fraction on the frame). - binding = _versor_binding_from_scale_value( - getattr(scale_q, "value", scale_q), - semantic_identity=str(surface), - source_span=source_span, - ) - if binding is not None: - return binding + scale_value = getattr(scale_q, "value", scale_q) + + # Prefer exact kernel scalar when inside the organ domain. + if _is_valid_fraction_decrease_scale(scale_value): + binding = _versor_binding_from_scale_value( + scale_value, + semantic_identity=str(surface), + source_span=source_span, + ) + if binding is not None: + return binding + return None + + # Grounded numeric scale outside (0, 1): fail closed. Do not let pack + # geometric_signature re-admit an out-of-range kernel fact. + try: + float(scale_value) + except (TypeError, ValueError): + pass + else: + return None # Pack geometric_signature on scale surface (e.g. named fraction lemmas - # with senses geometric_signature) — substrate lookup only. + # with senses geometric_signature) — substrate lookup only; still domain-gated. signature = resolve_geometric_signature(str(surface)) if signature is None: return None _, geom = signature scale = _scale_from_geometric_signature(geom) - if scale is None: + if scale is None or not _is_valid_fraction_decrease_scale(scale): return None return _versor_binding_from_scale_value( scale, diff --git a/tests/test_math_candidate_graph_sprint8_r6_affine_lift.py b/tests/test_math_candidate_graph_sprint8_r6_affine_lift.py index a06ee003..453fc760 100644 --- a/tests/test_math_candidate_graph_sprint8_r6_affine_lift.py +++ b/tests/test_math_candidate_graph_sprint8_r6_affine_lift.py @@ -148,6 +148,16 @@ class TestConfuserRefusals: assert resolve_promotable_fraction_decrease(FINAL_VALUE_CONFUSER) is None assert _run(FINAL_VALUE_CONFUSER).answer is None + def test_scale_out_of_range_refuses_fraction_decrease(self): + """Geometric path must not promote scale>1 (negative decrease).""" + case = ( + "In one hour, the river will decrease to 5/4 of its level. " + "If the current level is 40 feet, what will the level decrease by?" + ) + assert resolve_promotable_fraction_decrease(case) is None + assert compose_fraction_decrease(case) is None + assert _run(case).answer is None + def test_unequal_split_refuses_percent_partition(self): assert resolve_promotable_percent_partition(UNEQUAL_SPLIT_CONFUSER) is None assert _run(UNEQUAL_SPLIT_CONFUSER).answer is None diff --git a/tests/test_problem_frame_contracts.py b/tests/test_problem_frame_contracts.py index 3ec20246..6be5be14 100644 --- a/tests/test_problem_frame_contracts.py +++ b/tests/test_problem_frame_contracts.py @@ -1,7 +1,15 @@ +from fractions import Fraction from pathlib import Path +import dataclasses + +import pytest + +from generate.derivation.fraction_decrease import resolve_promotable_fraction_decrease from generate.problem_frame_builder import build_problem_frame from generate.problem_frame_contracts import ( + _fraction_decrease_scale_binding, + _is_valid_fraction_decrease_scale, assess_contracts, assess_fraction_decrease, assess_geometric_proposals, @@ -177,6 +185,117 @@ def test_fraction_decrease_rejects_scale_greater_than_one() -> None: assert "scale_out_of_range" in assessment.missing_bindings +def test_fraction_decrease_scale_domain_predicate() -> None: + """Single shared domain: finite 0 < k < 1 for Fraction and float.""" + assert _is_valid_fraction_decrease_scale(Fraction(3, 4)) + assert _is_valid_fraction_decrease_scale(Fraction(2, 3)) + assert _is_valid_fraction_decrease_scale(0.5) + assert not _is_valid_fraction_decrease_scale(Fraction(0, 4)) + assert not _is_valid_fraction_decrease_scale(Fraction(4, 4)) + assert not _is_valid_fraction_decrease_scale(Fraction(5, 4)) + assert not _is_valid_fraction_decrease_scale(Fraction(-1, 4)) + assert not _is_valid_fraction_decrease_scale(0.0) + assert not _is_valid_fraction_decrease_scale(1.0) + assert not _is_valid_fraction_decrease_scale(1.25) + assert not _is_valid_fraction_decrease_scale(-0.5) + assert not _is_valid_fraction_decrease_scale(float("nan")) + assert not _is_valid_fraction_decrease_scale(float("inf")) + assert not _is_valid_fraction_decrease_scale(float("-inf")) + assert not _is_valid_fraction_decrease_scale("not-a-number") + assert not _is_valid_fraction_decrease_scale(None) + + +@pytest.mark.parametrize( + "scale_surface", + ["5/4", "4/4", "0/4"], + ids=["gt_one", "one", "zero"], +) +def test_fraction_decrease_geometric_refuses_out_of_range_scale(scale_surface: str) -> None: + """Geometric admission must agree with obligation scale_out_of_range (no bypass).""" + case = ( + f"In one hour, the river will decrease to {scale_surface} of its level. " + "If the current level is 40 feet, what will the level decrease by?" + ) + frame = build_problem_frame(case) + obligation = assess_fraction_decrease(frame) + assert not obligation.runnable + assert "scale_out_of_range" in obligation.missing_bindings + assert obligation.bindings == () or not obligation.bindings + + geom = [ + a + for a in assess_geometric_proposals(frame) + if a.candidate_organ == "fraction_decrease" + ] + assert geom, "expected a geometric fraction_decrease proposal assessment" + for assessment in geom: + assert not assessment.runnable + assert not assessment.bindings + + assert _fraction_decrease_scale_binding(frame) is None + assert resolve_promotable_fraction_decrease(case) is None + + +def test_fraction_decrease_scale_gt_one_live_repro_refuses() -> None: + """Audit repro: 5/4 must not promote a negative decrease answer.""" + case = ( + "In one hour, the river will decrease to 5/4 of its level. " + "If the current level is 40 feet, what will the level decrease by?" + ) + assert resolve_promotable_fraction_decrease(case) is None + frame = build_problem_frame(case) + obligation = assess_fraction_decrease(frame) + assert "scale_out_of_range" in obligation.missing_bindings + geom = assess_geometric_proposals(frame) + assert all( + not (a.candidate_organ == "fraction_decrease" and a.bindings) + for a in geom + ) + + +def test_fraction_decrease_rejects_injected_negative_zero_one_and_gt_one_scale() -> None: + """Domain gate on mutated Fraction scales (GroundedScalar is Fraction-only).""" + case = ( + "In one hour, Addison mountain's temperature will decrease to 3/4 of its temperature. " + "If the current temperature of the mountain is 84 degrees, what will the temperature " + "decrease by?" + ) + base_frame = build_problem_frame(case) + assert assess_fraction_decrease(base_frame).runnable + + # Locate scale quantity fact and replace value. + relation = next( + r for r in base_frame.bound_relations if r.relation_type == "decrease_to_fraction" + ) + scale_id = next(role.target_id for role in relation.roles if role.role == "scale") + mention = next(m for m in base_frame.mentions if m.mention_id == scale_id) + fact_id = mention.fact_id + assert fact_id is not None + + for bad_value in ( + Fraction(-1, 4), + Fraction(0, 1), + Fraction(1, 1), + Fraction(5, 4), + ): + new_quantities = [] + for q in base_frame.quantities: + if q.fact_id == fact_id: + new_quantities.append(dataclasses.replace(q, value=bad_value)) + else: + new_quantities.append(q) + frame = dataclasses.replace(base_frame, quantities=tuple(new_quantities)) + assessment = assess_fraction_decrease(frame) + assert not assessment.runnable, bad_value + assert "scale_out_of_range" in assessment.missing_bindings, bad_value + assert _fraction_decrease_scale_binding(frame) is None, bad_value + geom = assess_geometric_proposals(frame) + assert all( + not (a.candidate_organ == "fraction_decrease" and a.bindings) + for a in geom + ), bad_value + + def test_multi_base_candidate_is_refused() -> None: import dataclasses case = ( diff --git a/tests/test_relational_operator_ablation.py b/tests/test_relational_operator_ablation.py index ff620f84..393f6e96 100644 --- a/tests/test_relational_operator_ablation.py +++ b/tests/test_relational_operator_ablation.py @@ -236,22 +236,19 @@ def test_adversarial_refuse_closed(case: AblationCase) -> None: assert result.answer is None -def test_preexisting_scale_out_of_range_gap_isolated() -> None: - """Document pre-existing Gate A2k gap: scale>1 may still admit geometrically. +def test_scale_out_of_range_operator_and_baseline_refuse() -> None: + """Gate A2k: scale>1 is fail-closed on both baseline and geometric operator. - Frame assess_fraction_decrease marks scale_out_of_range (baseline refuses). - Geometric A2k path may still compute a negative delta — not introduced by - this ablation. Do not mask; pin the split so a future organ fix flips this. + Formerly pinned a pre-existing geometric bypass (negative delta). Obligation + and geometric admission now share 0 < scale < 1, so operator must refuse. """ base = run_baseline(SCALE_OOR_PREEXISTING) op = run_operator(SCALE_OOR_PREEXISTING) assert base.outcome == "refused" assert base.answer is None - # Pre-existing: operator may wrong-commit. If a future PR fixes A2k, this - # assertion should be updated to require refuse. - assert op.outcome in {"refused", "wrong"} - if op.outcome == "wrong": - assert op.answer is not None + assert op.outcome == "refused" + assert op.answer is None + assert op.operator_bindings == 0 def test_malformed_depth_labels_cannot_elevate_runnable() -> None: