Skip to content

Commit 36b78ea

Browse files
committed
feat(memory): add retention model test gaps and health diagnostics
Wave 1 - P0 Test Gaps: - Add hard stale prune removed regression test - Add dormant overlap tests (entry created during dormancy) - Add invalid timestamp NaN protection test - Add reinforcement ordering test with reference type - Add dedupe same-session/under-1hr guard tests - Fix NaN handling with Number.isFinite check Wave 2 - Helper Functions: - Add timestampMs() for safe timestamp conversion - Add isSafetyCriticalForDiag() aligned with runtime Wave 3 - Health Output Format: - Fix top rendered candidates sorted by strength (not text length) - Add stored vs rendered counts breakdown - Add type caps and global cap overflow display - Track globalCapped array explicitly - Add dormant status section Wave 4 - Monitoring Metrics: - Add high_importance_ratio (alert > 30%) - Add safety_critical_count (alert > 5) - Add max_reinforced_count (alert > 10% active) Wave 5 - Integration Fixture: - Add 34-entry over-cap test - Add mixed retention regression fixture - Test TYPE_MAX caps, safety-critical exemption, reinforcement ordering Tests: 224 → 237
1 parent 406c160 commit 36b78ea

4 files changed

Lines changed: 474 additions & 17 deletions

File tree

scripts/memory-diag.ts

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, w
1212
import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts";
1313
import { redactCredentials } from "../src/redaction.ts";
1414
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
15-
import { renderWorkspaceMemory } from "../src/workspace-memory.ts";
15+
import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts";
1616
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts";
1717
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts";
1818

@@ -65,6 +65,14 @@ type MigrationLogRecord = {
6565
};
6666

