Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 63 additions & 6 deletions src/aelfrice/reason.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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],
Expand Down Expand Up @@ -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(
Expand Down
131 changes: 131 additions & 0 deletions tests/test_reason_classify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading