Skip to content

fix(memory): apply per_hop_decay once per hop in _spread 🤖🤖🤖 - #297

Open
sushant-mishra-dtu wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
sushant-mishra-dtu:fix/memory-spread-per-hop-decay
Open

fix(memory): apply per_hop_decay once per hop in _spread 🤖🤖🤖#297
sushant-mishra-dtu wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
sushant-mishra-dtu:fix/memory-spread-per-hop-decay

Conversation

@sushant-mishra-dtu

@sushant-mishra-dtu sushant-mishra-dtu commented Sep 6, 2026

Copy link
Copy Markdown

What this fixes

RetrievalEngine._spread is documented as decaying once per hop, in the docstring
(packages/nooa-memory/src/nooa_memory/retrieval.py:249):

Propagate activation outward over edges, decaying per_hop_decay per hop.

and again on the knob itself (packages/nooa-memory/src/nooa_memory/config.py:35):

per_hop_decay: float = 0.6  # delta — activation decay per hop

The loop applies per_hop_decay ** h at hop h
(packages/nooa-memory/src/nooa_memory/retrieval.py:265-283):

frontier = dict(seed_activation)
for h in range(1, hops + 1):
    decay = cfg.per_hop_decay**h
    ...
    for node, act in frontier.items():
        ...
            contrib = decay * act * e.weight * type_w
            if contrib < cfg.activation_floor:
                continue
            nxt[e.target_id] = nxt.get(e.target_id, 0.0) + contrib
    frontier = nxt

But act comes from frontier, which is last hop's nxt -- it already carries the decay
of every earlier hop. Multiplying by delta ** h again compounds them: the total applied
at hop h is delta ** (1+2+...+h), i.e. delta ** (h(h+1)/2), not delta ** h.

Why it matters

The compounding collapses fast enough to cross activation_floor and silently discard
whole hops. On a chain of unit-weight CAUSES edges with the shipped defaults
(per_hop_decay=0.6, activation_floor=0.05):

hop measured on main documented delta ** h
1 0.600000 0.600000
2 0.216000 0.360000
3 dropped (0.046656 < floor) 0.216000

A memory three causal edges from the seed is never recalled, however strong the links --
hops=3 behaves as hops=2 at the defaults. At hop 2 it is not dropped, just scored 40%
low, which quietly moves it down the ranking against unlinked candidates.

RetrievalConfig.hops is documented as 0 = vector-only; 1 = +1 graph hop; 2+ = multi-hop
(config.py:34), so 2+ is a supported configuration, not an edge case.

Reproduction

Four memories in a chain a -> b -> c -> d, all EdgeType.CAUSES, all weight 1.0:

cfg = RetrievalConfig(hops=3, per_hop_decay=0.6, activation_floor=0.05, per_hop_fanout=5)
eng = RetrievalEngine(store, emb, cfg)
spread = eng._spread({a.id: 1.0}, hops=3)

main:

hop  node        measured    documented d^h   compounded d^(h(h+1)/2)
1    b           0.600000          0.600000                  0.600000
2    c           0.216000          0.360000                  0.216000
3    d            DROPPED          0.216000                  0.046656

this branch:

1    b           0.600000          0.600000                  0.600000
2    c           0.360000          0.360000                  0.216000
3    d           0.216000          0.216000                  0.046656

The fix

The frontier already carries the earlier hops, so apply one factor per step:

         spread: dict[str, float] = {}
         frontier = dict(seed_activation)
-        for h in range(1, hops + 1):
-            decay = cfg.per_hop_decay**h
+        # ``act`` already carries the decay of every earlier hop, so applying a
+        # single factor per step is what yields ``per_hop_decay ** h`` at hop h.
+        decay = cfg.per_hop_decay
+        for _ in range(hops):
             nxt: dict[str, float] = {}