6767
const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
68+
const TYPE_MAX_FOR_DIAG: Record<LongTermType, number> = {
69+
feedback: 10,
70+
decision: 10,
71+
project: 8,
72+
reference: 6,
73+
};
74+
const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14;
75+
const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25;
6876
const SUSPICIOUS_REASONS = [
6977
"progress_snapshot",
7078
"active_file_snapshot",
@@ -229,6 +237,68 @@ function ageDays(entry: LongTermMemoryEntry): number | null {
229237
return Math.floor((Date.now() - time) / 86_400_000);
230238
}
231239

240+
function formatStrength(value: number): string {
241+
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
242+
}
243+
244+
function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
245+
if (!value) return null;
246+
const ms = new Date(value).getTime();
247+
if (!Number.isFinite(ms)) return null;
248+
return Math.max(0, (now - ms) / 86_400_000);
249+
}
250+
251+
function formatPercent(ratio: number): string {
252+
return `${(ratio * 100).toFixed(1)}%`;
253+
}
254+
255+
type RetentionDiagItem = {
256+
entry: LongTermMemoryEntry;
257+
strength: number;
258+
};
259+
260+
function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
261+
return entry.safetyCritical === true;
262+
}
263+
264+
function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
265+
sorted: RetentionDiagItem[];
266+
rendered: RetentionDiagItem[];
267+
typeCapped: RetentionDiagItem[];
268+
globalCapped: RetentionDiagItem[];
269+
} {
270+
const now = Date.now();
271+
const active = store.entries.filter(entry => entry.status !== "superseded");
272+
const sorted = active
273+
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
274+
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
275+
276+
const rendered: RetentionDiagItem[] = [];
277+
const typeCapped: RetentionDiagItem[] = [];
278+
const globalCapped: RetentionDiagItem[] = [];
279+
const typeCounts: Partial<Record<LongTermType, number>> = {};
280+
281+
for (const item of sorted) {
282+
if (!isSafetyCriticalForDiag(item.entry)) {
283+
const count = typeCounts[item.entry.type] ?? 0;
284+
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
285+
if (count >= max) {
286+
typeCapped.push(item);
287+
continue;
288+
}
289+
typeCounts[item.entry.type] = count + 1;
290+
}
291+
292+
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
293+
rendered.push(item);
294+
} else {
295+
globalCapped.push(item);
296+
}
297+
}
298+
299+
return { sorted, rendered, typeCapped, globalCapped };
300+
}
301+
232302
function promotionLimit(source: LongTermSource): number {
233303
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
234304
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
@@ -333,10 +403,13 @@ async function printWorkspaceHealth(input: {
333403

334404
const active = store.entries.filter(entry => entry.status !== "superseded");
335405
const superseded = store.entries.filter(entry => entry.status === "superseded");
406+
const retention = retentionCandidatesForDiag(store);
407+
const renderedEntries = retention.rendered.map(item => item.entry);
336408
const renderedEstimate = renderWorkspaceMemory(store).length;
337409

338-
console.log(`Active memories: ${active.length}`);
410+
console.log(`Stored active memories: ${active.length}`);
339411
console.log(`Superseded memories: ${superseded.length}`);
412+
console.log(`Rendered candidates: ${renderedEntries.length}`);
340413
console.log(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
341414
console.log("");
342415

@@ -356,12 +429,18 @@ async function printWorkspaceHealth(input: {
356429

357430
console.log("By type:");
358431
for (const type of TYPES) {
359-
const activeCount = active.filter(entry => entry.type === type).length;
432+
const storedCount = active.filter(entry => entry.type === type).length;
433+
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
360434
const supersededCount = superseded.filter(entry => entry.type === type).length;
361-
console.log(` ${type.padEnd(9)} active=${String(activeCount).padEnd(3)} superseded=${supersededCount}`);
435+
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`);
362436
}
363437
console.log("");
364438

439+
console.log("Retention caps:");
440+
console.log(` type-capped entries: ${retention.typeCapped.length}`);
441+
console.log(` global-cap overflow: ${retention.globalCapped.length}`);
442+
console.log("");
443+
365444
const olderThan30 = active.filter(entry => (ageDays(entry) ?? 0) > 30).length;
366445
const olderThan90 = active.filter(entry => (ageDays(entry) ?? 0) > 90).length;
367446
const staleMarked = active.filter(entry => {
@@ -374,6 +453,33 @@ async function printWorkspaceHealth(input: {
374453
console.log(` older than 90d: ${olderThan90}`);
375454
console.log("");
376455

456+
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt);
457+
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG;
458+
const dormantDaysPastGrace = wallDaysSinceActivity === null
459+
? 0
460+
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG);
461+
console.log("Dormancy:");
462+
console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
463+
console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
464+
console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
465+
console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
466+
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`);
467+
console.log("");
468+
469+
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
470+
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
471+
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
472+
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
473+
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
474+
const highImportanceAlert = highImportanceRatio > 0.3;
475+
const safetyCriticalAlert = safetyCriticalCount > 5;
476+
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
477+
console.log("Retention monitoring:");
478+
console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
479+
console.log(` safety_critical_count: ${safetyCriticalCount} (alert > 5)${safetyCriticalAlert ? " ALERT" : ""}`);
480+
console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
481+
console.log("");
482+
377483
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
378484
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
379485
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
@@ -400,12 +506,23 @@ async function printWorkspaceHealth(input: {
400506

401507
console.log("");
402508
console.log("Top rendered candidates:");
403-
const top = [...active].sort((a, b) => b.text.length - a.text.length).slice(0, 5);
509+
const top = retention.rendered.slice(0, 5);
404510
if (top.length === 0) {
405511
console.log(" (none)");
406512
} else {
407-
for (const entry of top) {
408-
console.log(` - [${entry.type}] ${truncate(cleanText(entry.text, input.raw))}`);
513+
for (const item of top) {
514+
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
515+
}
516+
}
517+
518+
console.log("");
519+
console.log("Weakest active memories:");
520+
const weakest = retention.sorted.slice(-5).reverse();
521+
if (weakest.length === 0) {
522+
console.log(" (none)");
523+
} else {
524+
for (const item of weakest) {
525+
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
409526
}
410527
}
411528
}

src/workspace-memory.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number
6767
return BASE_HALF_LIFE_DAYS / factor;
6868
}
6969

70+
function timestampMs(value: unknown, fallback: number): number {
71+
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
72+
return Number.isFinite(ms) ? ms : fallback;
73+
}
74+
7075
export function calculateRetentionStrength(
7176
memory: LongTermMemoryEntry,
7277
now: number,
@@ -76,14 +81,16 @@ export function calculateRetentionStrength(
7681
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
7782

7883
// Use retentionClock if available, fallback to updatedAt.
79-
const retentionStart = memory.retentionClock ?? memory.updatedAt;
80-
const createdAtMs = new Date(retentionStart).getTime();
84+
const retentionStart = Number.isFinite(memory.retentionClock)
85+
? memory.retentionClock
86+
: memory.updatedAt ?? memory.createdAt;
87+
const createdAtMs = timestampMs(retentionStart, now);
8188
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
8289

8390
// Calculate strength using exponential decay.
8491
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
8592

86-
return Math.max(0, strength);
93+
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
8794
}
8895

8996
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {

tests/memory-diag.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { execFile } from "node:child_process";
4+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
5+
import { tmpdir } from "node:os";
6+
import { dirname, join } from "node:path";
7+
import { fileURLToPath } from "node:url";
8+
import { promisify } from "node:util";
9+
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
10+
import { LONG_TERM_LIMITS } from "../src/types.ts";
11+
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
12+
13+
const execFileAsync = promisify(execFile);
14+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
15+
16+
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
17+
const now = new Date().toISOString();
18+
return {
19+
id,
20+
type,
21+
text,
22+
source: "compaction",
23+
confidence: 0.75,
24+
status: "active",
25+
createdAt: now,
26+
updatedAt: now,
27+
};
28+
}
29+
30+
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[], options: { lastActivityAt?: string; omitLastActivityAt?: boolean } = {}): Promise<void> {
31+
const key = await workspaceKey(root);
32+
const path = await workspaceMemoryPath(root);
33+
const now = new Date().toISOString();
34+
const store: WorkspaceMemoryStore = {
35+
version: 1,
36+
workspace: { root, key },
37+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
38+
entries,
39+
migrations: [],
40+
updatedAt: now,
41+
};
42+
if (!options.omitLastActivityAt) store.lastActivityAt = options.lastActivityAt ?? now;
43+
44+
await mkdir(dirname(path), { recursive: true });
45+
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
46+
}
47+
48+
async function runMemoryDiagHealth(root: string): Promise<string> {
49+
const { stdout } = await execFileAsync(process.execPath, [
50+
"--experimental-strip-types",
51+
"scripts/memory-diag.ts",
52+
"health",
53+
"--workspace",
54+
root,
55+
], { cwd: repoRoot });
56+
57+
return stdout;
58+
}
59+
60+
test("memory health reports stored vs rendered retention counts", async () => {
61+
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
62+
try {
63+
const entries: LongTermMemoryEntry[] = [
64+
...Array.from({ length: 17 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference for memory health ${i}`, "feedback")),
65+
...Array.from({ length: 11 }, (_, i) => entry(`decision-${i}`, `Unique durable decision for memory health ${i}`, "decision")),
66+
];
67+
await writeWorkspaceStore(root, entries);
68+
69+
const stdout = await runMemoryDiagHealth(root);
70+
71+
assert.match(stdout, /Stored active memories:/);
72+
assert.match(stdout, /Rendered candidates:/);
73+
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
74+
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
75+
} finally {
76+
await rm(root, { recursive: true, force: true });
77+
}
78+
});
79+
80+
test("memory health reports dormancy and retention monitoring alerts", async () => {
81+
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
82+
try {
83+
const lastActivityAt = new Date(Date.now() - 19 * 24 * 60 * 60 * 1000).toISOString();
84+
const entries = Array.from({ length: 10 }, (_, i) => ({
85+
...entry(`monitoring-${i}`, `Unique monitoring memory ${i} for retention health`, i % 2 === 0 ? "feedback" : "decision"),
86+
userImportance: i < 4 ? "high" as const : "normal" as const,
87+
safetyCritical: i < 6,
88+
reinforcementCount: i < 2 ? 6 : 0,
89+
}));
90+
await writeWorkspaceStore(root, entries, { lastActivityAt });
91+
92+
const stdout = await runMemoryDiagHealth(root);
93+
94+
assert.match(stdout, /Dormancy:/);
95+
assert.match(stdout, /wall days since activity: 19\.0/);
96+
assert.match(stdout, /dormant discount active: yes/);
97+
assert.match(stdout, /dormant days past grace: 5\.0/);
98+
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
99+
assert.match(stdout, /safety_critical_count: 6 .* ALERT/);
100+
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
101+
} finally {
102+
await rm(root, { recursive: true, force: true });
103+
}
104+
});
105+
106+
test("memory health reports global cap overflow separately from type caps", async () => {
107+
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
108+
try {
109+
const entries: LongTermMemoryEntry[] = [
110+
...Array.from({ length: 10 }, (_, i) => entry(`global-feedback-${i}`, `Unique global feedback preference ${i}`, "feedback")),
111+
...Array.from({ length: 10 }, (_, i) => entry(`global-decision-${i}`, `Unique global durable decision ${i}`, "decision")),
112+
...Array.from({ length: 8 }, (_, i) => entry(`global-project-${i}`, `Unique global project fact ${i}`, "project")),
113+
...Array.from({ length: 6 }, (_, i) => entry(`global-reference-${i}`, `Unique global reference fact ${i}`, "reference")),
114+
];
115+
await writeWorkspaceStore(root, entries);
116+
117+
const stdout = await runMemoryDiagHealth(root);
118+
119+
assert.match(stdout, /Rendered candidates: 28/);
120+
assert.match(stdout, /type-capped entries: 0/);
121+
assert.match(stdout, /global-cap overflow: 6/);
122+
} finally {
123+
await rm(root, { recursive: true, force: true });
124+
}
125+
});
126+
127+
test("memory health reports missing dormancy and non-alert monitoring defaults", async () => {
128+
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
129+
try {
130+
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
131+
132+
const stdout = await runMemoryDiagHealth(root);
133+
134+
assert.match(stdout, /lastActivityAt: \(missing\)/);
135+
assert.match(stdout, /wall days since activity: unknown/);
136+
assert.match(stdout, /dormant discount active: no/);
137+
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
138+
assert.match(stdout, /safety_critical_count: 0 \(alert > 5\)\n/);
139+
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
140+
} finally {
141+
await rm(root, { recursive: true, force: true });
142+
}
143+
});

0 commit comments

Comments
 (0)