Skip to content

Commit a37708f

Browse files
author
StackMemory Bot (CLI)
committed
feat(gc): incremental GC with importance scoring and protection rules (STA-314)
Add generational garbage collection: importance_score column, score-based eviction ordering, active frame and session protection via protectedRunIds, dedicated GC timer (60s default), and score recomputation in maintenance.
1 parent 779b34b commit a37708f

6 files changed

Lines changed: 503 additions & 16 deletions

File tree

src/core/context/frame-database.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export class FrameDatabase {
118118
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
119119
closed_at INTEGER,
120120
retention_policy TEXT DEFAULT 'default',
121+
importance_score REAL DEFAULT 0.5,
121122
FOREIGN KEY (parent_frame_id) REFERENCES frames(frame_id)
122123
);
123124
`);
@@ -160,6 +161,15 @@ export class FrameDatabase {
160161
// Column already exists — safe to ignore
161162
}
162163

164+
// Migration: add importance_score column if missing (pre-v1.3 databases)
165+
try {
166+
this.db.exec(
167+
'ALTER TABLE frames ADD COLUMN importance_score REAL DEFAULT 0.5'
168+
);
169+
} catch {
170+
// Column already exists — safe to ignore
171+
}
172+
163173
// Create indexes for performance
164174
this.db.exec(`
165175
CREATE INDEX IF NOT EXISTS idx_frames_run ON frames(run_id);
@@ -170,6 +180,7 @@ export class FrameDatabase {
170180
CREATE INDEX IF NOT EXISTS idx_frames_project_state ON frames(project_id, state);
171181
CREATE INDEX IF NOT EXISTS idx_frames_project_created ON frames(project_id, created_at DESC);
172182
CREATE INDEX IF NOT EXISTS idx_frames_retention_created ON frames(retention_policy, created_at);
183+
CREATE INDEX IF NOT EXISTS idx_frames_gc_score ON frames(state, retention_policy, importance_score ASC, created_at ASC);
173184
CREATE INDEX IF NOT EXISTS idx_events_frame ON events(frame_id);
174185
CREATE INDEX IF NOT EXISTS idx_events_seq ON events(frame_id, seq);
175186
CREATE INDEX IF NOT EXISTS idx_anchors_frame ON anchors(frame_id);

src/core/database/__tests__/gc.test.ts

Lines changed: 273 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,30 @@ describe('Garbage Collection', () => {
3030
});
3131

3232
/**
33-
* Helper: insert a frame with a specific created_at timestamp and retention_policy
33+
* Helper: insert a frame with configurable created_at, retention_policy, state, importance_score, run_id
3434
*/
3535
function insertFrame(opts: {
3636
frameId: string;
3737
createdAt: number;
3838
retentionPolicy?: string;
39+
state?: string;
40+
importanceScore?: number;
41+
runId?: string;
42+
digestText?: string | null;
3943
}): void {
4044
const db = adapter.getRawDatabase()!;
4145
db.prepare(
42-
`INSERT INTO frames (frame_id, run_id, project_id, type, name, state, depth, inputs, outputs, digest_json, created_at, retention_policy)
43-
VALUES (?, 'run-1', 'test-project', 'task', ?, 'active', 0, '{}', '{}', '{}', ?, ?)`
46+
`INSERT INTO frames (frame_id, run_id, project_id, type, name, state, depth, inputs, outputs, digest_text, digest_json, created_at, retention_policy, importance_score)
47+
VALUES (?, ?, 'test-project', 'task', ?, ?, 0, '{}', '{}', ?, '{}', ?, ?, ?)`
4448
).run(
4549
opts.frameId,
50+
opts.runId ?? 'run-1',
4651
`frame-${opts.frameId}`,
52+
opts.state ?? 'closed',
53+
opts.digestText ?? null,
4754
opts.createdAt,
48-
opts.retentionPolicy ?? 'default'
55+
opts.retentionPolicy ?? 'default',
56+
opts.importanceScore ?? 0.5
4957
);
5058
}
5159

@@ -57,12 +65,16 @@ describe('Garbage Collection', () => {
5765
).run(eventId, frameId);
5866
}
5967

60-
function insertAnchor(frameId: string, anchorId: string): void {
68+
function insertAnchor(
69+
frameId: string,
70+
anchorId: string,
71+
type: string = 'pin'
72+
): void {
6173
const db = adapter.getRawDatabase()!;
6274
db.prepare(
6375
`INSERT INTO anchors (anchor_id, frame_id, project_id, type, text, priority)
64-
VALUES (?, ?, 'test-project', 'pin', 'test anchor', 0)`
65-
).run(anchorId, frameId);
76+
VALUES (?, ?, 'test-project', ?, 'test anchor', 0)`
77+
).run(anchorId, frameId, type);
6678
}
6779

6880
function countRows(table: string): number {
@@ -74,9 +86,20 @@ describe('Garbage Collection', () => {
7486
).count;
7587
}
7688

89+
function getImportanceScore(frameId: string): number {
90+
const db = adapter.getRawDatabase()!;
91+
return (
92+
db
93+
.prepare('SELECT importance_score FROM frames WHERE frame_id = ?')
94+
.get(frameId) as { importance_score: number }
95+
).importance_score;
96+
}
97+
7798
const nowSec = Math.floor(Date.now() / 1000);
7899
const daysAgo = (d: number) => nowSec - d * 86400;
79100

101+
// --- Existing retention-based tests (updated for state='closed') ---
102+
80103
it('should delete frames older than retention period', async () => {
81104
insertFrame({ frameId: 'old-1', createdAt: daysAgo(100) });
82105
insertFrame({ frameId: 'old-2', createdAt: daysAgo(95) });
@@ -240,12 +263,11 @@ describe('Garbage Collection', () => {
240263
digest_text: 'this frame will be garbage collected',
241264
});
242265

243-
// Backdate it so it qualifies for GC
266+
// Backdate and close it so it qualifies for GC
244267
const db = adapter.getRawDatabase()!;
245-
db.prepare('UPDATE frames SET created_at = ? WHERE frame_id = ?').run(
246-
daysAgo(100),
247-
frameId
248-
);
268+
db.prepare(
269+
"UPDATE frames SET created_at = ?, state = 'closed' WHERE frame_id = ?"
270+
).run(daysAgo(100), frameId);
249271

250272
// Verify it is searchable before GC
251273
let results = await adapter.search({ query: 'searchable' });
@@ -257,4 +279,243 @@ describe('Garbage Collection', () => {
257279
results = await adapter.search({ query: 'searchable' });
258280
expect(results.length).toBe(0);
259281
});
282+
283+
// --- Protection rules ---
284+
285+
describe('Protection rules', () => {
286+
it('should not delete active frames', async () => {
287+
insertFrame({
288+
frameId: 'active-old',
289+
createdAt: daysAgo(100),
290+
state: 'active',
291+
});
292+
insertFrame({
293+
frameId: 'closed-old',
294+
createdAt: daysAgo(100),
295+
state: 'closed',
296+
});
297+
298+
const result = await adapter.runGC({ retentionDays: 90 });
299+
300+
expect(result.framesDeleted).toBe(1);
301+
const active = await adapter.getFrame('active-old');
302+
expect(active).not.toBeNull();
303+
const closed = await adapter.getFrame('closed-old');
304+
expect(closed).toBeNull();
305+
});
306+
307+
it('should not delete frames with protected run_id', async () => {
308+
insertFrame({
309+
frameId: 'protected-1',
310+
createdAt: daysAgo(100),
311+
runId: 'active-session',
312+
});
313+
insertFrame({
314+
frameId: 'unprotected-1',
315+
createdAt: daysAgo(100),
316+
runId: 'old-session',
317+
});
318+
319+
const result = await adapter.runGC({
320+
retentionDays: 90,
321+
protectedRunIds: ['active-session'],
322+
});
323+
324+
expect(result.framesDeleted).toBe(1);
325+
const kept = await adapter.getFrame('protected-1');
326+
expect(kept).not.toBeNull();
327+
const deleted = await adapter.getFrame('unprotected-1');
328+
expect(deleted).toBeNull();
329+
});
330+
331+
it('should still delete closed frames past retention when not protected', async () => {
332+
insertFrame({
333+
frameId: 'closed-expired',
334+
createdAt: daysAgo(100),
335+
state: 'closed',
336+
});
337+
338+
const result = await adapter.runGC({ retentionDays: 90 });
339+
340+
expect(result.framesDeleted).toBe(1);
341+
});
342+
});
343+
344+
// --- Score-based eviction ---
345+
346+
describe('Score-based eviction', () => {
347+
it('should evict lowest importance_score first', async () => {
348+
insertFrame({
349+
frameId: 'low-score',
350+
createdAt: daysAgo(100),
351+
importanceScore: 0.2,
352+
});
353+
insertFrame({
354+
frameId: 'mid-score',
355+
createdAt: daysAgo(100),
356+
importanceScore: 0.5,
357+
});
358+
insertFrame({
359+
frameId: 'high-score',
360+
createdAt: daysAgo(100),
361+
importanceScore: 0.8,
362+
});
363+
364+
const result = await adapter.runGC({
365+
retentionDays: 90,
366+
batchSize: 1,
367+
});
368+
369+
expect(result.framesDeleted).toBe(1);
370+
// Lowest score should be deleted first
371+
const low = await adapter.getFrame('low-score');
372+
expect(low).toBeNull();
373+
const mid = await adapter.getFrame('mid-score');
374+
expect(mid).not.toBeNull();
375+
const high = await adapter.getFrame('high-score');
376+
expect(high).not.toBeNull();
377+
});
378+
379+
it('should evict by created_at when scores are equal', async () => {
380+
insertFrame({
381+
frameId: 'older',
382+
createdAt: daysAgo(200),
383+
importanceScore: 0.3,
384+
});
385+
insertFrame({
386+
frameId: 'newer',
387+
createdAt: daysAgo(100),
388+
importanceScore: 0.3,
389+
});
390+
391+
const result = await adapter.runGC({
392+
retentionDays: 90,
393+
batchSize: 1,
394+
});
395+
396+
expect(result.framesDeleted).toBe(1);
397+
// Older frame should be deleted first when scores are equal
398+
const older = await adapter.getFrame('older');
399+
expect(older).toBeNull();
400+
const newer = await adapter.getFrame('newer');
401+
expect(newer).not.toBeNull();
402+
});
403+
});
404+
405+
// --- Importance scoring ---
406+
407+
describe('Importance scoring', () => {
408+
it('should compute base score for empty frame', async () => {
409+
insertFrame({
410+
frameId: 'empty-frame',
411+
createdAt: daysAgo(10),
412+
importanceScore: 0.5,
413+
});
414+
415+
const score = adapter.computeImportanceScore('empty-frame');
416+
// Base score: 0.3 (no anchors, no events > 3, no digest, no children, not recent)
417+
expect(score).toBe(0.3);
418+
});
419+
420+
it('should increase score for frames with DECISION anchors', async () => {
421+
insertFrame({
422+
frameId: 'decision-frame',
423+
createdAt: daysAgo(10),
424+
});
425+
insertAnchor('decision-frame', 'anc-decision', 'DECISION');
426+
427+
const score = adapter.computeImportanceScore('decision-frame');
428+
// Base 0.3 + 0.15 (DECISION) = 0.45
429+
expect(score).toBe(0.45);
430+
});
431+
432+
it('should increase score for frames with digest_text', async () => {
433+
insertFrame({
434+
frameId: 'digest-frame',
435+
createdAt: daysAgo(10),
436+
digestText: 'This frame has a digest',
437+
});
438+
439+
const score = adapter.computeImportanceScore('digest-frame');
440+
// Base 0.3 + 0.15 (digest) = 0.45
441+
expect(score).toBe(0.45);
442+
});
443+
444+
it('should increase score for frames with many events', async () => {
445+
insertFrame({
446+
frameId: 'eventful-frame',
447+
createdAt: daysAgo(10),
448+
});
449+
for (let i = 0; i < 5; i++) {
450+
insertEvent('eventful-frame', `evt-${i}`);
451+
}
452+
453+
const score = adapter.computeImportanceScore('eventful-frame');
454+
// Base 0.3 + 0.1 (events > 3) = 0.4
455+
expect(score).toBe(0.4);
456+
});
457+
458+
it('should increase score for recent frames', async () => {
459+
// Insert a frame less than 1 day old
460+
insertFrame({
461+
frameId: 'recent-frame',
462+
createdAt: nowSec - 3600, // 1 hour ago
463+
});
464+
465+
const score = adapter.computeImportanceScore('recent-frame');
466+
// Base 0.3 + 0.1 (recency) = 0.4
467+
expect(score).toBe(0.4);
468+
});
469+
470+
it('should cap score at 1.0', async () => {
471+
// Create a frame with all scoring factors
472+
insertFrame({
473+
frameId: 'max-frame',
474+
createdAt: nowSec - 3600, // recent
475+
digestText: 'Has digest',
476+
});
477+
insertAnchor('max-frame', 'anc-dec', 'DECISION');
478+
for (let i = 0; i < 5; i++) {
479+
insertEvent('max-frame', `evt-max-${i}`);
480+
}
481+
// Insert a child frame
482+
const db = adapter.getRawDatabase()!;
483+
db.prepare(
484+
`INSERT INTO frames (frame_id, run_id, project_id, type, name, state, depth, parent_frame_id, inputs, outputs, digest_json, created_at, retention_policy, importance_score)
485+
VALUES ('child-1', 'run-1', 'test-project', 'task', 'child', 'closed', 1, 'max-frame', '{}', '{}', '{}', ?, 'default', 0.5)`
486+
).run(nowSec);
487+
488+
const score = adapter.computeImportanceScore('max-frame');
489+
// 0.3 + 0.15 (DECISION) + 0.1 (events) + 0.15 (digest) + 0.1 (children) + 0.1 (recency) = 0.9
490+
expect(score).toBe(0.9);
491+
expect(score).toBeLessThanOrEqual(1.0);
492+
});
493+
494+
it('should recompute scores in batches', async () => {
495+
// Insert frames with default score of 0.5
496+
insertFrame({
497+
frameId: 'score-1',
498+
createdAt: daysAgo(10),
499+
importanceScore: 0.5,
500+
digestText: 'Has digest text',
501+
});
502+
insertFrame({
503+
frameId: 'score-2',
504+
createdAt: daysAgo(10),
505+
importanceScore: 0.5,
506+
});
507+
508+
const updated = adapter.recomputeImportanceScores(100);
509+
510+
// score-1 should get recomputed (0.3 + 0.15 digest = 0.45, != 0.5 so updated)
511+
// score-2 should get recomputed (0.3, != 0.5 so updated)
512+
expect(updated).toBe(2);
513+
514+
const score1 = getImportanceScore('score-1');
515+
expect(score1).toBe(0.45);
516+
517+
const score2 = getImportanceScore('score-2');
518+
expect(score2).toBe(0.3);
519+
});
520+
});
260521
});

src/core/database/database-adapter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export abstract class DatabaseAdapter {
234234
retentionDays?: number;
235235
batchSize?: number;
236236
dryRun?: boolean;
237+
protectedRunIds?: string[];
237238
}): Promise<{
238239
framesDeleted: number;
239240
eventsDeleted: number;

0 commit comments

Comments
 (0)