stackmem 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,816 @@
1
+ #!/usr/bin/env tsx
2
+ // Benchmark suite for stackmem's scoring algorithms, run against real
3
+ // Supabase data (not fixtures). Two Supabase queries total — one for all
4
+ // memories, one for all memory_links — everything else below is derived
5
+ // from those two result sets in memory, so re-running this doesn't hammer
6
+ // the backend. Run with: npx tsx src/benchmark.ts
7
+ import { execSync } from "node:child_process";
8
+ import nodeFs from "node:fs";
9
+ import nodePath from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import {
12
+ applyContextBudget,
13
+ computeDecayScore,
14
+ decideSaveAction,
15
+ estimateTokens,
16
+ extractEntities,
17
+ hybridScore,
18
+ scoreMemoryRelevance,
19
+ type ExistingMemory,
20
+ } from "./scoring.js";
21
+ import { supabase } from "./storage.js";
22
+
23
+ const CONTEXT_BUDGET_TOKENS = 2000;
24
+ const REPO_ROOT = nodePath.join(nodePath.dirname(fileURLToPath(import.meta.url)), "..");
25
+
26
+ // Literal content prefixes the git hook template (buildHookLogicScript in
27
+ // src/cli.ts) generates. Any memory starting with one of these was
28
+ // unambiguously auto-captured, regardless of who ran the commit. Shared by
29
+ // section 4 (template-collision false positives) and section 6 (automation
30
+ // rate / captured-category listing).
31
+ const HOOK_TEMPLATE_PREFIXES = [
32
+ { label: "new auth-related file → discovery", prefix: "new auth-related file:" },
33
+ { label: "new config file → constraint", prefix: "new config:" },
34
+ { label: "schema change → discovery", prefix: "schema change:" },
35
+ { label: "removed dependency → rejection", prefix: "removed dependency:" },
36
+ { label: "added dependency → decision", prefix: "added dependency:" },
37
+ { label: "database change → discovery", prefix: "database change:" },
38
+ { label: "open TODO/FIXME/HACK/XXX comment → discovery", prefix: "open issue from commit:" },
39
+ ];
40
+
41
+ interface MemoryRow {
42
+ id: string;
43
+ project: string;
44
+ type: string;
45
+ content: string;
46
+ resolved: boolean;
47
+ access_count: number | null;
48
+ created_at: string;
49
+ }
50
+
51
+ interface MemoryLinkRow {
52
+ source_id: string;
53
+ target_id: string;
54
+ score: number;
55
+ }
56
+
57
+ // --- deterministic sampling -------------------------------------------------
58
+
59
+ // A "publishable" benchmark should give the same numbers on every run, but
60
+ // the duplicate/contradiction tests below need a varied cross-section of
61
+ // real content rather than always the first N rows. A seeded PRNG gets
62
+ // both: varied sampling that's identical every time this file is run.
63
+ const BENCHMARK_SEED = 1337;
64
+
65
+ function seededRandom(seed: number): () => number {
66
+ let state = seed | 0;
67
+ return () => {
68
+ state = (state + 0x6d2b79f5) | 0;
69
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
70
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
71
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
72
+ };
73
+ }
74
+
75
+ function seededShuffle<T>(items: T[], seed: number): T[] {
76
+ const rng = seededRandom(seed);
77
+ const copy = [...items];
78
+ for (let i = copy.length - 1; i > 0; i--) {
79
+ const j = Math.floor(rng() * (i + 1));
80
+ [copy[i], copy[j]] = [copy[j], copy[i]];
81
+ }
82
+ return copy;
83
+ }
84
+
85
+ function generateAllPairs<T>(items: T[]): [T, T][] {
86
+ const pairs: [T, T][] = [];
87
+ for (let i = 0; i < items.length; i++) {
88
+ for (let j = i + 1; j < items.length; j++) {
89
+ pairs.push([items[i], items[j]]);
90
+ }
91
+ }
92
+ return pairs;
93
+ }
94
+
95
+ function pairKey(a: string, b: string): string {
96
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
97
+ }
98
+
99
+ // --- shared data fetch -------------------------------------------------------
100
+
101
+ async function fetchAllMemories(): Promise<MemoryRow[]> {
102
+ const { data, error } = await supabase.from("memories").select("*");
103
+ if (error) throw new Error(`Failed to fetch memories: ${error.message}`);
104
+ return data ?? [];
105
+ }
106
+
107
+ async function fetchAllLinks(): Promise<MemoryLinkRow[]> {
108
+ const { data, error } = await supabase.from("memory_links").select("source_id, target_id, score");
109
+ if (error) throw new Error(`Failed to fetch memory_links: ${error.message}`);
110
+ return data ?? [];
111
+ }
112
+
113
+ // --- shared: cm start's exact injection pipeline ------------------------------
114
+
115
+ // Mirrors cmdStart's exact pipeline (decay-sort, then applyContextBudget) so
116
+ // anything built on this measures the real injection path, not an idealized
117
+ // one. Shared by section 0 (stackmem's token count there must match section
118
+ // 1's, since they're both "what cm start actually injects for this project")
119
+ // and section 1 itself.
120
+ function getBudgetedMemoriesForProject(allMemories: MemoryRow[], project: string): MemoryRow[] {
121
+ const rows = allMemories.filter((m) => m.project === project && !m.resolved);
122
+ const scored = rows
123
+ .map((m) => ({
124
+ ...m,
125
+ decay_score: computeDecayScore(new Date(m.created_at), m.access_count ?? 0),
126
+ }))
127
+ .sort((a, b) => b.decay_score - a.decay_score);
128
+ return applyContextBudget(scored, CONTEXT_BUDGET_TOKENS);
129
+ }
130
+
131
+ // --- 0. comparative baseline --------------------------------------------------
132
+
133
+ const COMPARATIVE_PROJECT = "coding-memory";
134
+ const EXCLUDED_SCAN_DIRS = new Set(["node_modules", "dist", ".git"]);
135
+
136
+ function listTsFiles(dir: string): string[] {
137
+ const results: string[] = [];
138
+ for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
139
+ if (EXCLUDED_SCAN_DIRS.has(entry.name)) continue;
140
+ const fullPath = nodePath.join(dir, entry.name);
141
+ if (entry.isDirectory()) results.push(...listTsFiles(fullPath));
142
+ else if (entry.isFile() && entry.name.endsWith(".ts")) results.push(fullPath);
143
+ }
144
+ return results;
145
+ }
146
+
147
+ interface FullDumpResult {
148
+ filesScanned: number;
149
+ totalTokens: number;
150
+ }
151
+
152
+ // What a developer would actually paste if they tried to hand-feed Claude
153
+ // the whole codebase for context, instead of using stackmem.
154
+ function benchmarkFullCodebaseDump(): FullDumpResult {
155
+ const files = listTsFiles(REPO_ROOT);
156
+ const totalTokens = files.reduce(
157
+ (sum, file) => sum + estimateTokens(nodeFs.readFileSync(file, "utf-8")),
158
+ 0
159
+ );
160
+ return { filesScanned: files.length, totalTokens };
161
+ }
162
+
163
+ interface KeyFactsCoverage {
164
+ techStack: boolean;
165
+ activeConstraints: boolean;
166
+ recentDecisions: boolean;
167
+ knownFixes: boolean;
168
+ }
169
+
170
+ function computeKeyFactsCoverage(budgeted: MemoryRow[]): KeyFactsCoverage {
171
+ return {
172
+ techStack: budgeted.some((m) => /^tech stack:/i.test(m.content)),
173
+ activeConstraints: budgeted.some((m) => m.type === "constraint"),
174
+ recentDecisions: budgeted.some((m) => m.type === "decision"),
175
+ // cmdStart (src/cli.ts) only ever selects from the memories table —
176
+ // execution_log is never part of what `cm start` injects, so this is
177
+ // categorically false here regardless of how many real fixes exist in
178
+ // execution_log, not a sampling artifact of this benchmark.
179
+ knownFixes: false,
180
+ };
181
+ }
182
+
183
+ interface ComparativeBaselineResult {
184
+ approachA: { tokens: number; coveragePct: number };
185
+ approachB: FullDumpResult;
186
+ approachC: {
187
+ tokens: number;
188
+ coverage: KeyFactsCoverage;
189
+ factsCovered: number;
190
+ coveragePct: number;
191
+ };
192
+ }
193
+
194
+ function benchmarkComparativeBaseline(allMemories: MemoryRow[]): ComparativeBaselineResult {
195
+ const dump = benchmarkFullCodebaseDump();
196
+
197
+ const budgeted = getBudgetedMemoriesForProject(allMemories, COMPARATIVE_PROJECT);
198
+ const tokens = budgeted.reduce((sum, m) => sum + estimateTokens(m.content), 0);
199
+ const coverage = computeKeyFactsCoverage(budgeted);
200
+ const factsCovered = Object.values(coverage).filter(Boolean).length;
201
+
202
+ return {
203
+ approachA: { tokens: 0, coveragePct: 0 },
204
+ approachB: dump,
205
+ approachC: { tokens, coverage, factsCovered, coveragePct: factsCovered / 4 },
206
+ };
207
+ }
208
+
209
+ // --- 1. token efficiency -----------------------------------------------------
210
+
211
+ interface TokenEfficiencyResult {
212
+ perProject: { project: string; rawTokens: number; injectedTokens: number }[];
213
+ totalRaw: number;
214
+ totalInjected: number;
215
+ }
216
+
217
+ function benchmarkTokenEfficiency(allMemories: MemoryRow[]): TokenEfficiencyResult {
218
+ const unresolved = allMemories.filter((m) => !m.resolved);
219
+ const projects = [...new Set(unresolved.map((m) => m.project))];
220
+
221
+ const perProject: TokenEfficiencyResult["perProject"] = [];
222
+ let totalRaw = 0;
223
+ let totalInjected = 0;
224
+
225
+ for (const project of projects) {
226
+ const rows = unresolved.filter((m) => m.project === project);
227
+ const rawTokens = rows.reduce((sum, m) => sum + estimateTokens(m.content), 0);
228
+
229
+ const budgeted = getBudgetedMemoriesForProject(allMemories, project);
230
+ const injectedTokens = budgeted.reduce((sum, m) => sum + estimateTokens(m.content), 0);
231
+
232
+ perProject.push({ project, rawTokens, injectedTokens });
233
+ totalRaw += rawTokens;
234
+ totalInjected += injectedTokens;
235
+ }
236
+
237
+ perProject.sort((a, b) => b.rawTokens - a.rawTokens);
238
+ return { perProject, totalRaw, totalInjected };
239
+ }
240
+
241
+ // --- 2. context relevance (precision@5) --------------------------------------
242
+
243
+ const CONTEXT_RELEVANCE_PROJECT = "coding-memory";
244
+
245
+ interface ContextTask {
246
+ description: string;
247
+ // Manually reviewed against the live coding-memory dataset on 2026-10-11:
248
+ // the memory ids genuinely relevant to this task's intent, not just
249
+ // lexically similar. An empty list means no memory in the current
250
+ // dataset actually covers that task — a real retrieval gap, not a bug in
251
+ // this harness. Re-curate these if the memory set changes significantly.
252
+ relevantIds: string[];
253
+ }
254
+
255
+ const CONTEXT_TASKS: ContextTask[] = [
256
+ {
257
+ description: "setting up npm publishing and versioning",
258
+ relevantIds: ["a5aab9dc-49b6-4bbc-aac2-5e65ace3e4ac"], // hosted Supabase backend, npm publish as stackmem
259
+ },
260
+ {
261
+ description: "fixing git hook to prevent recursion",
262
+ // No memory documents the [skip ci] recursion guard: the commit that
263
+ // introduced it used a "test:" prefix, which the quality filter drops.
264
+ relevantIds: [],
265
+ },
266
+ {
267
+ description: "adding Supabase database storage",
268
+ relevantIds: ["a5aab9dc-49b6-4bbc-aac2-5e65ace3e4ac"], // hosted Supabase backend, npm publish as stackmem
269
+ },
270
+ {
271
+ description: "implementing decay scoring algorithm",
272
+ // No memory documents computeDecayScore's implementation itself.
273
+ relevantIds: [],
274
+ },
275
+ {
276
+ description: "building cursor and windsurf support",
277
+ relevantIds: ["86006f9a-159a-4d88-bc94-018edc32cbf4"], // write .cursorrules and .windsurfrules on cm init
278
+ },
279
+ ];
280
+
281
+ interface ContextRelevanceTaskResult {
282
+ task: string;
283
+ hits: number;
284
+ precisionAt5: number;
285
+ precisionAt1: number;
286
+ }
287
+
288
+ function benchmarkContextRelevance(allMemories: MemoryRow[]): {
289
+ results: ContextRelevanceTaskResult[];
290
+ average: number;
291
+ } {
292
+ const pool = allMemories.filter((m) => m.project === CONTEXT_RELEVANCE_PROJECT && !m.resolved);
293
+
294
+ const results = CONTEXT_TASKS.map((task) => {
295
+ const ranked = pool
296
+ .map((m) => ({ ...m, relevance: scoreMemoryRelevance(m.content, task.description) }))
297
+ .sort((a, b) => b.relevance - a.relevance);
298
+
299
+ const top5 = ranked.slice(0, 5);
300
+ const relevantSet = new Set(task.relevantIds);
301
+ const hits = top5.filter((m) => relevantSet.has(m.id)).length;
302
+ const top1Hit = ranked.length > 0 && relevantSet.has(ranked[0].id);
303
+
304
+ return {
305
+ task: task.description,
306
+ hits,
307
+ precisionAt5: hits / 5,
308
+ precisionAt1: top1Hit ? 1 : 0,
309
+ };
310
+ });
311
+
312
+ const average = results.reduce((sum, r) => sum + r.precisionAt5, 0) / results.length;
313
+ return { results, average };
314
+ }
315
+
316
+ // --- 3. duplicate detection accuracy ------------------------------------------
317
+
318
+ interface ClassificationResult {
319
+ tp: number;
320
+ fp: number;
321
+ tn: number;
322
+ fn: number;
323
+ precision: number;
324
+ recall: number;
325
+ f1: number;
326
+ }
327
+
328
+ interface DuplicateDetectionResult extends ClassificationResult {
329
+ // FNs split by root cause, since "miss" alone doesn't explain why: a
330
+ // self-pair can fail to register as a duplicate either because the
331
+ // negation-word carve-out misfires (content like "removed dependency: X"
332
+ // reads as a supersede of itself) or because the content has no
333
+ // extractable entities at all (e.g. "wip" — every word under the 5-char
334
+ // minimum), leaving containment permanently at 0.
335
+ fnViaNegationCarveout: number;
336
+ fnViaNoExtractableEntities: number;
337
+ }
338
+
339
+ function scoreClassification(tp: number, fp: number, tn: number, fn: number): ClassificationResult {
340
+ const precision = tp + fp === 0 ? 0 : tp / (tp + fp);
341
+ const recall = tp + fn === 0 ? 0 : tp / (tp + fn);
342
+ const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
343
+ return { tp, fp, tn, fn, precision, recall, f1 };
344
+ }
345
+
346
+ const SAMPLE_SIZE = 20;
347
+
348
+ // The system refuses to persist true duplicate rows in the first place
349
+ // (decideSaveAction skips the insert), so there's no "same content saved
350
+ // twice" to mine directly from the table. The faithful equivalent is
351
+ // re-submitting a real memory's exact content as if saving it again —
352
+ // literally the scenario the duplicate check exists to catch.
353
+ function benchmarkDuplicateDetection(
354
+ allMemories: MemoryRow[],
355
+ unrelatedPairs: [MemoryRow, MemoryRow][]
356
+ ): DuplicateDetectionResult {
357
+ const positiveSample = seededShuffle(allMemories, BENCHMARK_SEED + 1).slice(0, SAMPLE_SIZE);
358
+
359
+ let tp = 0;
360
+ let fn = 0;
361
+ let fnViaNegationCarveout = 0;
362
+ let fnViaNoExtractableEntities = 0;
363
+ for (const memory of positiveSample) {
364
+ const existing: ExistingMemory[] = [{ id: memory.id, content: memory.content }];
365
+ const result = decideSaveAction(memory.content, existing);
366
+ if (result.action === "duplicate") {
367
+ tp++;
368
+ } else {
369
+ fn++;
370
+ if (result.action === "superseded") fnViaNegationCarveout++;
371
+ else if (extractEntities(memory.content).size === 0) fnViaNoExtractableEntities++;
372
+ }
373
+ }
374
+
375
+ let tn = 0;
376
+ let fp = 0;
377
+ for (const [a, b] of unrelatedPairs) {
378
+ const existing: ExistingMemory[] = [{ id: b.id, content: b.content }];
379
+ const result = decideSaveAction(a.content, existing);
380
+ if (result.action === "duplicate") fp++;
381
+ else tn++;
382
+ }
383
+
384
+ return { ...scoreClassification(tp, fp, tn, fn), fnViaNegationCarveout, fnViaNoExtractableEntities };
385
+ }
386
+
387
+ // --- 4. contradiction detection accuracy --------------------------------------
388
+
389
+ // Prepending to the full original content guarantees the new entity set is
390
+ // a superset of the old one, so containment is ~1.0 by construction — this
391
+ // isolates whether the negation-word carve-out in decideSaveAction fires at
392
+ // all, not whether it can detect partial-overlap contradictions (e.g.
393
+ // "switched from Postgres to Supabase" against "using Postgres"), which is
394
+ // a harder, unverified case this benchmark doesn't cover.
395
+ const CONTRADICTION_PREFIXES = ["switched from ", "no longer using "];
396
+
397
+ interface ContradictionDetectionResult extends ClassificationResult {
398
+ // Both unrelated memories sharing a short template ("removed dependency:
399
+ // X" / "removed dependency: Y") push containment over CONTRADICTION_MIN
400
+ // on template words alone, even though the actual payloads (X vs Y) are
401
+ // unrelated — a template-collision false positive, distinct from any
402
+ // other way the negation carve-out could misfire.
403
+ fpViaTemplateCollision: number;
404
+ }
405
+
406
+ function benchmarkContradictionDetection(
407
+ allMemories: MemoryRow[],
408
+ unrelatedPairs: [MemoryRow, MemoryRow][]
409
+ ): ContradictionDetectionResult {
410
+ // Hook-generated templates (dependency changes, config/schema/TODO
411
+ // discoveries) are structural event records, not decisions — decideSaveAction
412
+ // deliberately exempts dependency templates from ever routing to
413
+ // "superseded" (see DEPENDENCY_TEMPLATE_RE in scoring.ts), and prepending
414
+ // "switched from X" to any of these templates isn't a phrase anyone would
415
+ // actually write. Sampling them here would just measure whether that
416
+ // exemption holds, not whether contradiction detection works — so they're
417
+ // excluded, keeping this test on genuine decision/discovery-style content.
418
+ const contradictableMemories = allMemories.filter(
419
+ (m) => !HOOK_TEMPLATE_PREFIXES.some((p) => m.content.startsWith(p.prefix))
420
+ );
421
+ const baseSample = seededShuffle(contradictableMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
422
+
423
+ let tp = 0;
424
+ let fn = 0;
425
+ for (const memory of baseSample) {
426
+ for (const prefix of CONTRADICTION_PREFIXES) {
427
+ const existing: ExistingMemory[] = [{ id: memory.id, content: memory.content }];
428
+ const result = decideSaveAction(`${prefix}${memory.content}`, existing);
429
+ if (result.action === "superseded") tp++;
430
+ else fn++;
431
+ }
432
+ }
433
+
434
+ // Reuses the same unrelated-pair sample from section 3 as the negative
435
+ // set: if two unrelated memories get flagged "superseded" against each
436
+ // other, that's a false positive here just as "duplicate" was there.
437
+ let tn = 0;
438
+ let fp = 0;
439
+ let fpViaTemplateCollision = 0;
440
+ for (const [a, b] of unrelatedPairs) {
441
+ const existing: ExistingMemory[] = [{ id: b.id, content: b.content }];
442
+ const result = decideSaveAction(a.content, existing);
443
+ if (result.action === "superseded") {
444
+ fp++;
445
+ const sharedPrefix = HOOK_TEMPLATE_PREFIXES.find(
446
+ (p) => a.content.startsWith(p.prefix) && b.content.startsWith(p.prefix)
447
+ );
448
+ if (sharedPrefix) fpViaTemplateCollision++;
449
+ } else {
450
+ tn++;
451
+ }
452
+ }
453
+
454
+ return { ...scoreClassification(tp, fp, tn, fn), fpViaTemplateCollision };
455
+ }
456
+
457
+ // --- 5. semantic linking quality ----------------------------------------------
458
+
459
+ interface SemanticLinkingResult {
460
+ avgLinkScore: number;
461
+ avgRandomPairScore: number;
462
+ signalToNoiseRatio: number;
463
+ linkCount: number;
464
+ baselinePairCount: number;
465
+ }
466
+
467
+ // Baseline is every same-project pair that ISN'T linked, computed
468
+ // exhaustively rather than randomly sampled — at this data size (a few
469
+ // thousand possible pairs at most) exhaustive is cheap and gives a more
470
+ // stable number than a small random draw would.
471
+ //
472
+ // CAVEAT: avgLinkScore below reads memory_links.score as stored — i.e.
473
+ // whatever computeMemoryLinks computed at save time. Rows saved before
474
+ // computeMemoryLinks switched from containmentScore to hybridScore still
475
+ // hold old-formula scores; only the baseline here is guaranteed to reflect
476
+ // the current formula (it's computed live). A mixed-vintage avgLinkScore
477
+ // vs. a current-formula baseline is still the most honest comparison
478
+ // available without backfilling every historical link.
479
+ function benchmarkSemanticLinking(allMemories: MemoryRow[], allLinks: MemoryLinkRow[]): SemanticLinkingResult {
480
+ const avgLinkScore =
481
+ allLinks.length === 0 ? 0 : allLinks.reduce((sum, l) => sum + l.score, 0) / allLinks.length;
482
+
483
+ const linkedPairKeys = new Set(allLinks.map((l) => pairKey(l.source_id, l.target_id)));
484
+
485
+ const byProject = new Map<string, MemoryRow[]>();
486
+ for (const memory of allMemories) {
487
+ if (!byProject.has(memory.project)) byProject.set(memory.project, []);
488
+ byProject.get(memory.project)!.push(memory);
489
+ }
490
+
491
+ let baselineSum = 0;
492
+ let baselineCount = 0;
493
+ for (const rows of byProject.values()) {
494
+ for (let i = 0; i < rows.length; i++) {
495
+ for (let j = i + 1; j < rows.length; j++) {
496
+ const key = pairKey(rows[i].id, rows[j].id);
497
+ if (linkedPairKeys.has(key)) continue;
498
+ baselineSum += hybridScore(rows[i].content, rows[j].content);
499
+ baselineCount++;
500
+ }
501
+ }
502
+ }
503
+
504
+ const avgRandomPairScore = baselineCount === 0 ? 0 : baselineSum / baselineCount;
505
+ const signalToNoiseRatio =
506
+ avgRandomPairScore === 0 ? (avgLinkScore > 0 ? Infinity : 0) : avgLinkScore / avgRandomPairScore;
507
+
508
+ return {
509
+ avgLinkScore,
510
+ avgRandomPairScore,
511
+ signalToNoiseRatio,
512
+ linkCount: allLinks.length,
513
+ baselinePairCount: baselineCount,
514
+ };
515
+ }
516
+
517
+ // --- 6. git hook capture rate / automation rate -------------------------------
518
+
519
+ const HOOK_PATH = nodePath.join(REPO_ROOT, ".coding-memory", "post-commit-hook.mjs");
520
+ const AUTOMATION_PROJECT = "coding-memory";
521
+
522
+ interface AutomationRateResult {
523
+ totalMemories: number;
524
+ autoCount: number;
525
+ manualCount: number;
526
+ automationRate: number;
527
+ hookFound: boolean;
528
+ capturedCategories: string[];
529
+ gitLogAvailable: boolean;
530
+ }
531
+
532
+ function benchmarkAutomationRate(allMemories: MemoryRow[]): AutomationRateResult {
533
+ const hookFound = nodeFs.existsSync(HOOK_PATH);
534
+ const hookSource = hookFound ? nodeFs.readFileSync(HOOK_PATH, "utf-8") : "";
535
+ const capturedCategories = HOOK_TEMPLATE_PREFIXES.filter((p) => hookSource.includes(p.prefix)).map(
536
+ (p) => p.label
537
+ );
538
+
539
+ // The hook's 8th capture category — the raw commit message, saved as a
540
+ // decision when it passes the quality filter — isn't a template, so it's
541
+ // indistinguishable from a manual `cm save` by content shape alone. Cross
542
+ // -reference this repo's real git history instead: if a decision's
543
+ // content exactly matches a real commit message, it was hook-captured.
544
+ let commitMessages = new Set<string>();
545
+ let gitLogAvailable = true;
546
+ try {
547
+ const log = execSync("git log --pretty=%B%x00", { cwd: REPO_ROOT, encoding: "utf-8" });
548
+ for (const message of log.split("\x00")) {
549
+ const trimmed = message.trim();
550
+ if (trimmed) commitMessages.add(trimmed);
551
+ }
552
+ } catch {
553
+ gitLogAvailable = false;
554
+ }
555
+
556
+ const projectMemories = allMemories.filter((m) => m.project === AUTOMATION_PROJECT);
557
+
558
+ let autoCount = 0;
559
+ for (const memory of projectMemories) {
560
+ const templateMatch = HOOK_TEMPLATE_PREFIXES.some((p) => memory.content.startsWith(p.prefix));
561
+ const commitMatch = memory.type === "decision" && commitMessages.has(memory.content.trim());
562
+ if (templateMatch || commitMatch) autoCount++;
563
+ }
564
+
565
+ return {
566
+ totalMemories: projectMemories.length,
567
+ autoCount,
568
+ manualCount: projectMemories.length - autoCount,
569
+ automationRate: projectMemories.length === 0 ? 0 : autoCount / projectMemories.length,
570
+ hookFound,
571
+ capturedCategories,
572
+ gitLogAvailable,
573
+ };
574
+ }
575
+
576
+ // --- report formatting ---------------------------------------------------
577
+
578
+ const DIVIDER = "═".repeat(63);
579
+
580
+ function pct(ratio: number): string {
581
+ return `${(ratio * 100).toFixed(1)}%`;
582
+ }
583
+
584
+ function pad(label: string, width = 28): string {
585
+ return label.padEnd(width);
586
+ }
587
+
588
+ function printHeader() {
589
+ console.log(DIVIDER);
590
+ console.log("STACKMEM BENCHMARK REPORT");
591
+ console.log(DIVIDER);
592
+ console.log("");
593
+ }
594
+
595
+ function printTable(headers: string[], rows: string[][]) {
596
+ const widths = headers.map((header, i) =>
597
+ Math.max(header.length, ...rows.map((row) => row[i].length))
598
+ );
599
+ const border = (left: string, mid: string, right: string) =>
600
+ left + widths.map((w) => "─".repeat(w + 2)).join(mid) + right;
601
+ const formatRow = (cells: string[]) =>
602
+ "│" + cells.map((cell, i) => ` ${cell.padEnd(widths[i])} `).join("│") + "│";
603
+
604
+ console.log(border("┌", "┬", "┐"));
605
+ console.log(formatRow(headers));
606
+ console.log(border("├", "┼", "┤"));
607
+ for (const row of rows) console.log(formatRow(row));
608
+ console.log(border("└", "┴", "┘"));
609
+ }
610
+
611
+ const KEY_FACT_LABELS: Record<keyof KeyFactsCoverage, string> = {
612
+ techStack: "tech stack",
613
+ activeConstraints: "active constraints",
614
+ recentDecisions: "recent decisions",
615
+ knownFixes: "known fixes",
616
+ };
617
+
618
+ async function main() {
619
+ const [allMemories, allLinks] = await Promise.all([fetchAllMemories(), fetchAllLinks()]);
620
+
621
+ printHeader();
622
+
623
+ // 0. Comparative baseline ----------------------------------------------------
624
+ const baseline = benchmarkComparativeBaseline(allMemories);
625
+ const subDivider = " " + "─".repeat(43);
626
+ console.log("0. COMPARATIVE BASELINE");
627
+ console.log(subDivider);
628
+ console.log(" Approach A (cold session):");
629
+ console.log(` ${pad("Tokens injected:", 20)}${baseline.approachA.tokens}`);
630
+ console.log(` ${pad("Context coverage:", 20)}${pct(baseline.approachA.coveragePct)}`);
631
+ console.log("");
632
+ console.log(" Approach B (full codebase dump):");
633
+ console.log(` ${pad("Files scanned:", 20)}${baseline.approachB.filesScanned}`);
634
+ console.log(` ${pad("Tokens required:", 20)}${baseline.approachB.totalTokens.toLocaleString()}`);
635
+ console.log(` ${pad("Context coverage:", 20)}~100% (structural)`);
636
+ console.log(" Problem: exceeds context window for");
637
+ console.log(" large projects, no semantic");
638
+ console.log(" ranking, includes noise");
639
+ console.log("");
640
+ console.log(" Approach C (stackmem):");
641
+ console.log(` ${pad("Tokens injected:", 20)}${baseline.approachC.tokens.toLocaleString()}`);
642
+ console.log(
643
+ ` ${pad("Context coverage:", 20)}${pct(baseline.approachC.coveragePct)} (semantic) — ${
644
+ baseline.approachC.factsCovered
645
+ }/4 key facts`
646
+ );
647
+ const coveredFacts = Object.entries(baseline.approachC.coverage)
648
+ .filter(([, covered]) => covered)
649
+ .map(([key]) => KEY_FACT_LABELS[key as keyof KeyFactsCoverage]);
650
+ const missingFacts = Object.entries(baseline.approachC.coverage)
651
+ .filter(([, covered]) => !covered)
652
+ .map(([key]) => KEY_FACT_LABELS[key as keyof KeyFactsCoverage]);
653
+ console.log(` covered: ${coveredFacts.length > 0 ? coveredFacts.join(", ") : "(none)"}`);
654
+ console.log(` missing: ${missingFacts.length > 0 ? missingFacts.join(", ") : "(none)"}`);
655
+ const vsFullDump =
656
+ baseline.approachB.totalTokens === 0
657
+ ? 0
658
+ : 1 - baseline.approachC.tokens / baseline.approachB.totalTokens;
659
+ console.log(` ${pad("vs full dump:", 20)}${pct(vsFullDump)} fewer tokens`);
660
+ console.log(` ${pad("vs cold session:", 20)}${baseline.approachC.factsCovered} facts injected`);
661
+ console.log(subDivider);
662
+ console.log("");
663
+
664
+ // 1. Token efficiency ------------------------------------------------------
665
+ const tokenResult = benchmarkTokenEfficiency(allMemories);
666
+ console.log("1. TOKEN EFFICIENCY");
667
+ for (const row of tokenResult.perProject) {
668
+ const saved = row.rawTokens === 0 ? 0 : 1 - row.injectedTokens / row.rawTokens;
669
+ console.log(
670
+ ` ${pad(row.project, 20)} raw ${row.rawTokens.toLocaleString().padStart(6)} injected ${row.injectedTokens
671
+ .toLocaleString()
672
+ .padStart(6)} saved ${pct(saved)}`
673
+ );
674
+ }
675
+ console.log("");
676
+ const reductionRatio = tokenResult.totalInjected === 0 ? 0 : tokenResult.totalRaw / tokenResult.totalInjected;
677
+ const tokenSavings = tokenResult.totalRaw === 0 ? 0 : 1 - tokenResult.totalInjected / tokenResult.totalRaw;
678
+ console.log(` ${pad("Raw tokens (no budget):")}${tokenResult.totalRaw.toLocaleString()}`);
679
+ console.log(` ${pad("Injected tokens (budgeted):")}${tokenResult.totalInjected.toLocaleString()}`);
680
+ console.log(` ${pad("Reduction ratio:")}${reductionRatio.toFixed(2)}x`);
681
+ console.log(` ${pad("Token savings:")}${pct(tokenSavings)}`);
682
+ if (tokenSavings === 0) {
683
+ console.log(
684
+ " (0% because every project's unresolved memories currently fit under"
685
+ );
686
+ console.log(
687
+ " the 2000-token budget on their own — the cap hasn't had to bind yet."
688
+ );
689
+ console.log(" It will show real savings once a project accumulates enough");
690
+ console.log(" live memories to exceed the budget.)");
691
+ }
692
+ console.log("");
693
+
694
+ // 2. Context relevance ------------------------------------------------------
695
+ const relevanceResult = benchmarkContextRelevance(allMemories);
696
+ console.log("2. CONTEXT RELEVANCE (Precision@5)");
697
+ relevanceResult.results.forEach((r, i) => {
698
+ console.log(` Task ${i + 1}: ${r.hits}/5 relevant (${pct(r.precisionAt5)}) — "${r.task}"`);
699
+ });
700
+ console.log("");
701
+ console.log(` ${pad("Average Precision@5:")}${pct(relevanceResult.average)}`);
702
+ const avgPrecisionAt1 =
703
+ relevanceResult.results.reduce((sum, r) => sum + r.precisionAt1, 0) / relevanceResult.results.length;
704
+ console.log(` ${pad("Average Precision@1:")}${pct(avgPrecisionAt1)}`);
705
+ console.log(
706
+ " (note: with only ~13 live memories, most tasks have at most one truly"
707
+ );
708
+ console.log(
709
+ " relevant match, so the top-ranked result is usually right even when"
710
+ );
711
+ console.log(" padding ranks 2-5 with unrelated memories drags Precision@5 down.)");
712
+ console.log("");
713
+
714
+ // Shared sample for sections 3 and 4.
715
+ const unrelatedPairs = seededShuffle(generateAllPairs(allMemories), BENCHMARK_SEED).slice(0, SAMPLE_SIZE);
716
+
717
+ // 3. Duplicate detection ------------------------------------------------------
718
+ const dupResult = benchmarkDuplicateDetection(allMemories, unrelatedPairs);
719
+ console.log("3. DUPLICATE DETECTION");
720
+ console.log(` TP: ${dupResult.tp} FP: ${dupResult.fp} TN: ${dupResult.tn} FN: ${dupResult.fn}`);
721
+ console.log(` ${pad("Precision:")}${pct(dupResult.precision)}`);
722
+ console.log(` ${pad("Recall:")}${pct(dupResult.recall)}`);
723
+ console.log(` ${pad("F1 Score:")}${pct(dupResult.f1)}`);
724
+ if (dupResult.fn > 0) {
725
+ console.log(` FN breakdown (${dupResult.fn} missed self-duplicates):`);
726
+ console.log(
727
+ ` ${dupResult.fnViaNegationCarveout} via negation-word carve-out (content like "removed dependency: X" reads as superseding itself)`
728
+ );
729
+ console.log(
730
+ ` ${dupResult.fnViaNoExtractableEntities} via no extractable entities (content under the 5-char entity minimum, e.g. "wip")`
731
+ );
732
+ }
733
+ console.log("");
734
+
735
+ // 4. Contradiction detection ------------------------------------------------
736
+ const contradictionResult = benchmarkContradictionDetection(allMemories, unrelatedPairs);
737
+ console.log("4. CONTRADICTION DETECTION");
738
+ console.log(
739
+ ` TP: ${contradictionResult.tp} FP: ${contradictionResult.fp} TN: ${contradictionResult.tn} FN: ${contradictionResult.fn}`
740
+ );
741
+ console.log(` ${pad("Precision:")}${pct(contradictionResult.precision)}`);
742
+ console.log(` ${pad("Recall:")}${pct(contradictionResult.recall)}`);
743
+ console.log(` ${pad("F1 Score:")}${pct(contradictionResult.f1)}`);
744
+ if (contradictionResult.fp > 0) {
745
+ console.log(` FP breakdown (${contradictionResult.fp} unrelated pairs flagged as contradictions):`);
746
+ console.log(
747
+ ` ${contradictionResult.fpViaTemplateCollision} via template collision (both share a hook-generated prefix like "removed dependency: X", so shared template words alone cross the contradiction threshold)`
748
+ );
749
+ }
750
+ console.log("");
751
+
752
+ // 5. Semantic linking quality -------------------------------------------------
753
+ const linkingResult = benchmarkSemanticLinking(allMemories, allLinks);
754
+ console.log("5. SEMANTIC LINKING QUALITY");
755
+ console.log(
756
+ ` ${pad("Avg link score:")}${linkingResult.avgLinkScore.toFixed(2)} (n=${linkingResult.linkCount})`
757
+ );
758
+ console.log(
759
+ ` ${pad("Avg random pair score:")}${linkingResult.avgRandomPairScore.toFixed(2)} (n=${linkingResult.baselinePairCount})`
760
+ );
761
+ console.log(
762
+ ` ${pad("Signal-to-noise ratio:")}${
763
+ Number.isFinite(linkingResult.signalToNoiseRatio) ? `${linkingResult.signalToNoiseRatio.toFixed(1)}x` : "∞"
764
+ }`
765
+ );
766
+ console.log("");
767
+
768
+ // 6. Automation rate ----------------------------------------------------------
769
+ const automationResult = benchmarkAutomationRate(allMemories);
770
+ console.log("6. AUTOMATION RATE");
771
+ if (automationResult.hookFound) {
772
+ console.log(` Hook captures ${automationResult.capturedCategories.length} categories:`);
773
+ for (const label of automationResult.capturedCategories) {
774
+ console.log(` - ${label}`);
775
+ }
776
+ console.log(" - raw commit message → decision (quality-filtered)");
777
+ } else {
778
+ console.log(` (hook file not found at ${HOOK_PATH} — category list unavailable)`);
779
+ }
780
+ if (!automationResult.gitLogAvailable) {
781
+ console.log(" (git log unavailable — commit-message decisions counted as manual)");
782
+ }
783
+ console.log("");
784
+ console.log(
785
+ ` ${pad("Auto-captured memories:")}${pct(automationResult.automationRate)} (${automationResult.autoCount}/${automationResult.totalMemories})`
786
+ );
787
+ console.log(
788
+ ` ${pad("Manually saved memories:")}${pct(1 - automationResult.automationRate)} (${automationResult.manualCount}/${automationResult.totalMemories})`
789
+ );
790
+ console.log("");
791
+
792
+ // Head-to-head comparison -----------------------------------------------
793
+ console.log("APPROACH COMPARISON");
794
+ printTable(
795
+ ["", "Cold", "Full dump", "stackmem"],
796
+ [
797
+ [
798
+ "Tokens used",
799
+ baseline.approachA.tokens.toLocaleString(),
800
+ baseline.approachB.totalTokens.toLocaleString(),
801
+ baseline.approachC.tokens.toLocaleString(),
802
+ ],
803
+ ["Key facts", "0", "~all", pct(baseline.approachC.coveragePct)],
804
+ ["Stale content", "N/A", "possible", "auto-updated"],
805
+ ["Setup required", "none", "manual", "cm init"],
806
+ ]
807
+ );
808
+ console.log("");
809
+
810
+ console.log(DIVIDER);
811
+ }
812
+
813
+ main().catch((error) => {
814
+ console.error("Benchmark failed:", error instanceof Error ? error.message : error);
815
+ process.exit(1);
816
+ });