From 8c4221d4969d88561b0937c8b468c0acd71b24cc Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 20 Jul 2026 15:07:12 -0700 Subject: [PATCH] fix(stage3): gate inductive expansion on geometric admissibility Non-admissible candidates no longer enter work/derived. Only admissible base and derived edges seed fixed-point steps, so multi-step paths close only under geometric conditions (Stage 3 exit). Updates tests that previously locked in admissible=False-but-still-promoted behavior. [Verification]: tests/test_stage3_epistemic_inductive.py 10 passed --- core/cognition/inductive_closure.py | 21 ++++++++++--- tests/test_stage3_epistemic_inductive.py | 40 ++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/core/cognition/inductive_closure.py b/core/cognition/inductive_closure.py index 8014eedd..5eab4e48 100644 --- a/core/cognition/inductive_closure.py +++ b/core/cognition/inductive_closure.py @@ -106,8 +106,13 @@ def expand_relation_closure( * Cycle: if path would revisit a node, skip (no infinite loop). * Contradiction: two different tails for the same (head, relation) among base facts mark contradiction=True. - * Geometric admissibility: each *derived* relation is stamped - admissible only when ``geometric_admissible(h,r,t)`` is True. + * Geometric admissibility (Stage 3 exit gate): a candidate edge is + *promoted* into the expansion frontier (``work``) and into + ``derived`` **only** when ``geometric_admissible(h,r,t)`` is True. + Non-admissible candidates are excluded — they must not seed further + fixed-point steps (no ``admissible=False`` intermediates that still + expand). Base store facts remain visible in ``base`` with their + admissibility stamp, but only admissible base edges enter ``work``. Default requires non-empty distinct endpoints; pipeline supplies versor-grounded checks when session vocab is available. * Termination: no new triples or budget exhausted. @@ -163,7 +168,10 @@ def expand_relation_closure( ) derived: list[DerivedRelation] = [] - work = set(known) + # Frontier is geometry-gated: non-admissible base never seeds expansion. + work: set[tuple[str, str, str]] = { + dr.as_triple() for dr in base_list if dr.admissible + } steps_taken = 0 fixed_point = False truncated = False @@ -183,8 +191,11 @@ def expand_relation_closure( key3 = (a, r, c) if key3 in work: continue + # Promote only when geometrically admissible — never + # park an inadmissible edge in work to seed hop k+1. + if not bool(geom(a, r, c)): + continue path = (a, b, c) - ok = bool(geom(a, r, c)) new_edges.append( DerivedRelation( head=a, @@ -192,7 +203,7 @@ def expand_relation_closure( tail=c, path=path, step=step, - admissible=ok, + admissible=True, contradiction=False, ) ) diff --git a/tests/test_stage3_epistemic_inductive.py b/tests/test_stage3_epistemic_inductive.py index 04f07bb2..a0c35a04 100644 --- a/tests/test_stage3_epistemic_inductive.py +++ b/tests/test_stage3_epistemic_inductive.py @@ -77,6 +77,11 @@ def test_inductive_closure_derives_two_hop(): def test_inductive_derived_requires_geometric_admissibility(): + """Non-admissible candidates must not be promoted into derived or work. + + Stage 3 exit: multi-step paths close ONLY under geometric conditions. + Stamping admissible=False while still seeding further expansion is a leak. + """ triples = ( ("a", "is", "b"), ("b", "is", "c"), @@ -89,8 +94,39 @@ def test_inductive_derived_requires_geometric_admissibility(): res = expand_relation_closure( triples, budget=8, geometric_admissible=refuse_all ) - assert any(d.head == "a" and d.tail == "c" for d in res.derived) - assert all(d.admissible is False for d in res.derived) + # Refuse-all: base stays store-visible but no derived promotion. + assert len(res.derived) == 0 + assert not any(d.head == "a" and d.tail == "c" for d in res.derived) + assert all(b.admissible is False for b in res.base) + + +def test_inductive_non_admissible_intermediate_does_not_seed_expansion(): + """a→c rejected must not yield a→d solely via that intermediate. + + Graph: a→b→c→d. Refuse a→c and b→d so the only multi-hop bridge to a→d + would be the non-admissible a→c path. Closure must not invent a→d. + """ + triples = ( + ("a", "is", "b"), + ("b", "is", "c"), + ("c", "is", "d"), + ) + + def geom(h: str, r: str, t: str) -> bool: + del r + # Block the two-hop bridges that would otherwise seed a→d. + if (h, t) in {("a", "c"), ("b", "d")}: + return False + return True + + res = expand_relation_closure( + triples, budget=8, geometric_admissible=geom + ) + derived_pairs = {(d.head, d.tail) for d in res.derived} + assert ("a", "c") not in derived_pairs + assert ("b", "d") not in derived_pairs + assert ("a", "d") not in derived_pairs + assert all(d.admissible for d in res.derived) def test_inductive_closure_detects_contradiction():