Skip to content

Commit 7281d96

Browse files
committed
Persist passive working-memory decay
1 parent 8ee3382 commit 7281d96

9 files changed

Lines changed: 485 additions & 39 deletions

docs/paper/_manuscript/index.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2189,12 +2189,23 @@ accounting use separate timestamps so repeated maintenance passes charge
21892189
each elapsed interval once without making an untouched slot appear
21902190
recently accessed:
21912191

2192-
Δt_decay ← max(0, now_s() − strength_ts)
2193-
strength ← max(strength_floor(F,S,T),
2194-
strength − maintenance_cost_per_memory × Δt_decay)
2195-
strength_ts ← max(strength_ts, now_s())
2192+
Δt_decay ← now_s() − strength_ts
2193+
if Δt_decay > 0:
2194+
strength ← max(strength_floor(F,S,T),
2195+
strength − maintenance_cost_per_memory × Δt_decay)
2196+
strength_ts ← now_s()
21962197
# last_ts changes only on chunking, rehearsal, or insertion
21972198

2199+
The active floor is applied only while charging positive elapsed time. A
2200+
same-time or out-of-order signal therefore cannot recharge a slot that
2201+
was legitimately persisted below the current floor under an earlier knob
2202+
setting.
2203+
2204+
Persisted working-memory rows mirror `strength_ts` as
2205+
`strength_updated_at`. Load-time decay advances the same timestamp and
2206+
leaves changed slot metadata dirty so the next normal process persists
2207+
both values.
2208+
21982209
The manifold_complexity is defined as a normalized local variability
21992210
proxy (see Appendix B): manifold_complexity ← clamp((1 −
22002211
mean_cos_window) / 2, 0, 1).
@@ -3496,8 +3507,11 @@ boundaries, interrupts, candidate totals, threshold and effective-focus
34963507
sums, and final memory, association, and embedding counts. The observed
34973508
throughput difference is within run-to-run variation. This comparison
34983509
covers the core operation and persistence loop with synthetic
3499-
embeddings, not model inference or language-runtime overhead; the
3500-
implemented checks add no per-signal query or eager graph rebuild.
3510+
embeddings, not model inference or language-runtime overhead. At that
3511+
measured head, the implemented checks added no per-signal query or eager
3512+
graph rebuild. The later passive-decay durability repair adds at most
3513+
one bounded memory-row update per changed live slot during normal
3514+
persistence and is outside this historical measurement.
35013515

35023516
Separately, the July 8 retention work was run through the normalized
35033517
memory-eval smoke slice with all configured benchmark adapters, using
@@ -5100,10 +5114,12 @@ fanout limits. Working-memory and soft-anchor updates operate over
51005114
bounded live state and remain independent of total store size.
51015115

51025116
The safety checks preserve these bounds: bind-result validation is one
5103-
branch per existing parameter, working-memory decay adds one timestamp
5104-
update per bounded slot, and graph writes perform only O(1) cache
5105-
invalidation. None adds a per-signal database query or eager graph
5106-
reconstruction.
5117+
branch per existing parameter, working-memory decay advances one
5118+
timestamp per bounded slot and schedules at most one memory-row update
5119+
per changed slot during normal persistence, and graph writes perform
5120+
only O(1) cache invalidation. None adds a store-size-dependent query or
5121+
eager graph reconstruction; working-memory persistence remains bounded
5122+
by the configured live-slot capacity.
51075123

51085124
The retained long-horizon optimization target is that mean process
51095125
latency stays approximately flat as the database grows. The July 2026

docs/paper/sections/10_implementation.qmd

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,11 @@ soft-anchor updates operate over bounded live state and remain independent of
167167
total store size.
168168

169169
The safety checks preserve these bounds: bind-result validation is one branch
170-
per existing parameter, working-memory decay adds one timestamp update per
171-
bounded slot, and graph writes perform only O(1) cache invalidation. None adds
172-
a per-signal database query or eager graph reconstruction.
170+
per existing parameter, working-memory decay advances one timestamp per bounded
171+
slot and schedules at most one memory-row update per changed slot during normal
172+
persistence, and graph writes perform only O(1) cache invalidation. None adds a
173+
store-size-dependent query or eager graph reconstruction; working-memory
174+
persistence remains bounded by the configured live-slot capacity.
173175

174176
The retained long-horizon optimization target is that mean process latency
175177
stays approximately flat as the database grows. The July 2026 exact-replay

docs/paper/sections/6_advanced_cognitive.qmd

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,22 @@ separate timestamps so repeated maintenance passes charge each elapsed interval
9191
once without making an untouched slot appear recently accessed:
9292

9393
```
94-
Δt_decay ← max(0, now_s() − strength_ts)
95-
strength ← max(strength_floor(F,S,T),
96-
strength − maintenance_cost_per_memory × Δt_decay)
97-
strength_ts ← max(strength_ts, now_s())
94+
Δt_decay ← now_s() − strength_ts
95+
if Δt_decay > 0:
96+
strength ← max(strength_floor(F,S,T),
97+
strength − maintenance_cost_per_memory × Δt_decay)
98+
strength_ts ← now_s()
9899
# last_ts changes only on chunking, rehearsal, or insertion
99100
```
100101

102+
The active floor is applied only while charging positive elapsed time. A
103+
same-time or out-of-order signal therefore cannot recharge a slot that was
104+
legitimately persisted below the current floor under an earlier knob setting.
105+
106+
Persisted working-memory rows mirror `strength_ts` as `strength_updated_at`.
107+
Load-time decay advances the same timestamp and leaves changed slot metadata
108+
dirty so the next normal process persists both values.
109+
101110
The manifold_complexity is defined as a normalized local variability proxy (see Appendix B): manifold_complexity ← clamp((1 − mean_cos_window) / 2, 0, 1).
102111

103112
### Memory-Level Gating

docs/paper/sections/9_experimental.qmd

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,10 @@ boundaries, interrupts, candidate totals, threshold and effective-focus sums,
166166
and final memory, association, and embedding counts. The observed throughput
167167
difference is within run-to-run variation. This comparison covers the core
168168
operation and persistence loop with synthetic embeddings, not model inference
169-
or language-runtime overhead; the implemented checks add no per-signal query
170-
or eager graph rebuild.
169+
or language-runtime overhead. At that measured head, the implemented checks
170+
added no per-signal query or eager graph rebuild. The later passive-decay
171+
durability repair adds at most one bounded memory-row update per changed live
172+
slot during normal persistence and is outside this historical measurement.
171173

172174
Separately, the July 8 retention work was run through the normalized memory-eval
173175
smoke slice with all configured benchmark adapters, using durable history

src/operations/working_memory.cpp

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,27 @@ WorkingMemory::Execute (OperationContext &context, Transaction &tx) const
103103
{
104104
const double last_decay
105105
= slot.strength_ts > 0.0 ? slot.strength_ts : slot.last_ts;
106-
const double dt = std::max (0.0, now_s - last_decay);
107-
// Decay strength but floor at a minimum to preserve slot
108-
// Slot will be replaced via eviction when capacity is reached
109-
const double strength_after
110-
= std::max (core::WMStrengthFloor (
111-
cfg.focus, cfg.sensitivity, cfg.stability),
112-
slot.strength - cost_per_slot * dt);
106+
const double dt = now_s - last_decay;
107+
double strength_after = slot.strength;
108+
double strength_ts_after = slot.strength_ts;
109+
if (dt > 0.0)
110+
{
111+
// Decay strength but floor at a minimum to preserve the slot. The
112+
// active floor applies only while charging positive elapsed time;
113+
// a knob change must not recharge an already-weaker slot.
114+
strength_after
115+
= std::max (core::WMStrengthFloor (
116+
cfg.focus, cfg.sensitivity, cfg.stability),
117+
slot.strength - cost_per_slot * dt);
118+
strength_ts_after = now_s;
119+
}
120+
if (slot.strength != strength_after
121+
|| slot.strength_ts != strength_ts_after)
122+
{
123+
slot.metadata_dirty = true;
124+
}
113125
slot.strength = strength_after;
114-
slot.strength_ts = std::max (last_decay, now_s);
126+
slot.strength_ts = strength_ts_after;
115127
}
116128

117129
// Complexity penalty from entropy over strengths.

src/signal_processor.cpp

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "cortext/store/utils.hpp"
1313
#include "operations/constructive_recall_internal.hpp"
1414
#include "operations/historical_surface_search_cache_internal.hpp"
15+
#include "working_memory_time_internal.hpp"
1516
#include <algorithm>
1617
#include <atomic>
1718
#include <chrono>
@@ -1783,25 +1784,29 @@ LoadWorkingMemory (Store &store, ProcessorContext &ctx,
17831784
// DB stores milliseconds, convert to seconds for last_ts
17841785
const auto ts_ms = ExtractInt64 (row, "last_access", now_ms);
17851786
slot.last_ts = static_cast<double> (ts_ms) / 1000.0;
1786-
const auto strength_ts_ms
1787+
const auto persisted_strength_ts_ms
17871788
= ExtractInt64 (row, "strength_updated_at", ts_ms);
1789+
const auto strength_ts_ms = persisted_strength_ts_ms > 0
1790+
? persisted_strength_ts_ms
1791+
: ts_ms;
17881792
slot.strength_ts = static_cast<double> (strength_ts_ms) / 1000.0;
17891793
slot.start_ts = ExtractInt64 (row, "start_ts", 0);
17901794

17911795
// Apply time-based decay
1796+
const double strength_before = slot.strength;
1797+
const double strength_ts_before = slot.strength_ts;
17921798
const double now_s = static_cast<double> (now_ms) / 1000.0;
17931799
const double elapsed = now_s - slot.strength_ts;
17941800
if (elapsed > 0)
17951801
{
17961802
slot.strength
17971803
= std::max (strength_floor,
17981804
slot.strength - cost_per_slot * elapsed);
1805+
slot.strength_ts = now_s;
17991806
}
1800-
else
1801-
{
1802-
slot.strength = std::max (strength_floor, slot.strength);
1803-
}
1804-
slot.strength_ts = std::max (slot.strength_ts, now_s);
1807+
const bool metadata_changed
1808+
= slot.strength != strength_before
1809+
|| slot.strength_ts != strength_ts_before;
18051810

18061811
// Load extended metadata
18071812
slot.n_signals = static_cast<int> (ExtractInt64 (row, "n_signals", 1));
@@ -1899,7 +1904,7 @@ LoadWorkingMemory (Store &store, ProcessorContext &ctx,
18991904
}
19001905

19011906
slot.persisted_signal_record_count = slot.signal_records.size ();
1902-
slot.metadata_dirty = false;
1907+
slot.metadata_dirty = metadata_changed;
19031908
slot.embedding_dirty = false;
19041909
slot.signal_records_dirty = false;
19051910
ctx.wm_slots.push_back (std::move (slot));
@@ -2830,7 +2835,7 @@ SignalProcessor::PersistWorkingMemory (Transaction &tx, bool force,
28302835
bool all_slots_clean = true;
28312836

28322837
// Upsert current slots as MEMORIES with kind='WORKING'. Clean slots are
2833-
// left untouched; passive decay is recovered from last_access on reload.
2838+
// left untouched; passive maintenance marks changed metadata dirty.
28342839
for (auto &slot : context_->wm_slots)
28352840
{
28362841
std::string failure_stage = "slot_precheck";
@@ -2859,10 +2864,10 @@ SignalProcessor::PersistWorkingMemory (Transaction &tx, bool force,
28592864

28602865
try
28612866
{
2862-
const auto ts_ms = static_cast<int64_t> (slot.last_ts * 1000.0);
2863-
const auto strength_ts_ms = static_cast<long long> (
2864-
(slot.strength_ts > 0.0 ? slot.strength_ts : slot.last_ts)
2865-
* 1000.0);
2867+
const auto ts_ms
2868+
= internal::WorkingMemorySecondsToMillis (slot.last_ts);
2869+
const auto strength_ts_ms = internal::WorkingMemorySecondsToMillis (
2870+
slot.strength_ts > 0.0 ? slot.strength_ts : slot.last_ts);
28662871
const auto slot_created_at
28672872
= slot.start_ts > 0
28682873
? static_cast<long long> (slot.start_ts)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#pragma once
2+
3+
#include <cmath>
4+
#include <cstdint>
5+
#include <stdexcept>
6+
7+
namespace cortext::internal
8+
{
9+
10+
/// Convert a finite, nonnegative working-memory timestamp to the nearest
11+
/// signed-64-bit millisecond. The exclusive upper bound is exactly 2^63,
12+
/// which remains representable even when double and long double both have
13+
/// 53-bit precision.
14+
inline int64_t
15+
WorkingMemorySecondsToMillis (double seconds)
16+
{
17+
if (!std::isfinite (seconds) || seconds < 0.0)
18+
{
19+
throw std::invalid_argument (
20+
"Working-memory timestamp must be finite and nonnegative");
21+
}
22+
23+
const double millis = seconds * 1000.0;
24+
constexpr double kExclusiveUpperMillis = 0x1p63;
25+
if (!std::isfinite (millis) || millis >= kExclusiveUpperMillis)
26+
{
27+
throw std::out_of_range ("Working-memory timestamp exceeds int64 range");
28+
}
29+
return static_cast<int64_t> (std::llround (millis));
30+
}
31+
32+
} // namespace cortext::internal

tests/operations_working_memory.test.cpp

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ TEST_CASE ("Alg24 maintenance charges each elapsed interval once",
225225
slot.strength = 2.0;
226226
slot.last_ts = 0.0;
227227
slot.strength_ts = 0.0;
228+
slot.metadata_dirty = false;
228229
pctx.wm_slots.push_back (slot);
229230

230231
WorkingMemory op;
@@ -234,18 +235,86 @@ TEST_CASE ("Alg24 maintenance charges each elapsed interval once",
234235
first.timestamp = 1000;
235236
OperationContext first_ctx (first, pctx, cfg);
236237
op.Execute (first_ctx, cortext::testing::GetNullTransaction ());
238+
REQUIRE (pctx.wm_slots.front ().metadata_dirty);
239+
REQUIRE_FALSE (pctx.wm_slots_dirty);
240+
241+
pctx.wm_slots.front ().metadata_dirty = false;
242+
OperationContext same_time_ctx (first, pctx, cfg);
243+
op.Execute (same_time_ctx, cortext::testing::GetNullTransaction ());
244+
REQUIRE_FALSE (pctx.wm_slots.front ().metadata_dirty);
245+
REQUIRE_FALSE (pctx.wm_slots_dirty);
237246

238247
Signal second = first;
239248
second.timestamp = 2000;
240249
OperationContext second_ctx (second, pctx, cfg);
241250
op.Execute (second_ctx, cortext::testing::GetNullTransaction ());
251+
REQUIRE (pctx.wm_slots.front ().metadata_dirty);
252+
REQUIRE_FALSE (pctx.wm_slots_dirty);
242253

243254
const double cost
244255
= core::WMMaintenanceCostPerSlot (cfg.sensitivity, cfg.focus);
245256
REQUIRE (pctx.wm_slots.front ().strength
246257
== Catch::Approx (2.0 - 2.0 * cost));
247258
REQUIRE (pctx.wm_slots.front ().last_ts == Catch::Approx (0.0));
248259
REQUIRE (pctx.wm_slots.front ().strength_ts == Catch::Approx (2.0));
260+
261+
pctx.wm_slots.front ().metadata_dirty = false;
262+
Signal backward = second;
263+
backward.timestamp = 1500;
264+
OperationContext backward_ctx (backward, pctx, cfg);
265+
op.Execute (backward_ctx, cortext::testing::GetNullTransaction ());
266+
REQUIRE (pctx.wm_slots.front ().strength
267+
== Catch::Approx (2.0 - 2.0 * cost));
268+
REQUIRE_FALSE (pctx.wm_slots.front ().metadata_dirty);
269+
REQUIRE_FALSE (pctx.wm_slots_dirty);
270+
}
271+
272+
TEST_CASE ("Alg24 maintenance does not recharge below-floor slots without time",
273+
"[operations][working_memory][maintenance][decay][regression]")
274+
{
275+
SignalProcessor::Config prior_cfg;
276+
cortext::testing::RequireEncoder (prior_cfg);
277+
prior_cfg.focus = 1.0;
278+
prior_cfg.sensitivity = 1.0;
279+
prior_cfg.stability = 0.0;
280+
281+
SignalProcessor::Config current_cfg = prior_cfg;
282+
current_cfg.focus = 0.0;
283+
current_cfg.sensitivity = 0.0;
284+
current_cfg.stability = 1.0;
285+
286+
const double prior_floor = core::WMStrengthFloor (
287+
prior_cfg.focus, prior_cfg.sensitivity, prior_cfg.stability);
288+
REQUIRE (prior_floor
289+
< core::WMStrengthFloor (current_cfg.focus,
290+
current_cfg.sensitivity,
291+
current_cfg.stability));
292+
293+
ProcessorContext pctx;
294+
ProcessorContext::WMSlot slot;
295+
slot.embedding = Eigen::VectorXf::Ones (3);
296+
slot.strength = prior_floor;
297+
slot.last_ts = 2.0;
298+
slot.strength_ts = 2.0;
299+
slot.metadata_dirty = false;
300+
pctx.wm_slots.push_back (slot);
301+
302+
WorkingMemory op;
303+
Signal signal;
304+
signal.embedding = slot.embedding;
305+
signal.source_id = "test";
306+
signal.timestamp = 2000;
307+
OperationContext same_time_ctx (signal, pctx, current_cfg);
308+
op.Execute (same_time_ctx, cortext::testing::GetNullTransaction ());
309+
REQUIRE (pctx.wm_slots.front ().strength == Catch::Approx (prior_floor));
310+
REQUIRE_FALSE (pctx.wm_slots.front ().metadata_dirty);
311+
312+
signal.timestamp = 1500;
313+
OperationContext backward_ctx (signal, pctx, current_cfg);
314+
op.Execute (backward_ctx, cortext::testing::GetNullTransaction ());
315+
REQUIRE (pctx.wm_slots.front ().strength == Catch::Approx (prior_floor));
316+
REQUIRE (pctx.wm_slots.front ().strength_ts == Catch::Approx (2.0));
317+
REQUIRE_FALSE (pctx.wm_slots.front ().metadata_dirty);
249318
}
250319

251320
TEST_CASE ("Alg24 uses Focus-derived gate_threshold for gating decision",

0 commit comments

Comments
 (0)