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
32 changes: 26 additions & 6 deletions src/aelfrice/bfs_multihop.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,23 @@ class ScoredHop:
1.0 (no edge weight exceeds 1.0). `depth` is the number of edges
in the path (1 for a direct neighbour, 2 for a two-hop expansion,
etc.). `path` is the ordered list of edge-type strings.

`belief_id_trail` is the ordered tuple of belief ids the BFS
walked through to reach this hop, starting with the seed and
ending with this hop's belief id. Length is always ``depth + 1``
(seed + one id per hop). Empty default for backwards-compat with
callers that construct ``ScoredHop`` directly in tests; the
production ``expand_bfs`` always emits a populated trail. Added
for #645 R2 (#658) — compound-confidence + fork-on-CONTRADICTS
derivation needs the per-hop trail of beliefs, not just the
terminal endpoint.
"""

belief: Belief
score: float
depth: int
path: list[str]
belief_id_trail: tuple[str, ...] = ()


def expand_bfs(
Expand Down Expand Up @@ -134,16 +145,23 @@ def expand_bfs(
return []

visited: set[str] = {b.id for b in seeds}
# Frontier entries: (belief_id, path_score, depth, path_edge_types).
frontier: list[tuple[str, float, int, list[str]]] = [
(b.id, 1.0, 0, []) for b in seeds
# Frontier entries: (belief_id, path_score, depth, path_edge_types,
# belief_id_trail). The trail tracks every belief id the BFS has
# walked through to reach `belief_id`, starting from the seed;
# consumers downstream (compound-confidence + fork-on-CONTRADICTS,
# #645 R2) reconstruct paths from this without re-walking the
# graph.
frontier: list[tuple[str, float, int, list[str], tuple[str, ...]]] = [
(b.id, 1.0, 0, [], (b.id,)) for b in seeds
]
expanded: list[ScoredHop] = []
nodes_used: int = 0

while frontier and nodes_used < total_budget:
next_frontier: list[tuple[str, float, int, list[str]]] = []
for current_id, score, depth, path in frontier:
next_frontier: list[
tuple[str, float, int, list[str], tuple[str, ...]]
] = []
for current_id, score, depth, path, trail in frontier:
if depth >= max_depth:
continue
if nodes_used >= total_budget:
Expand Down Expand Up @@ -185,16 +203,18 @@ def expand_bfs(
# this query.
continue
new_path = path + [edge.type]
new_trail = trail + (edge.dst,)
expanded.append(
ScoredHop(
belief=belief,
score=new_score,
depth=depth + 1,
path=new_path,
belief_id_trail=new_trail,
)
)
next_frontier.append(
(edge.dst, new_score, depth + 1, new_path)
(edge.dst, new_score, depth + 1, new_path, new_trail)
)
nodes_used += 1
frontier = next_frontier
Expand Down
60 changes: 46 additions & 14 deletions src/aelfrice/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@
)
from aelfrice.bfs_multihop import expand_bfs
from aelfrice.reason import (
ConsequencePath,
Impasse,
Verdict,
classify as _reason_classify,
derive_paths as _reason_derive_paths,
dispatch_policy as _reason_dispatch_policy,
suggested_updates as _reason_suggested_updates,
)
Expand Down Expand Up @@ -753,7 +755,8 @@ def _cmd_reason(args: argparse.Namespace, out: object) -> int:
nodes_per_hop=args.fanout,
total_budget=args.budget,
)
verdict, impasses = _reason_classify(seeds, hops, store)
paths = _reason_derive_paths(seeds, hops)
verdict, impasses = _reason_classify(seeds, hops, store, paths=paths)
finally:
store.close()

Expand All @@ -776,6 +779,16 @@ def _cmd_reason(args: argparse.Namespace, out: object) -> int:
}
for h in hops
],
"paths": [
{
"belief_ids": list(p.belief_ids),
"edge_kinds": list(p.edge_kinds),
"compound_confidence": p.compound_confidence,
"weakest_link_belief_id": p.weakest_link_belief_id,
"fork_from": p.fork_from,
}
for p in paths
],
"verdict": verdict.value,
"impasses": [
{
Expand Down Expand Up @@ -811,7 +824,7 @@ def _cmd_reason(args: argparse.Namespace, out: object) -> int:
print(f" {b.id}: {b.content}", file=out) # type: ignore[arg-type]
if not hops:
print("(no expansions — seeds have no outbound edges within budget)", file=out) # type: ignore[arg-type]
_emit_reason_footer(verdict, impasses, out)
_emit_reason_footer(verdict, impasses, paths, out)
return 0
print("chain:", file=out) # type: ignore[arg-type]
for h in hops:
Expand All @@ -823,29 +836,48 @@ def _cmd_reason(args: argparse.Namespace, out: object) -> int:
)
if path_str:
print(f"{indent} via {path_str}", file=out) # type: ignore[arg-type]
_emit_reason_footer(verdict, impasses, out)
_emit_reason_footer(verdict, impasses, paths, out)
return 0


def _emit_reason_footer(
verdict: Verdict, impasses: list[Impasse], out: object
verdict: Verdict,
impasses: list[Impasse],
paths: list[ConsequencePath],
out: object,
) -> None:
"""Print the verdict + impasses block at the tail of `aelf reason`.

