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
This commit is contained in:
Shay 2026-07-20 15:07:12 -07:00
parent 9f85832baa
commit 8c4221d496
2 changed files with 54 additions and 7 deletions

View file

@ -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,
)
)

View file

@ -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: abcd. Refuse ac and bd so the only multi-hop bridge to ad
would be the non-admissible ac path. Closure must not invent ad.
"""
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():