Skip to content

fix(graph): propagate_valence attenuates by the broker, credits once, caps mass (#1169) - #1184

Merged
github-actions[bot] merged 5 commits into
mainfrom
fix/issue-1169-propagate-valence
Jul 30, 2026
Merged

fix(graph): propagate_valence attenuates by the broker, credits once, caps mass (#1169)#1184
github-actions[bot] merged 5 commits into
mainfrom
fix/issue-1169-propagate-valence

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #1169.

Four compounding defects in one BFS — store.propagate_valence, the walk a feedback event takes to reach related beliefs. All four are on a default-on path.

1. Attenuation direction

The multiplier was the recipient's posterior while the docstring called it broker confidence. So the amount of evidence a belief received was proportional to how confident it already was:

  • Positive: a belief at μ=0.95 absorbed 0.95× the signal; one at μ=0.10 absorbed 0.10×. Repeated propagation widened the gap with no evidence about either.
  • Negative: valence −1.0 into a junk belief at μ=0.08 became −0.08 and fell under the default min_threshold of 0.05 as soon as any edge multiplier was below 0.625. Low-confidence junk was structurally immune to the signal meant to remove it.

Now the multiplier is the confidence of the belief the signal travels through.

A detail worth flagging. With the direction corrected, the first hop's broker is the source — and propagate_valence runs after apply_feedback has already bumped the source. Reading the confidence back off the row would fold the event's own increment into the strength of its own propagation, a weaker form of exactly the self-reinforcement #1058 set out to remove. So apply_feedback now passes the source's confidence as of before the event, via a new src_confidence parameter.

I didn't pick that by preference — two existing integration tests pin exact downstream deltas (test_propagated_deltas_match_pure_walk_exactly, test_negative_signal_through_contradicts_penalizes_neighbor), and they only pass under the prior-confidence reading. They pass unchanged.

2. Fan-in and reconvergence double-count

applied[dst] += delta ran for every in-edge traversed, outside the visited guard, which only prevented re-queuing. So:

diamond  A→B, A→C, B→D, C→D    →  D receives 2× the source signal
fan-in   A→B0..B4, all Bi→T    →  T receives 5×

The issue's worked case: one aelf confirm on the root of a 20-node convergent subgraph added α += ~20 at the convergence point, pinning it at the top of the posterior-weighted rerank with no user having endorsed it.

3. Cycles between non-source beliefs

The guard was if edge.dst == src_id: continue — the source only. The docstring claimed cycles were dropped; true for the source, false for everything else. A→B, B→C, C→B delivered 2.0× to B. Any bidirectional RELATES_TO pair (written in both directions by wonder_ingest) or CONTRADICTS pair inside the hop radius hit this on every event.

2 and 3 have one fix: each belief is credited exactly once per walk, by its largest-magnitude path.

4. Edge-insertion-order dependence

edges_from was SELECT * FROM edges WHERE src = ? with no ORDER BY, so row order was (src, rowid) — physical insertion order, which SQLite does not contract. The issue measured a 3.3× difference in the delta delivered to one node purely from inserting A-SUPPORTS->B before or after A-RELATES_TO->C. A VACUUM, a migration, or a different query plan moves it, which breaks the "retrieval is a reproducible function of the write log" contract.

edges_from and its peer-scope sibling now ORDER BY dst, type, and within a hop candidates rank by (-abs(delta), dst) — so the strongest path propagates onward regardless of storage layout, matching the precedent already in bfs_multihop.py.

Mass cap (AC4)

Total injected mass now defaults to abs(valence) * max_hops, the invariant the issue proposed. Candidates are considered strongest-first, so when the budget binds it drops the weakest paths rather than diluting every delta. Without a cap, mass grows with graph size — a property of the substrate, not of the user's single click.

Acceptance criteria

  • Attenuate by the broker's (source-side) confidence
  • Move accumulation inside the visited guard
  • ORDER BY on edges_from for a total order
  • Cap total injected mass per feedback event
  • Property test: total mass invariant to graph shape and edge insertion order

Verification

  • All 9 new/changed tests were run against the pre-fix propagate_valence and all 9 fail — including the hypothesis property test.
  • New hypothesis property test asserts, over ~150 generated graph shapes (up to 14 edges on 6 nodes, positive/negative/fractional valence, 1–4 hops): total mass within budget, source never a recipient, no single delta exceeding the source valence, and identical output between two stores built from the same logical edge set in rotated insertion order.
  • test_reconvergent_paths_accumulate_delivery_once_onward was a characterization test of the double-count (it asserted C=0.5). Rewritten to the corrected magnitudes and renamed.
  • Full suite: 6098 passed, 69 skipped, 75 xfailed.

Note on ordering with #1168

PR #1183 (#1168) also touches feedback.py around the propagation call site. The two are independent fixes but will conflict trivially there; whichever merges second needs a one-hunk rebase. Flagging so the reviewer isn't surprised.

One cleanup I deliberately left out

tests/test_propagate_valence.py's module docstring on main opens with a session identity. I rewrote it, then reverted: removing the literal puts it on a - line in the diff, which trips the pre-push discretion grep, and that gate is not one to override. It needs an operator call on how to land it — flagging rather than working around it.

Summary by Sourcery

Correct valence propagation semantics, determinism, and mass bounding for feedback walks through the belief graph.

Bug Fixes:

  • Fix valence attenuation to use broker (through-belief) confidence rather than recipient confidence, preventing rich-get-richer and shielding of low-confidence junk from negative feedback.
  • Ensure each belief is credited at most once per propagation walk, eliminating fan-in double-counting and cycle-induced re-delivery between non-source beliefs.
  • Make propagation output deterministic by ordering outbound edges and ranking same-hop candidates by strength so results are independent of edge insertion order.

Enhancements:

  • Introduce a configurable cap on total injected mass per feedback event, defaulting to abs(valence) * max_hops, and consume strongest paths first within that budget.
  • Use the source belief’s prior confidence for the first propagation hop to avoid an event amplifying its own propagation strength.

Documentation:

  • Document the corrected propagation semantics, determinism guarantees, and mass cap behaviour in the store API docstrings and changelog.

Tests:

  • Add targeted tests for fan-in, reconvergent paths, cycles, attenuation direction, negative feedback to low-confidence beliefs, edge insertion order invariance, and mass capping behaviour.
  • Add a Hypothesis property test asserting bounded mass, source exclusion, per-belief delta limits, and order-invariant output across generated graph shapes.

Summary by CodeRabbit

  • Bug Fixes

    • Improved feedback propagation accuracy by preventing duplicate credit across converging paths and cycles.
    • Applied confidence-based attenuation consistently, including source confidence.
    • Ensured shallower paths take priority and negative feedback reaches low-confidence beliefs.
    • Added safeguards to cap total propagated evidence and prevent order-dependent results.
  • Tests

    • Expanded coverage for propagation budgets, determinism, graph shapes, reconverging paths, and cycles.

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

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors propagate_valence’s BFS so valence signals attenuate by broker (source-side) confidence, each belief is credited once via its strongest path, propagation order is deterministic, and total injected mass is capped, with apply_feedback passing prior source confidence and tests/CHANGELOG updated accordingly.

Sequence diagram for feedback application and valence propagation

sequenceDiagram
    actor User
    participant feedback_apply_feedback as apply_feedback
    participant store as Store
    participant store_propagate_valence as propagate_valence

    User ->> feedback_apply_feedback: apply_feedback(belief_id, valence)
    feedback_apply_feedback ->> store_propagate_valence: propagate_valence(belief_id, valence, src_confidence)
    store_propagate_valence ->> store: edges_from(src_id)
    store_propagate_valence ->> store: get_belief(current_id)
    store_propagate_valence ->> store: get_belief(edge.dst)
    store_propagate_valence -->> feedback_apply_feedback: deltas
    feedback_apply_feedback -->> User: posterior updated with deltas
Loading

File-Level Changes

Change Details Files
Correct valence attenuation to use broker (traversed belief) confidence and avoid self-reinforcement on the source.
  • Update propagate_valence docstring to define attenuation by broker confidence and explain prior recipient-side bug and behavior.
  • Change propagation loop to fetch the broker belief at the current node, compute confidence as alpha/(alpha+beta), and use that as the multiplier for outgoing edges instead of recipient confidence.
  • Introduce src_confidence parameter to propagate_valence and special-case the first hop to use the source’s pre-event confidence.
  • Modify apply_feedback to compute the source belief’s prior confidence and pass it into propagate_valence via src_confidence so the event cannot amplify its own propagation.
src/aelfrice/store.py
src/aelfrice/feedback.py
Ensure each belief is credited at most once per BFS walk, using the strongest-magnitude path and preventing fan-in/cycle double-counting.
  • Refactor BFS to accumulate per-hop candidate deliveries (dst_id, delta, hops) instead of writing to applied inside the edge loop.
  • Sort candidates by descending absolute delta and then by destination id, forming a deterministic total order.
  • Track a visited set that now includes all non-source beliefs once credited; skip candidates whose dst_id is already visited, ensuring a single credit per belief.
  • Record exactly one delta per destination in applied and push only that delta to the next frontier, eliminating per-in-edge accumulation for fan-in and cycles.
src/aelfrice/store.py
tests/test_propagate_valence.py
Make propagation deterministic and independent of SQLite edge insertion order.
  • Change edges_from to SELECT with ORDER BY dst, type and add a docstring explaining determinism and prior insertion-order dependence.
  • Apply the same ordered query to edges_from_in_scope for peer-scope BFS parity.
  • Within propagate_valence, sort per-hop candidates by (-abs(delta), dst) before applying them so path selection is deterministic even when multiple edges exist.
  • Add a unit test and a Hypothesis property test asserting invariance of propagation output to edge insertion order by rotating insertion sequences over the same logical edge set.
src/aelfrice/store.py
tests/test_propagate_valence.py
Cap total injected evidence (“mass”) per feedback event and respect an explicit mass budget.
  • Add max_total_mass parameter to propagate_valence, defaulting to abs(valence) * max_hops when None, and document the budget semantics in the docstring.
  • Introduce mass_used accumulator and cap computation; before accepting a candidate delivery, skip it if mass_used + abs(delta) would exceed the cap.
  • Change application step so each accepted delta is stored once in applied, added to mass_used, and placed onto the next frontier, dropping weaker paths when the budget binds.
  • Add tests to verify the default cap bounds total
delta
Expand and rewrite tests around propagate_valence to characterize and protect against the identified defects, plus add a property test for global invariants.
  • Rename and rewrite the reconvergent-path test to assert each belief is credited once and expectations align with the corrected magnitudes.
  • Add tests for fan-in over-crediting, cycles between non-source beliefs, recipient-side attenuation defects, and low-confidence beliefs receiving negative feedback.
  • Add an edge-insertion-order determinism test using a diamond graph where logical structure is fixed but edge insertion order is reversed.
  • Introduce a Hypothesis-based property test over random small graphs asserting bounded mass, the source absent from recipients, per-node delta not exceeding source valence, and order-invariant results between stores with rotated edge insertion order.
  • Import pytest, hypothesis, and new edge-type constants required by the new tests and adjust the helper chain builder docstring and comments to reflect broker-side attenuation.
tests/test_propagate_valence.py
Document the propagation bugfix comprehensively in the v4 changelog.
  • Add a detailed bullet under v4 “Fixed” describing the four issues in propagate_valence (attenuation direction, fan-in/cycles, determinism, and unbounded mass) and the corresponding fixes, including the new mass cap and order-invariance property test.
CHANGELOG/v4.md

Assessment against linked issues

Issue Objective Addressed Explanation
#1169 Change propagate_valence to attenuate by the broker's (source-side) confidence rather than the recipient's posterior, including correct handling of the first hop from the source.
#1169 Make propagation semantics correct and deterministic: each belief is credited at most once per walk (no fan-in/diamond/cycle double-counting) and edges_from uses a stable ORDER BY so results do not depend on edge insertion order.
#1169 Bound the total evidence mass injected per feedback event and add a property test asserting mass is within the cap and output is invariant to graph shape and edge insertion order.

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

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Valence propagation now uses source confidence, credits each reachable belief once via the shallowest strongest path, orders graph reads deterministically, and enforces a total evidence-mass budget. Feedback wiring and tests were updated for these semantics.

Changes

Valence propagation

Layer / File(s) Summary
Propagation inputs and deterministic graph reads
src/aelfrice/store.py, src/aelfrice/feedback.py
The propagation API accepts source confidence and an optional mass budget; outbound edge queries use deterministic ordering.
Single-credit bounded propagation
src/aelfrice/store.py
The walk selects shallowest paths, uses broker confidence for attenuation, prevents repeated delivery, and clips mass at the configured budget.
Propagation behavior and invariants
tests/test_propagate_valence.py, CHANGELOG/v4.md
Tests cover reconvergence, cycles, negative feedback, ordering invariance, budgets, and property-based graph invariants; the fixes are documented in the changelog.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Feedback
  participant MemoryStore
  participant Graph
  participant Beliefs
  Feedback->>MemoryStore: propagate valence with source confidence
  MemoryStore->>Graph: traverse ordered edges by hop
  Graph-->>MemoryStore: candidate paths
  MemoryStore->>Beliefs: apply one budgeted delta per recipient
Loading

Possibly related PRs

Suggested labels: attn:review

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: valence propagation now uses broker confidence, credits once, and caps mass.
Description check ✅ Passed The description is detailed and mostly follows the template, covering summary, linked issue, verification, and reviewer notes.
Linked Issues check ✅ Passed The PR appears to satisfy #1169 by fixing attenuation, once-only crediting, deterministic ordering, mass capping, and invariant tests.
Out of Scope Changes check ✅ Passed The changelog, docs, tests, and code all align with #1169; no obvious unrelated changes were introduced.
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-1169-propagate-valence

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.

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jul 29, 2026
@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:

  • 524 changed lines (limit: 200)
  • 4 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 4 issues, and left some high level feedback:

  • Inside propagate_valence the BFS now calls get_belief for every broker and destination on every hop, which could become a bottleneck on large graphs; consider carrying broker confidence in the frontier tuple and/or prefetching beliefs for the current frontier to avoid repeated per-edge DB lookups.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Inside `propagate_valence` the BFS now calls `get_belief` for every broker and destination on every hop, which could become a bottleneck on large graphs; consider carrying broker confidence in the frontier tuple and/or prefetching beliefs for the current frontier to avoid repeated per-edge DB lookups.

## Individual Comments

### Comment 1
<location path="src/aelfrice/store.py" line_range="4972" />
<code_context>
+        """
         cur = self._conn.execute(
-            "SELECT * FROM edges WHERE src = ?", (src,)
+            "SELECT * FROM edges WHERE src = ? ORDER BY dst, type", (src,)
         )
         return [_row_to_edge(r) for r in cur.fetchall()]
</code_context>
<issue_to_address>
**suggestion (performance):** Consider indexing `(src, dst, type)` to keep the new ORDER BY from regressing edge-lookup performance.

On large `edges` tables, this `ORDER BY dst, type` can turn an index-only `WHERE src = ?` lookup into an expensive sort unless a matching composite index exists. Please ensure the schema defines something like `CREATE INDEX ... ON edges(src, dst, type)` so `edges_from` remains O(k) over outgoing edges rather than incurring a sort.
</issue_to_address>

### Comment 2
<location path="src/aelfrice/store.py" line_range="5371-5372" />
<code_context>
+                    candidates.append((edge.dst, delta, hops + 1))
+
+            # Strongest path first; `dst` breaks ties into a total order.
+            candidates.sort(key=lambda c: (-abs(c[1]), c[0]))
+
+            next_frontier: list[tuple[str, float, int]] = []
</code_context>
<issue_to_address>
**suggestion (performance):** The global sort by |delta| per hop may be more expensive than necessary; consider a lighter-weight selection strategy.

This sorting approach is semantically correct, but it makes each hop an `O(n log n)` operation in the hot loop when the frontier is large. If only the top N candidates are needed before the mass cap applies, a partial selection (e.g. `heapq.nlargest` or a running threshold) could be cheaper. At minimum, consider benchmarking on large graphs to confirm this full sort doesn’t dominate `propagate_valence` runtime.

```suggestion
            # Strongest path first; `dst` breaks ties into a total order.
            #
            # When the frontier is large, a full O(n log n) sort can dominate
            # runtime. Pre-select the strongest K candidates, then sort just
            # that reduced set to preserve the original ordering semantics.
            if candidates:
                import heapq  # local import to avoid touching module imports

                # Soft cap; tune as appropriate for typical frontier sizes.
                max_candidates_per_hop = 4096

                if len(candidates) > max_candidates_per_hop:
                    # Pre-select the strongest subset by |delta|.
                    candidates = heapq.nlargest(
                        max_candidates_per_hop,
                        candidates,
                        key=lambda c: abs(c[1]),
                    )

                # Final deterministic ordering: strongest path first; `dst`
                # breaks ties into a total order.
                candidates.sort(key=lambda c: (-abs(c[1]), c[0]))
```
</issue_to_address>

### Comment 3
<location path="tests/test_propagate_valence.py" line_range="275-296" />
<code_context>
+    )
+
+
+def test_total_injected_mass_is_capped(  # AC4
+) -> None:
+    """One event cannot inject unbounded evidence into a wide graph.
+
+    Falsifiable by the summed absolute delta exceeding the cap."""
+    s = MemoryStore(":memory:")
+    s.insert_belief(_mk("A", alpha=9.0, beta=1.0))
+    for i in range(40):
+        s.insert_belief(_mk(f"B{i:02d}", alpha=5.0, beta=5.0))
+        s.insert_edge(
+            Edge(src="A", dst=f"B{i:02d}", type=EDGE_SUPPORTS, weight=1.0)
+        )
+    out = s.propagate_valence("A", valence=1.0, max_hops=3,
+                              min_threshold=0.0001)
+    total = sum(abs(v) for v in out.values())
+    assert total <= 1.0 * 3 + 1e-9, f"mass {total} exceeds the cap"
+    # The cap binds here, so not every neighbour is reached — but the ones
+    # that are still get a full-strength delta rather than a diluted one.
+    assert out, "cap swallowed every delivery"
+    assert all(abs(v) > 0.0 for v in out.values())
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding an explicit test for negative valence with a custom max_total_mass

The property test already covers negative valence with the default cap, and this test covers positive valence with a custom max_total_mass. To better exercise the override logic for negative events, please add a focused unit test that sets valence < 0 with a custom max_total_mass (smaller than abs(valence) * max_hops) and asserts that the total absolute mass is bounded by that explicit cap.

```suggestion
def test_total_injected_mass_is_capped(  # AC4
) -> None:
    """One event cannot inject unbounded evidence into a wide graph.

    Falsifiable by the summed absolute delta exceeding the cap."""
    s = MemoryStore(":memory:")
    s.insert_belief(_mk("A", alpha=9.0, beta=1.0))
    for i in range(40):
        s.insert_belief(_mk(f"B{i:02d}", alpha=5.0, beta=5.0))
        s.insert_edge(
            Edge(src="A", dst=f"B{i:02d}", type=EDGE_SUPPORTS, weight=1.0)
        )
    out = s.propagate_valence(
        "A",
        valence=1.0,
        max_hops=3,
        min_threshold=0.0001,
    )
    total = sum(abs(v) for v in out.values())
    assert total <= 1.0 * 3 + 1e-9, f"mass {total} exceeds the cap"
    # The cap binds here, so not every neighbour is reached — but the ones
    # that are still get a full-strength delta rather than a diluted one.
    assert out, "cap swallowed every delivery"
    assert all(abs(v) > 0.0 for v in out.values())


def test_total_injected_mass_is_capped_for_negative_valence_with_override() -> None:
    """Negative events are also bounded by an explicit max_total_mass override.

    The explicit cap should bind even when it is smaller than
    abs(valence) * max_hops."""
    s = MemoryStore(":memory:")
    s.insert_belief(_mk("A", alpha=9.0, beta=1.0))
    for i in range(40):
        s.insert_belief(_mk(f"B{i:02d}", alpha=5.0, beta=5.0))
        s.insert_edge(
            Edge(src="A", dst=f"B{i:02d}", type=EDGE_SUPPORTS, weight=1.0)
        )

    valence = -1.0
    max_hops = 3
    max_total_mass = 1.0  # strictly less than abs(valence) * max_hops == 3.0

    out = s.propagate_valence(
        "A",
        valence=valence,
        max_hops=max_hops,
        min_threshold=0.0001,
        max_total_mass=max_total_mass,
    )
    total = sum(abs(v) for v in out.values())

    # The explicit cap must bind the total absolute mass.
    assert total <= max_total_mass + 1e-9, f"mass {total} exceeds explicit cap"

    # The cap must not swallow every delivery, and the delivered mass
    # should reflect the negative valence.
    assert out, "cap swallowed every delivery"
    assert all(v < 0.0 for v in out.values())
```
</issue_to_address>

### Comment 4
<location path="tests/test_propagate_valence.py" line_range="351-360" />
<code_context>
+    edges=st.lists(
</code_context>
<issue_to_address>
**question (testing):** Property test currently excludes self-loops; consider whether they should be in scope

The Hypothesis strategy currently filters out `p[0] == p[1]`, so these cases are never exercised. If self-loops can exist in real stores, it would be good to include them here to verify mass and the visited set logic. If they’re forbidden at the store level, adding a short comment documenting that assumption would clarify why the filter is present.
</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/store.py
Comment thread src/aelfrice/store.py
Comment thread tests/test_propagate_valence.py
Comment thread tests/test_propagate_valence.py
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Addressed the review in a0e1 (pushed).

ORDER BY dst, type needs no new index — declining the suggestion, with evidence. edges is PRIMARY KEY (src, dst, type) (store.py:210-217), so its autoindex already yields exactly that order under WHERE src = ?. Measured:

EXPLAIN QUERY PLAN SELECT * FROM edges WHERE src = ? ORDER BY dst, type
  SEARCH edges USING INDEX sqlite_autoindex_edges_1 (src=?)
  TEMP B-TREE PRESENT: False        # 599 outbound edges from one belief

No sort step, so edges_from stays O(k) in the outgoing degree. Recorded in the docstring so it isn't re-litigated.

Per-hop O(n log n) sort — declining. The sort is dominated by what surrounds it: the loop does a get_belief per candidate edge to check existence, so the per-hop cost is already one query per edge. The frontier is also bounded by max_hops (default 3) and by the mass cap. heapq.nlargest would trade a measurable win for a real loss in legibility here; if this ever shows up in a profile, the per-candidate belief read is the thing to batch first.

Self-loops — good catch, now in scope. insert_edge has no src != dst guard and the PK permits it, so a real store can hold one. I verified it inserts cleanly, removed the filter from the strategy, and documented why the walk is safe either way (source guard on the source, visited set elsewhere).

Negative valence with a custom max_total_mass — added, and it found a bug. The cap skipped any delivery that would overrun the budget. Invisible under the default cap (a single delta can never exceed abs(valence), which is at most abs(valence) * max_hops), but a budget of 0.5 against a first delta of 0.9 returned {} — the whole event silently vanished. The boundary delivery is now clipped to the remaining budget, so a small budget scales the walk down instead of cancelling it, and the total-mass invariant still holds exactly.

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

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T17:19:21Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Approve the fix; one documented property does not hold. All four defects are real and the fixes are right. But the docstring makes a claim about which path credits a belief that the code does not implement, and that matters more than usual here — because this PR's own defect #1 existed for exactly that reason.

The claim that fails

Both the docstring and the PR body say each belief is credited "with the largest-magnitude path that reaches it." It isn't. It's credited at the shallowest hop that reaches it, by the strongest path within that hop. A longer path can carry more mass, and when it does, it loses.

A --RELATES_TO(0.3)--> X                    (1 hop)
A --SUPPORTS(1.0)--> B --SUPPORTS(1.0)--> X (2 hops)
all confidences 0.9, valence 1.0

1-hop  to X: 1.0 * 0.3 * 0.9                       = 0.27
2-hop  to X: (delta_B = 0.9) * 1.0 * conf(B) = 0.9 = 0.81

applied: {'B': 0.9, 'X': 0.27}     <-- X takes the weaker path, 3x under

Magnitude is non-increasing along a single path (every |EDGE_VALENCE| is ≤ 1.0 and every confidence is ≤ 1.0), which I suspect is where the claim came from. But that says nothing about two different paths of different lengths, and a strong 2-hop chain beating a weak 1-hop edge is the ordinary case, not a corner.

Why I'm raising this rather than noting it: defect #1 in this very PR existed because the docstring said "broker confidence" while the code multiplied by the recipient's. The code was wrong for years and the docstring is what made it look right. Landing a new docstring that overstates what the walk does re-creates that failure mode one size smaller, in the same function, in the PR that fixes it.

Either is fine by me:

  • Implement it — collect candidate deltas across the whole walk and credit each node its maximum, rather than deciding at first reach. More faithful to "propagate the strongest evidence", and it interacts better with the mass cap (right now a weak hop-1 delivery can consume budget a stronger hop-2 delivery would have used).
  • Say what it does — "credited once, at the shallowest hop that reaches it, by the strongest path within that hop", and note that shortest-path-wins is a deliberate BFS convention. Cheaper, and defensible.

What I'd rather not see is the current pairing. It'd also be worth stating that the credit-once design leans on |EDGE_VALENCE| ≤ 1.0 for its "deeper is never stronger along a path" intuition — if an edge type ever exceeded 1.0, the gap this probe shows would widen rather than stay bounded.

The rest verified

All four defects are real, the diagnoses are right, and the tests are load-bearing. Mutations run in isolation:

  • Broker direction — reverting the multiplier to the recipient's confidence fails test_recipient_confidence_does_not_scale_its_own_delta and test_low_confidence_belief_still_receives_negative_feedback. The second is the one that matters: low-confidence junk being structurally shielded from the negative signal meant to remove it is a genuinely bad property, and it lines up with the recorded junk-percolation ranking inversion.
  • Fan-in / diamond / cycle double-count — restoring per-in-edge accumulation (dropping the delivery-side visited guard and the candidate-side filter together) fails 4 tests, including the hypothesis property test. α += ~20 from one aelf confirm was not a hypothetical.
  • ORDER BY dst, type is free, as claimed: the PK is (src, dst, type), so WHERE src = ? already yields that sequence from the autoindex. Good change, and the right justification.
  • src_confidence carrying the pre-event confidence is well argued, and "two existing integration tests only pass under the prior-confidence reading" is the right kind of evidence — the constraint came from the tests rather than from taste.
  • The mass cap at abs(valence) * max_hops bounds what one click can inject, and considering candidates strongest-first so the budget drops the weakest paths (rather than diluting everything) is the right shape.

One note on a guard that isn't distinguishing

applied[dst_id] = delta versus applied[dst_id] = applied.get(dst_id, 0.0) + delta is currently indistinguishable — the two visited checks mean a duplicate never reaches that line, and swapping the assignment for accumulation leaves all 16 tests green. That's fine as defensive style, but if the assignment form was meant to be the thing enforcing credit-once, it isn't; the guards are. Worth knowing so nobody later "restores" the += believing it to be equivalent, and then removes a guard.

Merge order

This should land before #1188. Two reasons, and they point the same way:

  1. fix(graph): traverse SUPERSEDES in reverse so BFS surfaces the replacement (#1170) #1188's edges_to docstring justifies its ORDER BY as "the same reason edges_from orders (fix(graph): propagate_valence attenuates by the recipient's own posterior, double-counts fan-in, and is insertion-order dependent #1169)" — a forward reference that is false until this PR lands.
  2. Both touch store.py in the same neighbourhood and will conflict.

This one also needs a rebase (main has moved) and has 3 unresolved bot threads that will bounce the merge-train — worth clearing those before labelling. Happy to re-review promptly once the path-credit question is settled either way; everything else here I'd take as-is.

@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:27:36Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T18:03:23Z]

… caps mass

Four compounding defects in one BFS, all in the same walk.

Attenuation direction. The multiplier was the *recipient's* posterior while
the docstring called it broker confidence. The amount of evidence a belief
received was therefore proportional to how confident it already was:
rich-get-richer on positive signal, and low-confidence junk structurally
shielded from negative signal because its small factor pushed the delta
under min_threshold. It now uses the confidence of the belief the signal
travels *through*. `apply_feedback` supplies the source's confidence as of
before the event, so an event cannot fold its own increment into the
strength of its own propagation — the two existing integration tests that
pin exact deltas already encoded this reading and pass unchanged.

Fan-in and cycles. Delivery accumulated per in-edge, outside the visited
guard, so a diamond delivered 2x and a 5-way fan-in 5x the source signal —
one `aelf confirm` on the root of a convergent subgraph could add alpha +=
20 at the convergence point. The source guard also covered only the source,
so any bidirectional edge pair between two non-source beliefs re-delivered
to them. Each belief is now credited exactly once per walk, by its
largest-magnitude path.

Determinism. edges_from had no ORDER BY, so row order was physical
insertion order — the issue measured a 3.3x difference in the delta
delivered to one node purely from edge insertion sequence. edges_from (and
its peer-scope sibling) now order by (dst, type), and within a hop
candidates are ranked by (-abs(delta), dst) so the strongest path wins with
ids breaking ties.

Mass cap. Total injected mass now defaults to abs(valence) * max_hops.
Candidates are taken strongest-first, so when the budget binds it drops the
weakest paths rather than diluting every delta.

Adds a hypothesis property test asserting, for any graph shape: mass stays
within budget, the source is never a recipient, no single delta exceeds the
source valence, and the result is invariant to edge insertion order. The
diamond characterization test that documented the double-count is rewritten
to the corrected magnitudes.
The mass cap skipped any delivery that would overrun the budget. With the
default cap that is unobservable (a single delta can never exceed
abs(valence), which is at most the cap), but an explicitly small
max_total_mass swallowed the whole event: a budget of 0.5 against a first
delta of 0.9 returned nothing. The boundary delivery is now clipped to the
remaining budget, so a small budget scales the walk down instead of
cancelling it, and the total-mass invariant still holds exactly.

The property test filtered self-loops out. insert_edge accepts src == dst
(no guard, and the PK permits it), so a real store can hold one and the walk
has to handle it — dropped by the source guard on the source, by the visited
set elsewhere. The strategy now generates them.

Adds a negative-valence case for the explicit budget override, and records
in the edges_from docstring why the new ORDER BY needs no additional index:
edges is PRIMARY KEY (src, dst, type), so its autoindex already yields that
order under WHERE src = ? — EXPLAIN QUERY PLAN shows a plain index search
with no temp b-tree at ~600 outbound edges from one belief.
The module docstring named a session identity and described only the
original one-chain broker test. Replaced with the file's actual coverage:
broker attenuation (#1058) plus the direction, credit-once, mass-cap and
determinism properties added in #1169.
propagate_valence credits each belief once, at the shallowest hop that
reaches it, by the strongest path within that hop. The docstring and the
changelog both said 'largest-magnitude path', which is a different rule
and not the one implemented: magnitude is non-increasing along a single
path, but two paths of different lengths are not comparable that way, so
a longer chain of strong edges can carry more than a short weak one.

  A -RELATES_TO(0.3)-> X          delivers 0.27   <- taken
  A -SUPPORTS-> B -SUPPORTS-> X   delivers 0.81   <- not taken

Shortest-path-wins is the right rule to keep: crediting by the maximum
over all paths would mean deferring every delivery until the walk
completes, and hop distance is itself evidence of relatedness. So this
corrects the description rather than the behaviour, and adds a test that
fails if a deeper hop is ever allowed to overwrite a shallower credit.

This matters more than a wording nit here: defect (1) of #1169 existed
because the docstring named broker confidence while the code multiplied
by the recipient's. Raised in review on #1184.
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1169-propagate-valence branch from ace8994 to 4f5f866 Compare July 30, 2026 18:07
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Rebased onto 7d4915c5 and addressed my own review finding plus both bot threads. Now 4f5f866d, FF on main, 5 signed commits, discretion clean, 17 valence tests green.

The path-credit question — corrected the description, not the behaviour.

Shortest-path-wins is the right rule to keep. Crediting by the maximum over all paths would mean deferring every delivery until the walk completes, and hop distance is itself evidence of relatedness. What was wrong was the docstring and the changelog both saying "largest-magnitude path", which is a different rule:

A -RELATES_TO(0.3)-> X           delivers 0.27   <- taken
A -SUPPORTS-> B -SUPPORTS-> X    delivers 0.81   <- not taken

Both now say what the walk does, and a new test fails if a deeper hop is ever allowed to overwrite a shallower credit (verified: letting deeper hops overwrite fails it, and also fails test_cycle_between_non_source_beliefs_delivers_once).

Sourcery, negative valence + custom max_total_mass — already covered. test_explicit_max_total_mass_is_honoured_for_negative_valence (line 304) does exactly this: valence=-1.0, max_total_mass=0.5 against a default of 3.0, asserting the summed magnitude is bounded and every surviving delta stays negative. I wrote the suggested test before noticing, confirmed it was load-bearing (ignoring the override fails it and the two existing cap tests), then deleted it as a duplicate. No change needed.

Sourcery, heapq.nlargest for the per-hop sort — declining, and I'd argue against it. Three reasons:

  1. It is slower in the common case. nlargest is only a win when k << n; here it would run and then the full sort still runs on the reduced set, so every hop under the threshold pays an extra pass for nothing.
  2. The magic 4096 silently truncates. Candidates above the cutoff are dropped with no counter and no log line — the exact "silent cap" failure mode [Umbrella] The measurement apparatus cannot detect the defects it exists to catch #1160 is open about one repo directory over.
  3. It ranks by abs(delta) alone, dropping the dst tie-break during selection. Two candidates with equal magnitude at the boundary would be selected non-deterministically, which undoes AC5 — the property test asserts output is invariant to edge insertion order, and this is the thing that makes that true.

The frontier is bounded by the mass cap, so candidates is bounded by (frontier x out-degree) rather than by graph size. If that ever does dominate, the fix is to bound the frontier, not to sample the sort.

Labelling once CI settles.

@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.

🧹 Nitpick comments (3)
tests/test_propagate_valence.py (2)

266-266: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nit: EDGE_RELATES_TO is already imported at module scope (line 23).

The function-local re-import is redundant now that the header import set was expanded.

♻️ Suggested cleanup
-    from aelfrice.models import EDGE_RELATES_TO
-
     forward = [
🤖 Prompt for 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.

In `@tests/test_propagate_valence.py` at line 266, Remove the redundant
function-local EDGE_RELATES_TO import in the affected test, since it is already
available from the module-scope import in tests/test_propagate_valence.py.

247-256: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: close the store in the helpers/tests that don't.

_diamond_in_edge_order, _build_chain-style helpers, and the mass-cap tests leave the MemoryStore handle open, unlike the newer property/shallow-path tests which use try/finally: s.close(). Consistent teardown (or a fixture) keeps connection handling uniform across the module.

🤖 Prompt for 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.

In `@tests/test_propagate_valence.py` around lines 247 - 256, Add consistent
MemoryStore cleanup to _diamond_in_edge_order, _build_chain-style helpers, and
mass-cap tests by ensuring each created store is closed in a finally block or
through the module’s existing fixture pattern. Preserve each helper’s current
propagation and assertion behavior while guaranteeing teardown on success and
failure.
src/aelfrice/store.py (1)

5580-5580: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: Ruff RUF002 flags the α in this docstring.

If RUF002 is enforced in CI, spelling it alpha (as the changelog entry does) clears the warning without losing meaning.

✏️ Suggested wording tweak
-        the root of a convergent subgraph could add α += ~20 to the
+        the root of a convergent subgraph could add alpha += ~20 to the
🤖 Prompt for 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.

In `@src/aelfrice/store.py` at line 5580, Optionally update the docstring text
around the convergent subgraph explanation to replace the ambiguous Greek
character “α” with the ASCII term “alpha,” preserving the existing meaning and
wording otherwise.

Source: Linters/SAST tools

🤖 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.

Nitpick comments:
In `@src/aelfrice/store.py`:
- Line 5580: Optionally update the docstring text around the convergent subgraph
explanation to replace the ambiguous Greek character “α” with the ASCII term
“alpha,” preserving the existing meaning and wording otherwise.

In `@tests/test_propagate_valence.py`:
- Line 266: Remove the redundant function-local EDGE_RELATES_TO import in the
affected test, since it is already available from the module-scope import in
tests/test_propagate_valence.py.
- Around line 247-256: Add consistent MemoryStore cleanup to
_diamond_in_edge_order, _build_chain-style helpers, and mass-cap tests by
ensuring each created store is closed in a finally block or through the module’s
existing fixture pattern. Preserve each helper’s current propagation and
assertion behavior while guaranteeing teardown on success and failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06edc647-fc3e-4b4d-ba17-d5d6469fd6f3

📥 Commits

Reviewing files that changed from the base of the PR and between 7d4915c and 4f5f866.

📒 Files selected for processing (4)
  • CHANGELOG/v4.md
  • src/aelfrice/feedback.py
  • src/aelfrice/store.py
  • tests/test_propagate_valence.py

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Sourcery, self-loops in the property test — already addressed, in commit 0eaa3237 ("address review — clip the boundary delta, cover self-loops"). The thread is reviewing an earlier diff state.

The strategy does not filter p[0] == p[1] any more, and carries the comment the thread asks for:

# Self-loops are NOT filtered: `insert_edge` accepts src == dst
# (no guard, and the PK permits it), so a real store can hold one
# and the walk must handle it — a self-loop on the source is
# dropped by the source guard, one elsewhere by the visited set.

Which is the right answer to the question it raises: self-loops are not forbidden at the store level, so they belong in scope, and they are.

No change needed.

@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
@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 4f5f866 into main Jul 30, 2026
36 of 37 checks passed
@github-actions

Copy link
Copy Markdown

merge-train: merged 4f5f866main via FF push.

robotrocketscience added a commit that referenced this pull request Jul 30, 2026
…y when it isn't

The three-round cap was the normal termination, not a backstop. At
l1_limit=50 the rounds reach 200, so a query whose 200 strongest matches
are all retired returned an empty pack while current ones sat below it:

  superseded/matching   available   returned (before)   after
  150/300               150         50                  50
  200/300               100          0                  50
  250/300                50          0                  50

That is the starvation #1187 set out to remove, moved out 4x rather than
removed — my bound, introduced while fixing the original.

Worse, it truncated silently. Nothing distinguished 'the store holds two
survivors' from 'I stopped after three rounds with a hundred unread',
which is the silent-cap failure #1160 is open about, and which I argued
against on #1184 before shipping it a PR later.

The cap is now high enough that binding means something pathological,
and binding traces to stderr naming the limit reached and how many
survivors were found. The comment's 'strictly more than the pre-#1187
behaviour' claim is corrected: at 200/300 both returned 0, so it was
'at least as much'.

Closes #1205.
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): propagate_valence attenuates by the recipient's own posterior, double-counts fan-in, and is insertion-order dependent

1 participant