SIGN IN SIGN UP

perf(consolidation): write only decayed rows that changed (#1417)

* perf(consolidation-pipeline): skip decay writes for unchanged memories

The decay tier of mem::consolidate-pipeline listed every semantic and
procedural memory and wrote every row back on every run, even when
applyDecay left the strength untouched (the common case for anything
not yet due for decay). Each write is a full state::set RPC and marks
the whole scope dirty for the engine's next flush, so this scaled with
total memory count on every consolidation cycle regardless of how much
actually decayed.

Capture each item's strength before calling applyDecay and only call
kv.set for items whose strength changed by more than a small float
tolerance, mirroring the dirty-only pattern already used by
mem::lesson-decay-sweep. applyDecay itself is unchanged, so the decay
trajectory over time is identical to before; only the redundant no-op
writes for untouched rows are removed. results.decay now reports
scanned/written counts per tier instead of just the row count.

Added tests covering: unchanged rows are not written, changed rows are
written with correct scanned/written counts, and decay progression
across simulated time lands at the same strengths as the unconditional
write path did.

* perf(consolidation): write decayed rows one at a time

The first decay run after an upgrade can find most rows due at once.
Writing them all in parallel would fire that many state::set calls at
the engine together, where they queue behind its single store lock.
Sequential writes keep the load the same shape as before.

* test(consolidation-pipeline): assert persistence at each decay step

The simulated-time decay test only checked kv.list()[0].strength, but
mockKV.list returns live object references that applyDecay mutates in
place. That let the assertions pass even if a kv.set write were
skipped, since the in-memory object already reflected the mutation
regardless of persistence. Spy on kv.set and assert exactly one
semantic write after the 45-day step and a second after the 95-day
step, so a missed persistence write would actually fail the test.

* fix(consolidation): decay at most once per period

applyDecay recomputed daysSince from lastAccessedAt on every pipeline
run, and lastAccessedAt never advances, so each run re-applied the
0.9^periods factor to the already-decayed strength. With the default
consolidation cooldown this compounded a month-old fact down to the
0.1 floor within hours of active use instead of over months.

Track lastDecayedAt on semantic and procedural rows and anchor the
next decay check to max(lastAccessedAt, lastDecayedAt), only applying
another decay step once decayDays have passed since that anchor. Rows
without the field decay once, same as before, and get it set. Updated
the simulated-time test to assert once-per-period decay (including a
run inside the same period that must not write) instead of pinning
the old compounded value.
R
Rohit Ghumare committed
e69a606e8faf5ccdde15723278a8bd7dd7ec047a
Parent: bcf4f0d
Committed by GitHub <noreply@github.com> on 9/28/2026, 6:38:00 AM