Skip to content

Commit d569297

Browse files
committed
fix(retention): add UTC calendar-day diversity gate to reinforceMemory
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.
1 parent 4f1c034 commit d569297

2 files changed

Lines changed: 40 additions & 0 deletions

File tree

src/retention.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ export function calculateEffectiveAgeDays(
108108
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
109109
}
110110

111+
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
112+
const d1 = new Date(ts1);
113+
const d2 = new Date(ts2);
114+
return d1.getUTCFullYear() === d2.getUTCFullYear()
115+
&& d1.getUTCMonth() === d2.getUTCMonth()
116+
&& d1.getUTCDate() === d2.getUTCDate();
117+
}
118+
111119
export function reinforceMemory(
112120
memory: LongTermMemoryEntry,
113121
sessionId: string,
@@ -117,6 +125,11 @@ export function reinforceMemory(
117125
return memory;
118126
}
119127

128+
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
129+
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
130+
return memory;
131+
}
132+
120133
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
121134
return memory;
122135
}

tests/workspace-memory.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,33 @@ test("reinforceMemory enforces session interval and max guards", () => {
505505
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
506506
});
507507

508+
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
509+
const firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15);
510+
const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30);
511+
const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30);
512+
const base: LongTermMemoryEntry = {
513+
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
514+
reinforcementCount: 1,
515+
lastReinforcedAt: firstReinforcedAt,
516+
lastReinforcedSessionID: "session-a",
517+
};
518+
519+
assert.equal(reinforceMemory(base, "session-b", sameUtcDayMuchLater), base);
520+
521+
const reinforcedNextDay = reinforceMemory(base, "session-b", nextUtcDayAfterInterval);
522+
assert.notEqual(reinforcedNextDay, base);
523+
assert.equal(reinforcedNextDay.reinforcementCount, 2);
524+
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
525+
assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b");
526+
assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval);
527+
528+
const atMax: LongTermMemoryEntry = {
529+
...base,
530+
reinforcementCount: 6,
531+
};
532+
assert.equal(reinforceMemory(atMax, "session-c", nextUtcDayAfterInterval), atMax);
533+
});
534+
508535
test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => {
509536
const now = Date.now();
510537
const retained: LongTermMemoryEntry = {

0 commit comments

Comments
 (0)