Two-line minimum: a `verdict:` line and an `impasses:` line. When
impasses are present, each one renders on its own indented row
after the header. Format is grep-friendly so downstream tooling
(e.g. R3 dispatch policy) can pick the verdict out of stdout.
"""Print the verdict + impasses + forks block at the tail of `aelf reason`.

Three sections: a ``verdict:`` line, the impasses block (either
``(none)`` or one indented row per impasse), and a ``forks:``
summary that calls out CONTRADICTS-forked paths with their
parent-path terminal id and the forked path's compound confidence.
Format is grep-friendly so downstream tooling (e.g. R3 dispatch
policy) can pick the verdict out of stdout.
"""
print(f"verdict: {verdict.value}", file=out) # type: ignore[arg-type]
if not impasses:
print("impasses: (none)", file=out) # type: ignore[arg-type]
else:
print("impasses:", file=out) # type: ignore[arg-type]
for imp in impasses:
ids_str = ",".join(imp.belief_ids)
print(
f" {imp.kind.value} [{ids_str}]: {imp.note}",
file=out, # type: ignore[arg-type]
)
forks = [p for p in paths if p.fork_from is not None]
if not forks:
print("forks: (none)", file=out) # type: ignore[arg-type]
return
print("impasses:", file=out) # type: ignore[arg-type]
for imp in impasses:
ids_str = ",".join(imp.belief_ids)
print("forks:", file=out) # type: ignore[arg-type]
for p in forks:
print(
f" {imp.kind.value} [{ids_str}]: {imp.note}",
(
f" {p.fork_from} -> {p.belief_ids[-1]} "
f"[compound={p.compound_confidence:.3f}, "
f"weakest={p.weakest_link_belief_id}]"
),
file=out, # type: ignore[arg-type]
)

Expand Down
163 changes: 163 additions & 0 deletions src/aelfrice/reason.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,41 @@ class ImpasseKind(str, Enum):
NO_CHANGE = "NO_CHANGE"


@dataclass(frozen=True)
class ConsequencePath:
"""One root-to-leaf consequence chain over the belief graph (#645 R2).

A ``ConsequencePath`` is the path-centric view of a BFS expansion
that R1's hop-centric :class:`~aelfrice.bfs_multihop.ScoredHop`
encodes endpoint-first. The fields:

- ``belief_ids`` — ordered ``root → leaf`` belief ids; first element
is the originating seed, last is the path's terminal belief.
- ``edge_kinds`` — ordered list of edge-type strings; length is
always ``len(belief_ids) - 1``. Length-zero edge_kinds means the
path is a seed-only "path" (the seed itself before any expansion).
- ``compound_confidence`` — ``∏ posterior_mean(belief) for belief in
belief_ids``. Multiplicative decay along the chain: a single
weak intermediate belief attenuates the whole path.
- ``weakest_link_belief_id`` — id of the belief with the lowest
posterior mean over ``belief_ids``. Ties broken by hop index,
deepest wins (later occurrence in the trail).
- ``fork_from`` — when this path was forked because its terminal
edge is ``EDGE_CONTRADICTS``, the belief id at the
parent-path's terminal (i.e. ``belief_ids[-2]``). ``None`` on
non-forked paths.

Frozen + hashable so two derivations from the same evidence are
byte-identical and the path can be used as a dict key.
"""

belief_ids: tuple[str, ...]
edge_kinds: tuple[str, ...]
compound_confidence: float
weakest_link_belief_id: str
fork_from: str | None = None


@dataclass(frozen=True)
class Impasse:
"""One reasoning impasse observed over the walk.
Expand Down Expand Up @@ -103,6 +138,8 @@ def classify(
seeds: list[Belief],
hops: list[ScoredHop],
store: MemoryStore,
*,
paths: list[ConsequencePath] | None = None,
) -> tuple[Verdict, list[Impasse]]:
"""Derive ``(verdict, impasses)`` from walk evidence.