The behaviour change, stated plainly: multi-hop spread values go up, so recall results
can reorder for anyone running hops >= 2. At the default hops=1 nothing changes at all
-- delta ** 1 is delta under either form. No existing test in
packages/nooa-memory/tests/memory/ changes result; the one that covers this
(test_spread_decays_per_hop) asserts only spread[b] > spread[c] > 0, which is why the
rate was never pinned down. If you would rather keep today's numbers and correct the two
comments instead, say so and I will send that diff -- but at the defaults that means
documenting hops=3 as having no third hop.

Test

One test, test_spread_decay_is_one_factor_per_hop, placed beside the existing
test_spread_decays_per_hop it strengthens. Verified to fail on the unfixed tree before
being kept:

FAILED packages/nooa-memory/tests/memory/test_memory_retrieval.py::
       test_spread_decay_is_one_factor_per_hop
E   assert 0.216 == 0.36 ± 3.6e-07
E     Obtained: 0.216
E     Expected: 0.36 ± 3.6e-07

packages/nooa-memory/tests/memory/ is 3 failed / 267 passed / 13 skipped on this branch
against 3 failed / 266 passed / 13 skipped on main -- the same three pre-existing
Windows-only failures (two read_text() calls with no encoding= inside the test files,
one expecting /etc/passwd to be absolute), plus the new test passing.

Worth noting: that directory is not in testpaths, so CI does not currently collect this
test. #292 is the one-line change that turns it on.

Scope

Only the decay factor. _visible and the owner-scoping logic are untouched, so this does
not overlap #275.

Summary by CodeRabbit

  • Bug Fixes

    • Corrected associative activation spreading so decay is applied consistently at each propagation step.
    • Improved activation values across multi-step causal chains, including values below the activation floor.
  • Tests

    • Added regression coverage for cumulative per-hop decay across three-edge causal chains.

_spread's docstring says it propagates activation "decaying per_hop_decay
per hop", and the config comment agrees: "delta -- activation decay per
hop". The loop multiplies by ``per_hop_decay ** h`` at hop h, but the
activation it multiplies is the frontier value, which already carries the
decay of every earlier hop. The factors compound to delta ** (h(h+1)/2).

On a chain of unit-weight causal edges with the defaults
(per_hop_decay=0.6, activation_floor=0.05):

    hop  measured   documented delta**h
    1    0.600000   0.600000
    2    0.216000   0.360000
    3    DROPPED    0.216000

Hop 3 falls under activation_floor and is discarded, so a memory three
causal edges from the seed is never recalled however strong the links.

The frontier already carries the earlier hops, so apply one factor per
step. Defaults are unaffected -- RetrievalConfig.hops is 1, where both
forms give delta -- this only changes the documented "2+ = multi-hop"
path, which now matches the documented rate.

🤖🤖🤖

Signed-off-by: sushant-mishra-dtu <sushant.arh@gmail.com>
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ee2cec45-47fb-42b5-9027-697326688a68

📥 Commits

Reviewing files that changed from the base of the PR and between e137e1b and d429da6.

📒 Files selected for processing (2)
  • packages/nooa-memory/src/nooa_memory/retrieval.py
  • packages/nooa-memory/tests/memory/test_memory_retrieval.py

Included review availability: Your plan provides up to 12 included reviews per hour; 10 remain after this review.


📝 Walkthrough

Walkthrough

The associative spread loop now applies per_hop_decay once per hop. A regression test verifies cumulative decay across a three-edge causal chain, including activations below the configured floor.

Changes

Associative spread decay

Layer / File(s) Summary
Incremental decay and regression coverage
packages/nooa-memory/src/nooa_memory/retrieval.py, packages/nooa-memory/tests/memory/test_memory_retrieval.py
The propagation loop applies decay incrementally at each hop. The regression test verifies activations of 0.6, 0.6**2, and 0.6**3 across three hops with activation_floor=0.05.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Merge Risk: ⚪ Minimal · up to d429d

Multi-hop associative retrieval now retains activation according to one decay factor per hop, improving intended recall behavior without changing single-hop behavior. The corrected decay sequence is covered by a three-hop regression test, with no current merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: applying per_hop_decay once per hop in _spread.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant