From 90020e302e541cc59b91c73db9f46019cce372e4 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Sat, 2 May 2026 07:01:37 +0100 Subject: [PATCH 1/2] 0.5.7: origin-aware retention (compactByOrigin + SymNode opts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional discrimination between self-authored and peer-received CMBs at compaction time. Apps that benefit from retaining their own lineage chains longer than peer chatter (e.g. retrospective analysis of generation-CMB → outcome-CMB chains) configure split thresholds; existing apps see no behavioral change. MemoryStore: - new `compactByOrigin(localFreshnessMs, peerFreshnessMs)` — self entries (peerId == null) use localFreshness, peer entries use peerFreshness. Hot-descendant preservation rule unchanged (chains stay hot regardless of origin). - `compact(freshnessMs)` shimmed to call compactByOrigin with both values equal. Back-compat preserved; no behavior change for callers that don't opt in. SymNode: - new constructor opts `localRetentionSeconds` + `peerRetentionSeconds`. Each defaults to `retentionSeconds` so existing apps unaffected. - `_runRetentionPurge` (run on start + hourly) uses the origin- aware path; log line surfaces both values when they differ, keeps single-value log shape when equal (existing log-grep tooling unaffected). Tests: - shim equivalence: compactByOrigin(N, N) returns a number, same as legacy compact(N). - origin discrimination: under split thresholds (local 120s, peer 30s), a 60s-old peer entry compacts to cold while a 60s-old self entry stays hot. Validates the discrimination is the cause, not coincidence. - 18/18 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 16 +++++++++++ lib/memory-store.js | 32 +++++++++++++++++++-- lib/node.js | 27 ++++++++++++++--- package.json | 2 +- tests/memory-store.test.js | 59 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0481948..8196970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ > **Note:** Versions 0.3.26 – 0.3.55 were released as git tags without changelog entries. Changelog resumes at 0.3.56 below. +## 0.5.7 + +### Added + +- **Origin-aware retention.** `MemoryStore.compactByOrigin(localFreshnessMs, peerFreshnessMs)` lets callers move self-authored CMBs (`peerId == null`) and peer-received CMBs to cold tier on independent freshness thresholds. Useful when the agent's own lineage chains carry more retrospective value than peer chatter — apps configure local > peer freshness so their own emissions survive longer. +- **`SymNode` constructor opts `localRetentionSeconds` + `peerRetentionSeconds`.** Optional overrides of the uniform `retentionSeconds`. When omitted, both fall through to `retentionSeconds` (back-compat preserved). When set, `_runRetentionPurge` (run on start + hourly) uses the new origin-aware path and logs both values when they differ. + +### Compatibility + +- `MemoryStore.compact(freshnessMs)` is now a back-compat shim that calls `compactByOrigin(freshnessMs, freshnessMs)`. No behavioral change for callers that don't explicitly opt into origin-aware retention. +- Existing apps configured with only `retentionSeconds` see no behavioral change. The new opts are purely additive. + +### Tests + +- 2 new tests in `tests/memory-store.test.js`: shim equivalence (back-compat) + origin discrimination (peer compacts past peer cutoff while self stays hot under local cutoff). 18/18 pass. + ## 0.5.6 ### Fixed diff --git a/lib/memory-store.js b/lib/memory-store.js index c169453..a6ac6ab 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -469,14 +469,42 @@ class MemoryStore { * @returns {number} count of CMBs moved to cold tier */ compact(freshnessMs) { - const cutoff = Date.now() - freshnessMs; + return this.compactByOrigin(freshnessMs, freshnessMs); + } + + /** + * Origin-aware compaction. Locally-authored CMBs (peerId == null) + * use `localFreshnessMs`; peer-received CMBs use `peerFreshnessMs`. + * Lets callers retain self CMBs longer than peer CMBs — the agent's + * own lineage chains are higher value for retrospective analysis, + * peer CMBs go cold sooner since their value-to-this-agent is + * already encoded in the SVAF-admission decision. + * + * Callers that don't need the discrimination keep using + * `compact(freshnessMs)` — shimmed to call this with both values + * equal. Apps that benefit from the split (e.g. retain own + * lineage 90d, peer chatter 30d) configure via + * `localRetentionSeconds` + `peerRetentionSeconds` on the SymNode + * constructor. + * + * @param {number} localFreshnessMs — age threshold for self CMBs + * @param {number} peerFreshnessMs — age threshold for peer CMBs + * @returns {number} count of CMBs moved to cold tier + */ + compactByOrigin(localFreshnessMs, peerFreshnessMs) { + const now = Date.now(); + const localCutoff = now - localFreshnessMs; + const peerCutoff = now - peerFreshnessMs; let moved = 0; for (const [key, entry] of this._index.byKey) { if (entry.tier !== 'hot') continue; + // peerId is set for received CMBs, null for locally-authored. + const cutoff = entry.peerId ? peerCutoff : localCutoff; if (entry.storedAt >= cutoff) continue; // still fresh - // Keep hot if any descendant is hot + // Keep hot if any descendant is hot — preserves chains + // regardless of origin (matches `compact()` semantics). const descs = this._index.descendants(key); let hasHotDesc = false; for (const dk of descs) { diff --git a/lib/node.js b/lib/node.js index 2c54926..c3bbfc8 100644 --- a/lib/node.js +++ b/lib/node.js @@ -46,7 +46,9 @@ class SymNode extends EventEmitter { * @param {number} [opts.svafTemporalLambda=0.3] — SVAF temporal decay lambda * @param {number} [opts.svafFreshnessSeconds=1800] — SVAF freshness window * @param {object} [opts.svafFieldWeights] — per-field weight profile (See MMP v0.2.0 Section 9) - * @param {number} [opts.retentionSeconds=86400] — CMB retention period + * @param {number} [opts.retentionSeconds=86400] — uniform CMB retention period (single-value knob, applies to both self-authored and peer-received entries) + * @param {number} [opts.localRetentionSeconds] — origin-aware retention for self-authored CMBs (peerId == null). Falls back to `retentionSeconds` when omitted. + * @param {number} [opts.peerRetentionSeconds] — origin-aware retention for peer-received CMBs. Falls back to `retentionSeconds` when omitted. Apps that value depth on their own lineage chains but tolerate faster forgetting of peer chatter set local > peer. * @param {object} [opts.wakeChannel] — wake channel configuration * @param {string} [opts.relay] — WebSocket relay URL (See MMP v0.2.0 Section 4) * @param {string} [opts.relayToken] — relay authentication token @@ -84,6 +86,15 @@ class SymNode extends EventEmitter { // Retention — how long to keep CMBs in local storage (default 86400s = 24h) // Regulated domains MUST set per compliance: legal (jurisdiction), health (HIPAA 6yr), finance (MiFID II 5yr, SEC 7yr) this._retentionSeconds = opts.retentionSeconds ?? 86400; + // Origin-aware retention (optional override of the uniform value + // above). When set, self-authored CMBs (peerId == null) and peer- + // received CMBs use independent freshness thresholds during + // compaction. Useful when the app's own lineage chains are higher + // value for retrospective analysis than peer chatter. Defaults + // preserve back-compat: both fall through to `retentionSeconds` + // when not explicitly set. + this._localRetentionSeconds = opts.localRetentionSeconds ?? this._retentionSeconds; + this._peerRetentionSeconds = opts.peerRetentionSeconds ?? this._retentionSeconds; // Neural SVAF evaluator (Layer 4 cognition). See MMP v0.2.0 Section 9. this._svafEvaluator = new SVAFEvaluator({ @@ -405,11 +416,19 @@ class SymNode extends EventEmitter { } _runRetentionPurge() { - const retentionMs = this._retentionSeconds * 1000; - const compacted = this._store.compact(retentionMs); + const localMs = this._localRetentionSeconds * 1000; + const peerMs = this._peerRetentionSeconds * 1000; + const compacted = this._store.compactByOrigin(localMs, peerMs); const purged = this._store.purge(); if (compacted > 0 || purged > 0) { - this._log(`Retention purge: ${compacted} compacted, ${purged} removed (retention: ${this._retentionSeconds}s)`); + // When local == peer (the back-compat path), log the single + // value to keep existing log-grep tooling working. When they + // differ, surface both so operators can confirm the origin + // discrimination is in effect. + const retentionDesc = (this._localRetentionSeconds === this._peerRetentionSeconds) + ? `retention: ${this._retentionSeconds}s` + : `local: ${this._localRetentionSeconds}s, peer: ${this._peerRetentionSeconds}s`; + this._log(`Retention purge: ${compacted} compacted, ${purged} removed (${retentionDesc})`); } } diff --git a/package.json b/package.json index a31d0da..9651de6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sym-bot/sym", - "version": "0.5.6", + "version": "0.5.7", "description": "Infrastructure and protocol for multi-agent collective intelligence", "main": "lib/node.js", "bin": { diff --git a/tests/memory-store.test.js b/tests/memory-store.test.js index d70ec7a..7746a89 100644 --- a/tests/memory-store.test.js +++ b/tests/memory-store.test.js @@ -166,6 +166,65 @@ describe('MemoryStore', () => { assert.ok(typeof removed === 'number', 'purge should return count'); }); + it('compactByOrigin shims to compact when both freshnessMs values are equal', () => { + // Back-compat: the legacy single-value compact() now shims to + // compactByOrigin with equal local + peer thresholds. The result + // count must remain shaped like a number (existing assertions). + const isolatedDir = path.join(os.tmpdir(), `sym-test-shim-${Date.now()}`); + const isolated = new MemoryStore(isolatedDir, 'test-agent'); + try { + isolated.write('shim-test entry', { tags: ['shim'] }); + const compacted = isolated.compactByOrigin(0, 0); + assert.ok(typeof compacted === 'number', + 'compactByOrigin should return count'); + } finally { + fs.rmSync(isolatedDir, { recursive: true, force: true }); + } + }); + + it('compactByOrigin uses peer threshold for peer entries and local threshold for self entries', () => { + // Origin-aware retention: a peer entry past its peerCutoff + // compacts to cold; a self entry of the same age but within its + // localCutoff stays hot. The discrimination is the whole point of + // the API — apps that retain own lineage longer than peer chatter + // configure local > peer freshness. + const isolatedDir = path.join(os.tmpdir(), `sym-test-origin-${Date.now()}`); + const isolated = new MemoryStore(isolatedDir, 'test-agent'); + try { + // Self entry (peerId == null). + const selfEntry = isolated.write('self ancient', { tags: ['origin-test'] }); + // Peer entry, written via the receiveFromPeer path so peerId is + // populated and the entry is treated as not-self by the index. + const peerEntry = isolated.receiveFromPeer('peer-x', { + key: 'peer-ancient-key', + content: 'peer ancient', + source: 'peer-x', + tags: ['origin-test'], + }); + // Backdate both storedAt to 60 seconds ago via in-memory index + // mutation — test-only manipulation; production code never + // touches storedAt directly. + const sixtySecAgo = Date.now() - 60_000; + isolated._index.get(selfEntry.key).storedAt = sixtySecAgo; + if (peerEntry) isolated._index.get(peerEntry.key).storedAt = sixtySecAgo; + + // localFreshness = 120s (self stays hot), peerFreshness = 30s + // (peer entry is past cutoff and should compact). + const moved = isolated.compactByOrigin(120_000, 30_000); + + assert.strictEqual(moved, 1, + 'exactly one entry (peer) should compact under split thresholds'); + assert.strictEqual(isolated._index.get(selfEntry.key).tier, 'hot', + 'self entry must stay hot when within local freshness window'); + if (peerEntry) { + assert.strictEqual(isolated._index.get(peerEntry.key).tier, 'cold', + 'peer entry must compact when past peer freshness window'); + } + } finally { + fs.rmSync(isolatedDir, { recursive: true, force: true }); + } + }); + it('should receive from peer', () => { const peerEntry = { key: 'peer-mem-1', From 11327f81d83c088170f18f26ddf2c1f9d91c2ed6 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Sat, 2 May 2026 07:05:21 +0100 Subject: [PATCH 2/2] 0.5.7 follow-up: fold N13 (peerId != null) + N15 (lineage docstring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMO PR #26 review notes: N13: change `entry.peerId ? peerCutoff : localCutoff` to `entry.peerId != null ? peerCutoff : localCutoff`. Defensive against 0/''/false peerIds that shouldn't exist today but might via test fixtures or future relay edge cases. Matches the docstring's "peerId == null" wording exactly. N15: one-line docstring add to compactByOrigin noting that lineage chains are preserved across origins — a peer entry whose self- descendant is still hot stays hot regardless of peerFreshnessMs. The hot-descendant rule transcends origin discrimination by design (lineage walks must remain intact at retention boundaries); saying so explicitly prevents readers from assuming "peer entries compact at peerCutoff" is absolute. 18/18 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/memory-store.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/memory-store.js b/lib/memory-store.js index a6ac6ab..62dd76a 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -480,10 +480,15 @@ class MemoryStore { * peer CMBs go cold sooner since their value-to-this-agent is * already encoded in the SVAF-admission decision. * + * Lineage chains are preserved across origins — a peer entry whose + * self-descendant is still hot stays hot regardless of + * `peerFreshnessMs`. This matches `compact()`'s hot-descendant + * preservation rule and keeps lineage walks intact at retention + * boundaries. + * * Callers that don't need the discrimination keep using * `compact(freshnessMs)` — shimmed to call this with both values - * equal. Apps that benefit from the split (e.g. retain own - * lineage 90d, peer chatter 30d) configure via + * equal. Apps that benefit from the split configure via * `localRetentionSeconds` + `peerRetentionSeconds` on the SymNode * constructor. * @@ -500,11 +505,18 @@ class MemoryStore { for (const [key, entry] of this._index.byKey) { if (entry.tier !== 'hot') continue; // peerId is set for received CMBs, null for locally-authored. - const cutoff = entry.peerId ? peerCutoff : localCutoff; + // Use `!= null` (not truthy) so 0 / '' / false peerIds — which + // shouldn't occur today but might via test fixtures or future + // relay edge cases — are correctly classified as peer rather + // than silently mapped to self. + const cutoff = (entry.peerId != null) ? peerCutoff : localCutoff; if (entry.storedAt >= cutoff) continue; // still fresh // Keep hot if any descendant is hot — preserves chains - // regardless of origin (matches `compact()` semantics). + // regardless of origin (matches `compact()` semantics). This + // is intentional: a peer entry whose self-descendant is still + // hot stays hot regardless of `peerFreshnessMs`, so lineage + // chains aren't broken at retention boundaries. const descs = this._index.descendants(key); let hasHotDesc = false; for (const dk of descs) {