Expand All @@ -116,6 +153,14 @@ def classify(
then ``TIE``, then ``GAP``); within each kind impasses are emitted
in hop-list order (which itself is deterministic per
:func:`aelfrice.bfs_multihop.expand_bfs`'s ordering contract).

``paths`` (R2, #658): when supplied, the classifier additionally
emits a ``TIE`` impasse when two CONTRADICTS-forked paths share a
common parent and have compound-confidence values within
:data:`CLOSE_MEAN_DELTA` of one another. This is in addition to
the R1 posterior-mean TIE rule and trips ``CONTRADICTORY`` per the
#658 acceptance criterion. Backwards-compat: when ``paths`` is
``None`` the R1-only behaviour holds.
"""
impasses: list[Impasse] = []

Expand Down Expand Up @@ -179,6 +224,36 @@ def classify(
)
)

if paths:
forks = [p for p in paths if p.fork_from is not None]
by_parent: dict[str, list[ConsequencePath]] = {}
for p in forks:
assert p.fork_from is not None
by_parent.setdefault(p.fork_from, []).append(p)
for parent_id, siblings in by_parent.items():
for i in range(len(siblings)):
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
):
impasses.append(
Impasse(
kind=ImpasseKind.TIE,
belief_ids=tuple(
sorted(
[a.belief_ids[-1], b.belief_ids[-1]]
)
),
note=(
"forked CONTRADICTS branches with "
"comparable compound_confidence"
),
)
)

has_tie = any(i.kind == ImpasseKind.TIE for i in impasses)
has_cf = any(i.kind == ImpasseKind.CONSTRAINT_FAILURE for i in impasses)
has_gap = any(i.kind == ImpasseKind.GAP for i in impasses)
Expand Down Expand Up @@ -348,3 +423,91 @@ def suggested_updates(
)

return rows


def derive_paths(
seeds: list[Belief],
hops: list[ScoredHop],
) -> list[ConsequencePath]:
"""Derive :class:`ConsequencePath` records from walk evidence.

Pure function — no graph traversal, no store lookups. Reconstructs
each path from each hop's ``belief_id_trail`` (populated by
:func:`aelfrice.bfs_multihop.expand_bfs`) and the posterior means
of beliefs reachable through ``seeds`` and ``hops``.

Emits:

1. One length-1 ``ConsequencePath`` per seed (the "no-expansion"
baseline path; ``edge_kinds == ()``).
2. One ``ConsequencePath`` per hop, mirroring its trail.

Fork detection: a hop whose terminal edge is ``EDGE_CONTRADICTS``
surfaces with ``fork_from`` set to the parent-path's terminal
belief (``belief_id_trail[-2]``). Both the parent and the forked
branch are present in the returned list — the parent is just the
earlier hop (or seed) whose terminal id matches.

Order is preserved from the input ``hops`` (which is itself
deterministic per :func:`expand_bfs`'s sort contract). Seeds are
emitted first, in seed-input order; hops after, in
sorted-result order.

Skips hops whose ``belief_id_trail`` is empty (test fixtures that
don't bother to populate it) so the function is defensive against
older callers without crashing.
"""
belief_by_id: dict[str, Belief] = {s.id: s for s in seeds}
for h in hops:
belief_by_id[h.belief.id] = h.belief

paths: list[ConsequencePath] = []

for s in seeds:
m = _mean(s)
paths.append(
ConsequencePath(
belief_ids=(s.id,),
edge_kinds=(),
compound_confidence=m,
weakest_link_belief_id=s.id,
fork_from=None,
)
)

for h in hops:
trail = h.belief_id_trail
if not trail:
continue
beliefs_along: list[Belief] = []
for bid in trail:
b = belief_by_id.get(bid)
if b is None:
break
beliefs_along.append(b)
if len(beliefs_along) != len(trail):
continue
compound = 1.0
for b in beliefs_along:
compound *= _mean(b)
weakest_id = beliefs_along[0].id
weakest_mean = _mean(beliefs_along[0])
for b in beliefs_along[1:]:
m = _mean(b)
if m <= weakest_mean:
weakest_mean = m
weakest_id = b.id
fork_from: str | None = None
if h.path and h.path[-1] == EDGE_CONTRADICTS and len(trail) >= 2:
fork_from = trail[-2]
paths.append(
ConsequencePath(
belief_ids=tuple(trail),
edge_kinds=tuple(h.path),
compound_confidence=compound,
Comment thread
robotrocketscience marked this conversation as resolved.
weakest_link_belief_id=weakest_id,
fork_from=fork_from,
)
)

return paths
Loading
Loading