diff --git a/src/aelfrice/reason.py b/src/aelfrice/reason.py index 6b6232de0..e8f7e3df8 100644 --- a/src/aelfrice/reason.py +++ b/src/aelfrice/reason.py @@ -114,9 +114,40 @@ class Impasse: CLOSE_MEAN_DELTA: Final[float] = 0.15 """Two posterior means whose absolute difference is below this delta -count as "similar" for ``TIE`` detection. Empirical: a 0.15 gap in -posterior mean is roughly the noise floor of a ``Beta(2, 4)`` vs -``Beta(3, 3)`` comparison (both ~6 trials).""" +count as "similar" for the R1 posterior-mean ``TIE`` rule. Empirical: +a 0.15 gap in posterior mean is roughly the noise floor of a +``Beta(2, 4)`` vs ``Beta(3, 3)`` comparison (both ~6 trials). + +This constant applies to per-belief posterior means only. The +fork-aware ``TIE`` rule on ``ConsequencePath.compound_confidence`` +uses :data:`COMPOUND_TIE_FLOOR` + :data:`COMPOUND_TIE_REL_TOL` +instead (#668) — multiplicative compound scores need a +ratio-based test, not an absolute-diff one, to stay sensible across +varying path depths.""" + +COMPOUND_TIE_FLOOR: Final[float] = 0.10 +"""Minimum ``compound_confidence`` for a forked-path pair to be +eligible for ``TIE`` detection. Pairs whose lower-compound side is +below this floor are not "comparable" in any useful sense — they +both decayed too hard along their respective paths to constitute +meaningful evidence either way. Empirical: 0.10 sits below the BFS +``min_path_score`` floor (0.10) so any path that survived the walk +clears the floor in the common case; only deeply-attenuated +multi-hop paths fall below.""" + +COMPOUND_TIE_REL_TOL: Final[float] = 0.20 +"""Relative tolerance (``abs(a - b) / max(a, b)``) below which two +compound_confidence values count as "comparable" for fork-TIE +detection. Scale-invariant: deeper paths require tighter absolute +agreement, matching the intuition that two near-collapsed compounds +(say 0.10 vs 0.08, ratio 0.20) are an actual tie while two strong +ones (0.99 vs 0.85, ratio ~0.14) are also one, but two mid-range +compounds at 0.24 vs 0.10 (ratio 0.58) are not. + +The earlier R2 prototype used :data:`CLOSE_MEAN_DELTA` (0.15 +absolute) as a stand-in here; that was calibrated against +per-belief means in the ~0.5 region and over-fires on +long-path pairs. Replaced as #668.""" def _trials(b: Belief) -> float: @@ -134,6 +165,33 @@ def _is_low_evidence(b: Belief) -> bool: return _trials(b) < CONFIDENT_TRIALS_MIN +def _compound_paths_tie(a: float, b: float) -> bool: + """Whether two fork-path ``compound_confidence`` values count as a + TIE for fork-aware classifier rule (#668). + + Two-knob test (Option B from #668 design): + + 1. Both compounds must be above :data:`COMPOUND_TIE_FLOOR` — + deeply-attenuated pairs are too weak to constitute meaningful + contradiction, no matter how close they are. + 2. Their relative gap (``abs(a - b) / max(a, b)``) must be below + :data:`COMPOUND_TIE_REL_TOL`. Scale-invariant: deeper paths + require tighter absolute agreement, in proportion to their + compound's magnitude. + + Replaces the earlier absolute-diff check against + :data:`CLOSE_MEAN_DELTA`, which was calibrated against per- + belief posterior means and over-fired on long-path pairs whose + compounds had decayed together. + """ + if min(a, b) <= COMPOUND_TIE_FLOOR: + return False + denom = max(a, b) + if denom <= 0: + return False + return abs(a - b) / denom < COMPOUND_TIE_REL_TOL + + def classify( seeds: list[Belief], hops: list[ScoredHop], @@ -235,9 +293,8 @@ def classify( for j in range(i + 1, len(siblings)): a = siblings[i] b = siblings[j] - if ( - abs(a.compound_confidence - b.compound_confidence) - < CLOSE_MEAN_DELTA + if _compound_paths_tie( + a.compound_confidence, b.compound_confidence ): impasses.append( Impasse( diff --git a/tests/test_reason_classify.py b/tests/test_reason_classify.py index 2ab83a5e3..9635fd834 100644 --- a/tests/test_reason_classify.py +++ b/tests/test_reason_classify.py @@ -307,3 +307,134 @@ def test_classify_paths_none_preserves_r1_behaviour(store: MemoryStore) -> None: v_explicit_none, i_explicit_none = classify([seed], [h], store, paths=None) assert v_no_paths == v_explicit_none assert i_no_paths == i_explicit_none + + +# --- #668: ratio-based compound-tie threshold --------------------------- + + +def test_classify_fork_tie_short_path_ties_long_path_does_not( + store: MemoryStore, +) -> None: + """#668: identical absolute diff (0.14) — short paths near 1.0 tie, + long paths near 0.2 do not. + + Short-path pair: compound 0.99 vs 0.85, ratio 0.14 < 0.20 → TIE. + Long-path pair: compound 0.24 vs 0.10, ratio 0.58 → no TIE. + + Both have the same absolute diff (~0.14). The old absolute-only + rule against CLOSE_MEAN_DELTA=0.15 would tie both; the new + relative-tolerance rule correctly separates them. + """ + from aelfrice.reason import ConsequencePath + + seed = _mk("seed", alpha=5.0, beta=2.0) + confident = _mk("conf", alpha=8.0, beta=2.0) + store.insert_edge( + Edge(src="conf", dst="x", type=EDGE_RELATES_TO, weight=1.0) + ) + h = _hop(confident, [EDGE_CONTRADICTS]) + + short_paths = [ + ConsequencePath( + belief_ids=("seed", "S1"), + edge_kinds=("CONTRADICTS",), + compound_confidence=0.99, + weakest_link_belief_id="S1", + fork_from="seed", + ), + ConsequencePath( + belief_ids=("seed", "S2"), + edge_kinds=("CONTRADICTS",), + compound_confidence=0.85, + weakest_link_belief_id="S2", + fork_from="seed", + ), + ] + _, impasses_short = classify([seed], [h], store, paths=short_paths) + short_tie = [ + i for i in impasses_short + if i.kind == ImpasseKind.TIE and i.belief_ids == ("S1", "S2") + ] + assert len(short_tie) == 1, "short-path pair must TIE" + + long_paths = [ + ConsequencePath( + belief_ids=("seed", "x", "y", "L1"), + edge_kinds=("RELATES_TO", "RELATES_TO", "CONTRADICTS"), + compound_confidence=0.24, + weakest_link_belief_id="L1", + fork_from="seed", + ), + ConsequencePath( + belief_ids=("seed", "x", "y", "L2"), + edge_kinds=("RELATES_TO", "RELATES_TO", "CONTRADICTS"), + compound_confidence=0.10, + weakest_link_belief_id="L2", + fork_from="seed", + ), + ] + _, impasses_long = classify([seed], [h], store, paths=long_paths) + long_tie = [ + i for i in impasses_long + if i.kind == ImpasseKind.TIE and i.belief_ids == ("L1", "L2") + ] + assert len(long_tie) == 0, ( + "long-path pair with same absolute diff but different ratio " + "must NOT TIE" + ) + + +def test_classify_fork_tie_below_compound_floor_does_not_tie( + store: MemoryStore, +) -> None: + """#668: near-collapsed compounds (≈0.02) do not TIE even though + their absolute diff is tiny. + + Compound floor is 0.10. A pair at 0.03 vs 0.02 has ratio 0.33, + BUT both sides are below the floor — meaning neither side has + survived the BFS as meaningful evidence. The fork-TIE rule + skips them entirely. + """ + from aelfrice.reason import ConsequencePath + + seed = _mk("seed", alpha=5.0, beta=2.0) + confident = _mk("conf", alpha=8.0, beta=2.0) + store.insert_edge( + Edge(src="conf", dst="x", type=EDGE_RELATES_TO, weight=1.0) + ) + h = _hop(confident, [EDGE_CONTRADICTS]) + + paths = [ + ConsequencePath( + belief_ids=("seed", "F1"), + edge_kinds=("CONTRADICTS",), + compound_confidence=0.03, + weakest_link_belief_id="F1", + fork_from="seed", + ), + ConsequencePath( + belief_ids=("seed", "F2"), + edge_kinds=("CONTRADICTS",), + compound_confidence=0.02, + weakest_link_belief_id="F2", + fork_from="seed", + ), + ] + _, impasses = classify([seed], [h], store, paths=paths) + floor_tie = [ + i for i in impasses + if i.kind == ImpasseKind.TIE and i.belief_ids == ("F1", "F2") + ] + assert len(floor_tie) == 0, ( + "pair below COMPOUND_TIE_FLOOR must not TIE regardless of " + "absolute diff" + ) + + +def test_compound_tie_constants_documented_and_load_bearing() -> None: + """#668 named thresholds; pin the values so any retune is a + deliberate change.""" + from aelfrice.reason import COMPOUND_TIE_FLOOR, COMPOUND_TIE_REL_TOL + + assert COMPOUND_TIE_FLOOR == 0.10 + assert COMPOUND_TIE_REL_TOL == 0.20