From f25a235b93d8cf19739d1943a464ce83ec1873d7 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Thu, 30 Apr 2026 18:38:29 +0800 Subject: [PATCH] fix(retention): add UTC calendar-day diversity gate to reinforceMemory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement OQ-2 decision: allow at most one reinforcement per memory identity per UTC calendar day. Same-day reinforcement is blocked regardless of session or interval. This prevents repetitive-task gaming where a daily recurring task could reach MAX_COUNT=6 in hours. Guard order: same-session → calendar-day → 1-hour → max-count (existing guards kept as defense-in-depth) 1 hour guard is redundant within same day but preserved for sub-hour edge cases. --- src/retention.ts | 13 +++++++++++++ tests/workspace-memory.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/retention.ts b/src/retention.ts index b1b6438..4f618ab 100644 --- a/src/retention.ts +++ b/src/retention.ts @@ -108,6 +108,14 @@ export function calculateEffectiveAgeDays( return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER; } +function isSameUTCCalendarDay(ts1: number, ts2: number): boolean { + const d1 = new Date(ts1); + const d2 = new Date(ts2); + return d1.getUTCFullYear() === d2.getUTCFullYear() + && d1.getUTCMonth() === d2.getUTCMonth() + && d1.getUTCDate() === d2.getUTCDate(); +} + export function reinforceMemory( memory: LongTermMemoryEntry, sessionId: string, @@ -117,6 +125,11 @@ export function reinforceMemory( return memory; } + // Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement. + if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) { + return memory; + } + if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) { return memory; } diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index dd13824..ebf9b23 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -505,6 +505,33 @@ test("reinforceMemory enforces session interval and max guards", () => { assert.equal(reinforceMemory(atMax, "session-c", now), atMax); }); +test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => { + const firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15); + const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30); + const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30); + const base: LongTermMemoryEntry = { + ...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"), + reinforcementCount: 1, + lastReinforcedAt: firstReinforcedAt, + lastReinforcedSessionID: "session-a", + }; + + assert.equal(reinforceMemory(base, "session-b", sameUtcDayMuchLater), base); + + const reinforcedNextDay = reinforceMemory(base, "session-b", nextUtcDayAfterInterval); + assert.notEqual(reinforcedNextDay, base); + assert.equal(reinforcedNextDay.reinforcementCount, 2); + assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval); + assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b"); + assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval); + + const atMax: LongTermMemoryEntry = { + ...base, + reinforcementCount: 6, + }; + assert.equal(reinforceMemory(atMax, "session-c", nextUtcDayAfterInterval), atMax); +}); + test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => { const now = Date.now(); const retained: LongTermMemoryEntry = {