Skip to content

fix(graph): traverse SUPERSEDES in reverse so BFS surfaces the replacement (#1170) - #1188

Merged
github-actions[bot] merged 3 commits into
mainfrom
fix/issue-1170-supersedes-direction
Jul 30, 2026
Merged

fix(graph): traverse SUPERSEDES in reverse so BFS surfaces the replacement (#1170)#1188
github-actions[bot] merged 3 commits into
mainfrom
fix/issue-1170-supersedes-direction

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #1170. The retrieval half (AC3) is split to #1187 — see Scope below.

The direction question (AC1)

Two conventions were in play, and they disagreed:

Direction Reads as
Producerscontradiction.resolve_contradiction (src=winner.id, dst=loser.id), triple extractor ("X supersedes Y" → src=X) new → old "X supersedes Y" ✅
Memo + BFS + every test fixture old → new "X is superseded by Y"

Canonical direction: the producers'. It is what the edge type's name means, and keeping it needs no data migration; flipping the producers would leave SUPERSEDES reading backwards and require migrating existing edges. So the walk was wrong, not the data.

The bug (AC2)

expand_bfs followed SUPERSEDES outbound at the table's highest weight, 0.90 — the exact inverse of the rationale the memo gives for that weight:

"B replaces A" — the most actionable adjacency. If the query hit A, the user almost certainly wants B.

which needs an old → new hop that did not exist. Measured before the fix: BFS from the old belief returned []; BFS from the new belief returned [('old', 0.9, ['SUPERSEDES'])]. So a hit on the current belief spent prompt budget at maximum path score on the exact claim the supersession was recorded to retire, and the case 0.90 was chosen for never fired at all.

SUPERSEDES is now read from the inbound side via REVERSE_TRAVERSED_EDGE_TYPES and is not also followed outbound — bidirectional traversal would restore the bug while all the new tests still passed, so reverse-only is asserted explicitly.

The finding that explains why this survived

Every SUPERSEDES fixture in the BFS suite was written src=old → dst=new — the reverse of what production writes. The suite was testing a direction the product does not produce, which is why a high-severity inversion sat behind 32 green tests.

Flipping those fixtures to the production direction leaves all 32 pre-existing assertions passing unchanged. That is the strongest evidence the change is right: the walk's semantics (old → new) were always what the suite asserted; only the stored direction was wrong.

tests/test_cli_reason_wonder.py's shared fixture needed splitting rather than flipping: wonder's seed picker and its random-walk phantom generator reason about outbound degree ("a has 1 outbound edge, b has 1, c has 0 — tie broken by id-asc", per the test's own comment), which a production-direction SUPERSEDES edge necessarily inverts. Those tests get a plain outbound chain; the one test that exercises the SUPERSEDES hop gets its own fixture.

Two tables (AC4)

EDGE_VALENCE[SUPERSEDES] = 0.0 vs BFS_EDGE_WEIGHTS[SUPERSEDES] = 0.90 is the starkest gap between the tables. Reconciled as intentional, not drift — neither number moves:

  • EDGE_VALENCE = 0.0 answers "does a feedback signal cross this edge?" No — reinforcing a replacement says nothing about the confidence of what it replaced. Valence never traverses SUPERSEDES at all, so the direction question this PR fixes does not arise there.
  • BFS_EDGE_WEIGHTS = 0.90 answers "how relevant is the belief on the other end?" Maximally. This is the one that had to be reversed to match its own rationale.

Recorded in docs/design/bfs_multihop.md so it isn't re-litigated.

Also

Scope — AC3 is deliberately not here

AC3 ("exclude or demote superseded beliefs at retrieval") is filed as #1187 rather than included, because it is a different risk class:

#1187 carries the reproduction (retrieve() currently returns ['old', 'new'] — superseded first), the suggested use_supersession_demote shape, and the open demote-vs-exclude design question. Two-axis dup check run before filing: no existing open issue, and apply_supersession_demote has no importer on main.

Acceptance criteria

Verification

  • All 6 direction-sensitive tests were run with REVERSE_TRAVERSED_EDGE_TYPES emptied (i.e. the old outbound behaviour) and all 6 fail, including two pre-existing ones.
  • New tests cover: hit-on-stale surfaces the replacement at 0.90; hit-on-current surfaces nothing; a 3-link chain walks forward to the latest; and SUPERSEDES is reverse-only rather than bidirectional.
  • Full suite: 6094 passed, 69 skipped, 75 xfailed.

Summary by Sourcery

Reverse BFS traversal of SUPERSEDES edges so queries on superseded beliefs surface their replacements, and align tests, storage helpers, and documentation with the canonical edge direction.

Enhancements:

  • Introduce reverse-traversed edge handling in BFS, reading SUPERSEDES inbound without changing stored edge direction.
  • Ensure deterministic inbound edge ordering and add scoped inbound edge accessors for BFS and reranking.
  • Clarify BFS and valence semantics for SUPERSEDES in design docs, including temporal coherence rationale.

Tests:

  • Update existing BFS and CLI reasoning fixtures to match production SUPERSEDES direction and add focused tests ensuring only replacements surface from supersession chains.

Summary by CodeRabbit

  • New Features

    • BFS expansion now traverses supersession relationships in reverse, helping surface newer replacements and follow supersession chains.
    • Traversal results are ranked consistently and avoid duplicate neighbors.
    • Federation-aware inbound edge lookups now handle unavailable peers gracefully.
  • Bug Fixes

    • Improved deterministic ordering for inbound edge results.
    • Updated temporal-coherence behavior and supersession handling across retrieval and CLI graph expansion.
  • Documentation

    • Clarified supersession traversal direction, relevance, and temporal-coherence semantics.

@robotrocketscience robotrocketscience added the author-Kulili PR coordination mutex label Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 11282040-1d11-4292-8118-972baa333bec

📥 Commits

Reviewing files that changed from the base of the PR and between 6b3db59 and 4ecac50.

📒 Files selected for processing (5)
  • docs/design/bfs_multihop.md
  • src/aelfrice/bfs_multihop.py
  • src/aelfrice/store.py
  • tests/test_bfs_multihop.py
  • tests/test_cli_reason_wonder.py
📝 Walkthrough

Walkthrough

BFS expansion now reverse-traverses SUPERSEDES edges through scoped inbound lookups, ranks deduplicated neighbors deterministically, and updates traversal paths. Store tests, BFS fixtures, CLI tests, and design documentation reflect the new direction.

Changes

SUPERSEDES traversal semantics

Layer / File(s) Summary
Scoped inbound edge access
src/aelfrice/store.py
edges_to now orders results by src, type, and edges_to_in_scope provides federation-aware inbound lookup with empty results for peer failures or schema drift.
Reverse-traversed BFS expansion
src/aelfrice/bfs_multihop.py
SUPERSEDES is read inbound, combined with outbound candidates, filtered against visited beliefs, ranked deterministically, and threaded through paths and belief trails.
Behavior validation and design documentation
tests/test_bfs_multihop.py, tests/test_cli_reason_wonder.py, docs/design/bfs_multihop.md
Fixtures and assertions cover reverse-only supersession expansion, chain traversal, deduplication, CLI behavior, and the distinction between valence and BFS weights.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BFS as expand_bfs
  participant Store as MemoryStore
  participant Belief as get_belief_in_scope
  BFS->>Store: Read outbound edges
  BFS->>Store: Read inbound SUPERSEDES edges
  BFS->>Belief: Fetch unvisited neighbour
  Belief-->>BFS: Return belief for next frontier
Loading

Possibly related PRs

Suggested labels: author-Setr

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning [#1170] The BFS reversal and docs are implemented, but the issue also requires retrieval to exclude or demote superseded beliefs, and this PR defers that to #1187. Add retrieval-side supersession exclusion or demotion, or explicitly move that acceptance criterion to #1187 before closing #1170.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the core change: reversing SUPERSEDES traversal so BFS surfaces the replacement.
Description check ✅ Passed The description covers summary, linked issue, scope split, verification, test coverage, and reviewer notes; only optional checklist-style sections are partly omitted.
Out of Scope Changes check ✅ Passed No unrelated scope creep is evident; the docs, storage ordering, reverse traversal, and tests all support the SUPERSEDES fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1170-supersedes-direction

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR fixes BFS traversal of SUPERSEDES edges to align with the canonical producer direction (src=new → dst=old), adds reverse traversal support via inbound edges, introduces deterministic inbound edge ordering and scoped accessors, adjusts fixtures and tests to match production semantics, and updates the BFS design docs to record the intended behavior and valence/weight rationale.

Sequence diagram for reverse SUPERSEDES traversal in BFS

sequenceDiagram
    participant expand_bfs
    participant Store
    participant Belief

    Note over expand_bfs,Store: BFS at node old_id

    expand_bfs->>Store: edges_from_in_scope(old_id, scope)
    Store-->>expand_bfs: outbound_edges
    expand_bfs->>Store: edges_to_in_scope(old_id, scope)
    Store-->>expand_bfs: inbound_edges (including SUPERSEDES src=new_id -> dst=old_id)

    Note over expand_bfs: Build neighbours:
    Note over expand_bfs: outbound: (dst, type, weight)
    Note over expand_bfs: inbound SUPERSEDES: (src, type, weight)

    expand_bfs->>Belief: get_belief_in_scope(new_id, scope)
    Belief-->>expand_bfs: belief(new_id)

    expand_bfs-->expand_bfs: enqueue new_id with path [SUPERSEDES] and updated score

    Note over expand_bfs,Store: SUPERSEDES only traversed via edges_to_in_scope (reverse) and not outbound
Loading

File-Level Changes

Change Details Files
Make BFS traverse SUPERSEDES edges in reverse (old → new) using inbound edges while keeping producers’ stored direction unchanged.
  • Introduce REVERSE_TRAVERSED_EDGE_TYPES to list edge types that are followed against their stored direction.
  • Change expand_bfs to build neighbour lists from both edges_from_in_scope (outbound) and edges_to_in_scope (inbound) and to treat SUPERSEDES as reverse-only.
  • Update BFS neighbour ranking and visit logic to operate on normalized neighbour tuples rather than raw Edge objects.
src/aelfrice/bfs_multihop.py
Provide deterministic, scoped accessors for inbound edges to support reverse traversal and federation.
  • Modify edges_to to order results by src and type for deterministic traversal.
  • Add edges_to_in_scope to mirror edges_from_in_scope and handle local and peer stores, including schema drift and unreachable peers by returning empty lists.
  • Document the determinism and cost rationale in the edges_to docstring.
src/aelfrice/store.py
Align BFS and CLI tests with the production SUPERSEDES direction and cover the new reverse-only traversal semantics.
  • Flip existing SUPERSEDES test fixtures from src=old → dst=new to src=new → dst=old while leaving assertions unchanged where semantics were already old → new.
  • Add new tests to ensure hits on superseded beliefs surface their replacements at weight 0.90, hits on current beliefs do not surface stale predecessors, chains walk forward to the latest belief, and SUPERSEDES is not followed outbound as well.
  • Refactor CLI wonder fixtures to separate a pure outbound RELATES_TO chain from a chain where SUPERSEDES is stored in the production direction, and update tests/comments to reflect the new topology.
tests/test_bfs_multihop.py
tests/test_cli_reason_wonder.py
Update BFS multihop design documentation to record reverse traversal of SUPERSEDES and reconcile valence vs weight semantics.
  • Clarify that SUPERSEDES is traversed in reverse for BFS, explain the producer storage direction, and describe the previous bug where traversal contradicted the weight rationale.
  • Explicitly document that EDGE_VALENCE[SUPERSEDES]=0.0 and BFS_EDGE_WEIGHTS[SUPERSEDES]=0.90 answer different questions and should both be kept.
  • Update the temporal coherence section to describe walking inbound along SUPERSEDES instead of outbound from the seed.
docs/design/bfs_multihop.md

Assessment against linked issues

Issue Objective Addressed Explanation
#1170 Ensure SUPERSEDES edges are modeled and traversed so that producers, BFS traversal, and design docs all agree: producers store SUPERSEDES as new→old, BFS traverses it in the logical direction (hit on stale surfaces replacement; hit on current does not surface stale), and tests cover this behavior.
#1170 Exclude or demote superseded beliefs in the core retrieval pipeline so that superseded (stale) beliefs do not outrank their successors in retrieve()/retrieve_v2 results. The PR explicitly defers the retrieval-layer change (AC3) to a separate issue/PR (#1187). No wiring of apply_supersession_demote or equivalent demotion/exclusion logic into retrieve_v2 is present; retrieval output ordering for superseded vs. current beliefs is unchanged.
#1170 Reconcile and document the relationship between EDGE_VALENCE and BFS_EDGE_WEIGHTS for SUPERSEDES, and update the BFS design memo so its rationale matches the implemented behavior.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 423 changed lines (limit: 200)
  • 5 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The neighbour collection and ranking logic in expand_bfs has grown fairly complex; consider extracting the reverse/outbound traversal normalization into a small helper function to make the BFS loop easier to read and reason about.
  • Both edges_to and edges_to_in_scope repeat the same SELECT * FROM edges WHERE dst = ? ORDER BY src, type SQL; factoring this into a shared helper or constant would reduce duplication and keep the ordering contract in one place.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The neighbour collection and ranking logic in `expand_bfs` has grown fairly complex; consider extracting the reverse/outbound traversal normalization into a small helper function to make the BFS loop easier to read and reason about.
- Both `edges_to` and `edges_to_in_scope` repeat the same `SELECT * FROM edges WHERE dst = ? ORDER BY src, type` SQL; factoring this into a shared helper or constant would reduce duplication and keep the ordering contract in one place.

## Individual Comments

### Comment 1
<location path="src/aelfrice/bfs_multihop.py" line_range="221-230" />
<code_context>
+            neighbours: list[tuple[str, str, float]] = [
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid processing the same neighbour_id multiple times when multiple edges target it

With the new `neighbours` abstraction, multiple edges (e.g. outbound and reverse inbound) can reference the same `neighbour_id`. Since `visited` is only checked when building `candidates` and not inside the `for neighbour_id, edge_type, _edge_weight in ranked` loop, distinct edges to the same node can all end up in `ranked`, producing multiple `ScoredHop`s and `next_frontier` entries for the same belief in one step.

To preserve BFS-style “at most one expansion per node per hop”, either dedupe `candidates` by `neighbour_id` before sorting (e.g. keep only the best-ranked edge per neighbour), or add a `if neighbour_id in visited: continue` guard inside the `ranked` loop as a second safeguard.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/aelfrice/bfs_multihop.py
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Good catch — fixed in 6b3db592, and it's a pre-existing bug that this PR widens rather than introduces.

candidates was filtered against visited before ranking, but the emit loop never re-checked, so a belief named twice within one hop was returned twice and charged the node budget twice. Reproduced on main with no #1170 changes at all — the (src, dst, type) PK permits two edge types between one pair:

A -SUPPORTS-> B
A -CITES->    B
expand_bfs([A]) -> hop ids: ['B', 'B']

#1170 adds a second route in, exactly as you describe: an outbound edge and a reverse-traversed inbound one can now name the same neighbour. Both cases now have tests. Ranking is strongest-first, so the copy already taken is the higher-scoring one — asserted, so a future change that reorders ranking can't silently start returning the weaker edge's path.

Full suite: 6147 passed, 69 skipped, 75 xfailed.

robotrocketscience added a commit that referenced this pull request Jul 29, 2026
Review catch on PR #1188. `candidates` was filtered against `visited` before
ranking, but the emit loop never re-checked, so a belief named twice within
one hop was returned twice and charged the node budget twice.

This predates #1170 — the `(src, dst, type)` PK permits two edge types
between one pair, and `A -SUPPORTS-> B` plus `A -CITES-> B` reproduces it on
main. #1170 adds a second route in, since an outbound edge and a
reverse-traversed inbound one can now name the same neighbour. Ranking is
strongest-first, so the copy already taken is the higher-scoring one; both
cases are covered by tests.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aelfrice/bfs_multihop.py`:
- Around line 78-95: Update the expand_bfs docstring to document mixed
traversal: follow ordinary edge types outbound and REVERSE_TRAVERSED_EDGE_TYPES
inbound, using the resulting neighbour rather than the stored destination for
ranking. Replace references to outbound-only edges_from_in_scope behavior and
dst-based ordering while preserving the existing traversal semantics.
- Around line 234-257: Update the neighbour selection flow before the `ranked`
slice in the BFS hop logic: sort `candidates` by the existing ranking, retain
only the first entry for each `neighbour_id`, then apply `nodes_per_hop` to the
deduplicated results. Keep the existing budget checks and processing loop, but
ensure duplicate IDs no longer consume top-k slots or require the within-hop
`visited` skip.

In `@src/aelfrice/store.py`:
- Around line 5166-5171: Update the comment near edges_to to remove the
incorrect claim that edges_from orders its results, and describe edges_to’s
deterministic ORDER BY src, type rationale independently. Keep the explanation
about physical row order, the sort cost, and the primary-key/index ordering
unchanged where applicable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d508cbf-d974-46c9-aea0-e9ffefd1152d

📥 Commits

Reviewing files that changed from the base of the PR and between c2b4e28 and 6b3db59.

📒 Files selected for processing (5)
  • docs/design/bfs_multihop.md
  • src/aelfrice/bfs_multihop.py
  • src/aelfrice/store.py
  • tests/test_bfs_multihop.py
  • tests/test_cli_reason_wonder.py

Comment thread src/aelfrice/bfs_multihop.py
Comment thread src/aelfrice/bfs_multihop.py
Comment thread src/aelfrice/store.py
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T17:11:58Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Approve the direction fix — but two things should change before merge, one of them a live correctness gap that the bot flagged and I've now reproduced.

The core work is excellent, and the evidence for it is the strongest kind. Confirmed independently:

  • The fixture flip is the proof. The test diff removes exactly 15 lines, all of them insert_edge(_edge(..., EDGE_SUPERSEDES)) fixtures, and not one assertion is removed or modified. The suite always asserted old → new semantics; only the stored direction was wrong. That is a much better argument than any prose could make, and it's why I'd take this change on sight.
  • Emptying REVERSE_TRAVERSED_EDGE_TYPES fails 17 tests, including all six direction-sensitive ones plus test_supersedes_is_not_followed_outbound_as_well. The reverse-only property is pinned, not incidental.
  • The reasoning for choosing the producers' direction is right: the edge type's name means new → old, and flipping producers would need a migration and leave SUPERSEDES reading backwards. Fixing the walk is the cheaper and more honest change.
  • The EDGE_VALENCE = 0.0 vs BFS_EDGE_WEIGHTS = 0.90 reconciliation is correct and worth having written down — the two tables answer different questions, and valence never traverses this edge type at all, so the direction question genuinely doesn't arise there.

1. The duplicate-neighbour slot consumption is real, and it fires on the normal case

CodeRabbit's nodes_per_hop finding is valid. Duplicates are filtered after the top-k slice, so each duplicate entry burns a slot and an eligible neighbour is silently dropped. Reproduced:

LOSER --CONTRADICTS(0.85)--> WINNER      (outbound)
WINNER --SUPERSEDES(0.90)--> LOSER       (inbound, reverse-traversed)
LOSER --SUPPORTS(0.60)--> OTHER          (a perfectly eligible neighbour)

expand_bfs from LOSER, nodes_per_hop=2 -> 1 hop:  ['WINNER']          <-- OTHER dropped
expand_bfs from LOSER, nodes_per_hop=3 -> 2 hops: ['WINNER', 'OTHER']

What makes this worth fixing rather than noting is which configuration triggers it. That is not a contrived graph — it is the steady state resolve_contradiction produces. It operates on an existing CONTRADICTS edge and inserts SUPERSEDES winner → loser between the same pair, so a contradiction-resolved pair carries both edges by construction. They are the two highest weights in the table (0.90 and 0.85), so the duplicate reliably occupies the top slots rather than landing harmlessly at the tail.

At DEFAULT_NODES_PER_HOP = 16 the cost is one lost slot per resolved pair in the frontier, so this is a real-but-modest defect rather than an emergency. But the fix is three lines — dedupe by neighbour_id after ranking, keeping the highest-scoring entry, then slice — and the existing guard test is what makes it worth doing now:

test_outbound_and_reverse_edge_to_one_neighbour_expand_it_once asserts the right property but runs at the default nodes_per_hop=16 with only two candidates, so it can never observe the underfill. It passes today and would keep passing after the bug bit. Whatever fix lands should carry a case with nodes_per_hop set tight enough that the dropped neighbour is visible — otherwise the guard reads as coverage it doesn't provide.

2. The edges_from ordering claim is not true yet

The new edges_to docstring says ORDER BY src, type is there "for the same reason edges_from orders (#1169)". On main, edges_from is still SELECT * FROM edges WHERE src = ? with no ORDER BY — that ordering lands in #1184, which is open and unmerged.

So this is a forward reference that is currently false, and it also implies a merge order: #1184 and #1188 both touch store.py in the same neighbourhood and will conflict. Merge #1184 first, and the reference becomes accurate with no edit. If #1188 goes first, reword to describe edges_to's own ordering on its own terms.

I'd rather this be sequenced than reworded — the reason given is the correct reason, it's just early.

3. The expand_bfs docstring still describes an outbound-only walk

Minor, and the bot is right. Line 147 says "Walk outbound edges from seeds", the ranking description talks in terms of dst, and the traversal note mentions only edges_from_in_scope. The inline comment at the neighbour-assembly site is excellent and explains the mixed traversal well — the function's own contract just hasn't caught up. Someone reading the signature and docstring alone would come away with the pre-#1170 model.


Smaller notes

edges_to_in_scope fetches the whole in-degree to find SUPERSEDES edges. The type filter is applied in Python after the query, so every frontier node pays a full inbound-edge read even in a store with no SUPERSEDES edges at all, and the cost scales with total in-degree rather than supersession in-degree. Measured impact today is immaterial — the AC10 latency band runs 0.08–0.09s on this branch against 0.07–0.08s on main, well inside the 1.5s budget — so this is not a blocker. But pushing AND type IN (...) into the SQL would make the cost proportional to what's actually used, and the lane is default-off precisely so it can be turned on later on bigger graphs.

edges_to_in_scope matches edges_from_in_scope's error handling (sqlite3.OperationalError[]), which is the right consistency for opportunistic federation.

The AC3 split to #1187 is the right call and the stated reason is the right reason: this touches a default-off lane, #1187 changes retrieve() on the default path. Worth knowing that I've asked for a change on #1187 as well — its exclusion arm shrinks the L1 pack instead of backfilling — so the two halves of #1170 are both one revision from done.


Requested: the dedup-before-slice fix with a test that can actually observe the underfill, plus the docstring correction. The edges_from reference resolves itself if #1184 merges first, which it should anyway to avoid the store.py conflict. Everything else here I'd take as-is; the direction fix and its evidence are the best-argued change I've reviewed on this umbrella.

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:review Needs review (PR open, awaiting reviewer) labels Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-07-30T17:17:13Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T18:10:19Z]

robotrocketscience added a commit that referenced this pull request Jul 30, 2026
Review catch on PR #1188. `candidates` was filtered against `visited` before
ranking, but the emit loop never re-checked, so a belief named twice within
one hop was returned twice and charged the node budget twice.

This predates #1170 — the `(src, dst, type)` PK permits two edge types
between one pair, and `A -SUPPORTS-> B` plus `A -CITES-> B` reproduces it on
main. #1170 adds a second route in, since an outbound edge and a
reverse-traversed inbound one can now name the same neighbour. Ranking is
strongest-first, so the copy already taken is the higher-scoring one; both
cases are covered by tests.
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Rebased onto 4f5f866d and addressed all three threads. Now 0dbe395b, FF on main, 3 signed commits, discretion clean, 94 tests green.

1. Dedup now happens before the nodes_per_hop slice. The duplicate no longer consumes a slot:

LOSER -CONTRADICTS-> WINNER,  WINNER -SUPERSEDES-> LOSER,  LOSER -SUPPORTS-> OTHER
before:  nodes_per_hop=2 -> 1 hop  ['WINNER']            <- OTHER dropped
after:   nodes_per_hop=2 -> 2 hops ['WINNER', 'OTHER']

Candidates are ranked, then deduped by neighbour id keeping the strongest edge to each, then sliced — so nodes_per_hop counts distinct beliefs rather than distinct edges. The delivery-side visited check stays as defence in depth.

New test test_a_duplicate_neighbour_does_not_consume_a_nodes_per_hop_slot uses the fixture above at nodes_per_hop=2, which is the part that matters: the pre-existing test_outbound_and_reverse_edge_to_one_neighbour_expand_it_once asserts the right property but runs at the default 16 with two candidates, so it could never observe the underfill. Reverting to slice-before-dedup fails the new test and not the old one.

2. expand_bfs's contract now describes the mixed traversal — which types are read outbound vs inbound, why SUPERSEDES is the inbound one, that the two reads merge into one candidate list before ranking, and that nodes_per_hop counts distinct beliefs. The federation paragraph now names edges_to_in_scope alongside edges_from_in_scope.

3. The edges_from ordering reference resolved itself#1184 merged first, as recommended, so edges_from really does ORDER BY dst, type on main now and the citation is accurate rather than forward-looking. Rebasing on top of it was clean, so the store.py conflict I flagged didn't materialise either.

Labelling once CI settles.

robotrocketscience added a commit that referenced this pull request Jul 30, 2026
Two edges in one hop can name the same neighbour — different types
between one pair are permitted by the (src, dst, type) PK, and since
#1170 an outbound edge and a reverse-traversed inbound one can collide.
Deduplicating only at delivery, after the top-k slice, let the duplicate
occupy a slot and dropped an otherwise-eligible belief:

  LOSER -CONTRADICTS-> WINNER, WINNER -SUPERSEDES-> LOSER, LOSER -SUPPORTS-> OTHER
  nodes_per_hop=2  ->  1 hop  (OTHER dropped)

That fixture is not contrived. resolve_contradiction acts on an existing
CONTRADICTS edge and inserts SUPERSEDES between the same pair, and those
are the two highest weights in the table, so the duplicate reliably lands
at the top of the ranking.

Dedupe now happens after ranking and before the slice, keeping the
strongest edge to each neighbour, so nodes_per_hop counts distinct
beliefs rather than distinct edges. The delivery-side visited check stays
as defence in depth.

Also updates expand_bfs's contract, which still described an
outbound-only walk after #1170 made it mixed.

Raised by CodeRabbit on #1188.
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1170-supersedes-direction branch from 6b3db59 to 0dbe395 Compare July 30, 2026 18:19
@robotrocketscience robotrocketscience added ready-to-merge Trigger merge-train: FF main to this PR's head and removed attn:unblock Needs answer from another session labels Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-07-30T18:25:40Z]

@github-actions

Copy link
Copy Markdown

merge-train: blocked

branch is not fast-forward on main (branch base 4f5f866d0323d8c61b262355f70e2fd163ffd411, current main 2f589f8fd9223b5f507173ebc6fdd585e85c1423). Rebase locally (git rebase github/main), force-push, and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
…ement

Producers store SUPERSEDES as src=winner(new) -> dst=loser(old):
`contradiction.resolve_contradiction` writes (winner.id, loser.id) and the
triple extractor parses "X supersedes Y" as src=X. That direction is what the
edge type's name means, so it is kept. The walk was wrong, not the data.

`expand_bfs` followed it outbound at the weight table's highest value (0.90),
which is the exact inverse of the rationale the memo gives for that weight
("if the query hit A, the user almost certainly wants B"). A hit on the
current belief pulled its stale predecessor into the prompt at maximum path
score; a hit on the stale one pulled nothing, so the case 0.90 was chosen for
never fired. SUPERSEDES is now read from the inbound side and is not followed
outbound — bidirectional traversal would have restored the bug while the new
tests still passed, so it is reverse-only and asserted as such.

Every SUPERSEDES fixture in the BFS suite was written src=old -> dst=new, the
reverse of production, which is why the suite never caught this: it was
testing a direction the product does not write. Flipping the fixtures to the
production direction leaves all 32 pre-existing assertions passing unchanged
— the walk's *semantics* (old -> new) were always right.

`test_cli_reason_wonder`'s shared fixture is split: `wonder`'s seed picker and
random-walk generator reason about outbound degree, which a
production-direction SUPERSEDES edge necessarily inverts, so they get a plain
outbound chain and the one test that exercises the SUPERSEDES hop gets its own
fixture.

`edges_to` gains `ORDER BY src, type` (the BFS frontier is
determinism-load-bearing) plus a peer-scope sibling for federation parity.

Docs: corrects the weight-table rationale and the temporal-coherence example
in docs/design/bfs_multihop.md, both of which described the reverse
traversal as if it already existed, and records that the SUPERSEDES gap
between EDGE_VALENCE (0.0) and BFS_EDGE_WEIGHTS (0.90) is intentional — the
two answer different questions, and valence never traverses the edge at all,
so the direction question does not arise there.
Review catch on PR #1188. `candidates` was filtered against `visited` before
ranking, but the emit loop never re-checked, so a belief named twice within
one hop was returned twice and charged the node budget twice.

This predates #1170 — the `(src, dst, type)` PK permits two edge types
between one pair, and `A -SUPPORTS-> B` plus `A -CITES-> B` reproduces it on
main. #1170 adds a second route in, since an outbound edge and a
reverse-traversed inbound one can now name the same neighbour. Ranking is
strongest-first, so the copy already taken is the higher-scoring one; both
cases are covered by tests.
Two edges in one hop can name the same neighbour — different types
between one pair are permitted by the (src, dst, type) PK, and since
#1170 an outbound edge and a reverse-traversed inbound one can collide.
Deduplicating only at delivery, after the top-k slice, let the duplicate
occupy a slot and dropped an otherwise-eligible belief:

  LOSER -CONTRADICTS-> WINNER, WINNER -SUPERSEDES-> LOSER, LOSER -SUPPORTS-> OTHER
  nodes_per_hop=2  ->  1 hop  (OTHER dropped)

That fixture is not contrived. resolve_contradiction acts on an existing
CONTRADICTS edge and inserts SUPERSEDES between the same pair, and those
are the two highest weights in the table, so the duplicate reliably lands
at the top of the ranking.

Dedupe now happens after ranking and before the slice, keeping the
strongest edge to each neighbour, so nodes_per_hop counts distinct
beliefs rather than distinct edges. The delivery-side visited check stays
as defence in depth.

Also updates expand_bfs's contract, which still described an
outbound-only walk after #1170 made it mixed.

Raised by CodeRabbit on #1188.
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1170-supersedes-direction branch from 0dbe395 to 4ecac50 Compare July 30, 2026 18:28
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
@github-actions
github-actions Bot merged commit 4ecac50 into main Jul 30, 2026
29 checks passed
@github-actions

Copy link
Copy Markdown

merge-train: merged 4ecac50main via FF push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(graph): SUPERSEDES points new→old, so BFS surfaces the stale belief at maximum path score

1 participant