stackmem 1.0.13 → 1.0.15
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.
- package/dist/benchmark.js +552 -0
- package/dist/cli.js +29 -1
- package/dist/index.js +15 -0
- package/dist/scoring.js +6 -1
- package/dist/tools.js +1 -0
- package/package.json +1 -1
- package/src/benchmark.ts +798 -0
- package/src/cli.ts +31 -1
- package/src/index.ts +18 -0
- package/src/scoring.ts +7 -1
package/src/benchmark.ts
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
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
|
+
containmentScore,
|
|
15
|
+
decideSaveAction,
|
|
16
|
+
estimateTokens,
|
|
17
|
+
extractEntities,
|
|
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
|
+
const baseSample = seededShuffle(allMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
|
|
411
|
+
|
|
412
|
+
let tp = 0;
|
|
413
|
+
let fn = 0;
|
|
414
|
+
for (const memory of baseSample) {
|
|
415
|
+
for (const prefix of CONTRADICTION_PREFIXES) {
|
|
416
|
+
const existing: ExistingMemory[] = [{ id: memory.id, content: memory.content }];
|
|
417
|
+
const result = decideSaveAction(`${prefix}${memory.content}`, existing);
|
|
418
|
+
if (result.action === "superseded") tp++;
|
|
419
|
+
else fn++;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Reuses the same unrelated-pair sample from section 3 as the negative
|
|
424
|
+
// set: if two unrelated memories get flagged "superseded" against each
|
|
425
|
+
// other, that's a false positive here just as "duplicate" was there.
|
|
426
|
+
let tn = 0;
|
|
427
|
+
let fp = 0;
|
|
428
|
+
let fpViaTemplateCollision = 0;
|
|
429
|
+
for (const [a, b] of unrelatedPairs) {
|
|
430
|
+
const existing: ExistingMemory[] = [{ id: b.id, content: b.content }];
|
|
431
|
+
const result = decideSaveAction(a.content, existing);
|
|
432
|
+
if (result.action === "superseded") {
|
|
433
|
+
fp++;
|
|
434
|
+
const sharedPrefix = HOOK_TEMPLATE_PREFIXES.find(
|
|
435
|
+
(p) => a.content.startsWith(p.prefix) && b.content.startsWith(p.prefix)
|
|
436
|
+
);
|
|
437
|
+
if (sharedPrefix) fpViaTemplateCollision++;
|
|
438
|
+
} else {
|
|
439
|
+
tn++;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { ...scoreClassification(tp, fp, tn, fn), fpViaTemplateCollision };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- 5. semantic linking quality ----------------------------------------------
|
|
447
|
+
|
|
448
|
+
interface SemanticLinkingResult {
|
|
449
|
+
avgLinkScore: number;
|
|
450
|
+
avgRandomPairScore: number;
|
|
451
|
+
signalToNoiseRatio: number;
|
|
452
|
+
linkCount: number;
|
|
453
|
+
baselinePairCount: number;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Baseline is every same-project pair that ISN'T linked, computed
|
|
457
|
+
// exhaustively rather than randomly sampled — at this data size (a few
|
|
458
|
+
// thousand possible pairs at most) exhaustive is cheap and gives a more
|
|
459
|
+
// stable number than a small random draw would.
|
|
460
|
+
function benchmarkSemanticLinking(allMemories: MemoryRow[], allLinks: MemoryLinkRow[]): SemanticLinkingResult {
|
|
461
|
+
const avgLinkScore =
|
|
462
|
+
allLinks.length === 0 ? 0 : allLinks.reduce((sum, l) => sum + l.score, 0) / allLinks.length;
|
|
463
|
+
|
|
464
|
+
const linkedPairKeys = new Set(allLinks.map((l) => pairKey(l.source_id, l.target_id)));
|
|
465
|
+
|
|
466
|
+
const byProject = new Map<string, MemoryRow[]>();
|
|
467
|
+
for (const memory of allMemories) {
|
|
468
|
+
if (!byProject.has(memory.project)) byProject.set(memory.project, []);
|
|
469
|
+
byProject.get(memory.project)!.push(memory);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let baselineSum = 0;
|
|
473
|
+
let baselineCount = 0;
|
|
474
|
+
for (const rows of byProject.values()) {
|
|
475
|
+
const entitiesById = new Map(rows.map((m) => [m.id, extractEntities(m.content)]));
|
|
476
|
+
for (let i = 0; i < rows.length; i++) {
|
|
477
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
478
|
+
const key = pairKey(rows[i].id, rows[j].id);
|
|
479
|
+
if (linkedPairKeys.has(key)) continue;
|
|
480
|
+
baselineSum += containmentScore(entitiesById.get(rows[i].id)!, entitiesById.get(rows[j].id)!);
|
|
481
|
+
baselineCount++;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const avgRandomPairScore = baselineCount === 0 ? 0 : baselineSum / baselineCount;
|
|
487
|
+
const signalToNoiseRatio =
|
|
488
|
+
avgRandomPairScore === 0 ? (avgLinkScore > 0 ? Infinity : 0) : avgLinkScore / avgRandomPairScore;
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
avgLinkScore,
|
|
492
|
+
avgRandomPairScore,
|
|
493
|
+
signalToNoiseRatio,
|
|
494
|
+
linkCount: allLinks.length,
|
|
495
|
+
baselinePairCount: baselineCount,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --- 6. git hook capture rate / automation rate -------------------------------
|
|
500
|
+
|
|
501
|
+
const HOOK_PATH = nodePath.join(REPO_ROOT, ".coding-memory", "post-commit-hook.mjs");
|
|
502
|
+
const AUTOMATION_PROJECT = "coding-memory";
|
|
503
|
+
|
|
504
|
+
interface AutomationRateResult {
|
|
505
|
+
totalMemories: number;
|
|
506
|
+
autoCount: number;
|
|
507
|
+
manualCount: number;
|
|
508
|
+
automationRate: number;
|
|
509
|
+
hookFound: boolean;
|
|
510
|
+
capturedCategories: string[];
|
|
511
|
+
gitLogAvailable: boolean;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function benchmarkAutomationRate(allMemories: MemoryRow[]): AutomationRateResult {
|
|
515
|
+
const hookFound = nodeFs.existsSync(HOOK_PATH);
|
|
516
|
+
const hookSource = hookFound ? nodeFs.readFileSync(HOOK_PATH, "utf-8") : "";
|
|
517
|
+
const capturedCategories = HOOK_TEMPLATE_PREFIXES.filter((p) => hookSource.includes(p.prefix)).map(
|
|
518
|
+
(p) => p.label
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// The hook's 8th capture category — the raw commit message, saved as a
|
|
522
|
+
// decision when it passes the quality filter — isn't a template, so it's
|
|
523
|
+
// indistinguishable from a manual `cm save` by content shape alone. Cross
|
|
524
|
+
// -reference this repo's real git history instead: if a decision's
|
|
525
|
+
// content exactly matches a real commit message, it was hook-captured.
|
|
526
|
+
let commitMessages = new Set<string>();
|
|
527
|
+
let gitLogAvailable = true;
|
|
528
|
+
try {
|
|
529
|
+
const log = execSync("git log --pretty=%B%x00", { cwd: REPO_ROOT, encoding: "utf-8" });
|
|
530
|
+
for (const message of log.split("\x00")) {
|
|
531
|
+
const trimmed = message.trim();
|
|
532
|
+
if (trimmed) commitMessages.add(trimmed);
|
|
533
|
+
}
|
|
534
|
+
} catch {
|
|
535
|
+
gitLogAvailable = false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const projectMemories = allMemories.filter((m) => m.project === AUTOMATION_PROJECT);
|
|
539
|
+
|
|
540
|
+
let autoCount = 0;
|
|
541
|
+
for (const memory of projectMemories) {
|
|
542
|
+
const templateMatch = HOOK_TEMPLATE_PREFIXES.some((p) => memory.content.startsWith(p.prefix));
|
|
543
|
+
const commitMatch = memory.type === "decision" && commitMessages.has(memory.content.trim());
|
|
544
|
+
if (templateMatch || commitMatch) autoCount++;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
totalMemories: projectMemories.length,
|
|
549
|
+
autoCount,
|
|
550
|
+
manualCount: projectMemories.length - autoCount,
|
|
551
|
+
automationRate: projectMemories.length === 0 ? 0 : autoCount / projectMemories.length,
|
|
552
|
+
hookFound,
|
|
553
|
+
capturedCategories,
|
|
554
|
+
gitLogAvailable,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- report formatting ---------------------------------------------------
|
|
559
|
+
|
|
560
|
+
const DIVIDER = "═".repeat(63);
|
|
561
|
+
|
|
562
|
+
function pct(ratio: number): string {
|
|
563
|
+
return `${(ratio * 100).toFixed(1)}%`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function pad(label: string, width = 28): string {
|
|
567
|
+
return label.padEnd(width);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function printHeader() {
|
|
571
|
+
console.log(DIVIDER);
|
|
572
|
+
console.log("STACKMEM BENCHMARK REPORT");
|
|
573
|
+
console.log(DIVIDER);
|
|
574
|
+
console.log("");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function printTable(headers: string[], rows: string[][]) {
|
|
578
|
+
const widths = headers.map((header, i) =>
|
|
579
|
+
Math.max(header.length, ...rows.map((row) => row[i].length))
|
|
580
|
+
);
|
|
581
|
+
const border = (left: string, mid: string, right: string) =>
|
|
582
|
+
left + widths.map((w) => "─".repeat(w + 2)).join(mid) + right;
|
|
583
|
+
const formatRow = (cells: string[]) =>
|
|
584
|
+
"│" + cells.map((cell, i) => ` ${cell.padEnd(widths[i])} `).join("│") + "│";
|
|
585
|
+
|
|
586
|
+
console.log(border("┌", "┬", "┐"));
|
|
587
|
+
console.log(formatRow(headers));
|
|
588
|
+
console.log(border("├", "┼", "┤"));
|
|
589
|
+
for (const row of rows) console.log(formatRow(row));
|
|
590
|
+
console.log(border("└", "┴", "┘"));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const KEY_FACT_LABELS: Record<keyof KeyFactsCoverage, string> = {
|
|
594
|
+
techStack: "tech stack",
|
|
595
|
+
activeConstraints: "active constraints",
|
|
596
|
+
recentDecisions: "recent decisions",
|
|
597
|
+
knownFixes: "known fixes",
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
async function main() {
|
|
601
|
+
const [allMemories, allLinks] = await Promise.all([fetchAllMemories(), fetchAllLinks()]);
|
|
602
|
+
|
|
603
|
+
printHeader();
|
|
604
|
+
|
|
605
|
+
// 0. Comparative baseline ----------------------------------------------------
|
|
606
|
+
const baseline = benchmarkComparativeBaseline(allMemories);
|
|
607
|
+
const subDivider = " " + "─".repeat(43);
|
|
608
|
+
console.log("0. COMPARATIVE BASELINE");
|
|
609
|
+
console.log(subDivider);
|
|
610
|
+
console.log(" Approach A (cold session):");
|
|
611
|
+
console.log(` ${pad("Tokens injected:", 20)}${baseline.approachA.tokens}`);
|
|
612
|
+
console.log(` ${pad("Context coverage:", 20)}${pct(baseline.approachA.coveragePct)}`);
|
|
613
|
+
console.log("");
|
|
614
|
+
console.log(" Approach B (full codebase dump):");
|
|
615
|
+
console.log(` ${pad("Files scanned:", 20)}${baseline.approachB.filesScanned}`);
|
|
616
|
+
console.log(` ${pad("Tokens required:", 20)}${baseline.approachB.totalTokens.toLocaleString()}`);
|
|
617
|
+
console.log(` ${pad("Context coverage:", 20)}~100% (structural)`);
|
|
618
|
+
console.log(" Problem: exceeds context window for");
|
|
619
|
+
console.log(" large projects, no semantic");
|
|
620
|
+
console.log(" ranking, includes noise");
|
|
621
|
+
console.log("");
|
|
622
|
+
console.log(" Approach C (stackmem):");
|
|
623
|
+
console.log(` ${pad("Tokens injected:", 20)}${baseline.approachC.tokens.toLocaleString()}`);
|
|
624
|
+
console.log(
|
|
625
|
+
` ${pad("Context coverage:", 20)}${pct(baseline.approachC.coveragePct)} (semantic) — ${
|
|
626
|
+
baseline.approachC.factsCovered
|
|
627
|
+
}/4 key facts`
|
|
628
|
+
);
|
|
629
|
+
const coveredFacts = Object.entries(baseline.approachC.coverage)
|
|
630
|
+
.filter(([, covered]) => covered)
|
|
631
|
+
.map(([key]) => KEY_FACT_LABELS[key as keyof KeyFactsCoverage]);
|
|
632
|
+
const missingFacts = Object.entries(baseline.approachC.coverage)
|
|
633
|
+
.filter(([, covered]) => !covered)
|
|
634
|
+
.map(([key]) => KEY_FACT_LABELS[key as keyof KeyFactsCoverage]);
|
|
635
|
+
console.log(` covered: ${coveredFacts.length > 0 ? coveredFacts.join(", ") : "(none)"}`);
|
|
636
|
+
console.log(` missing: ${missingFacts.length > 0 ? missingFacts.join(", ") : "(none)"}`);
|
|
637
|
+
const vsFullDump =
|
|
638
|
+
baseline.approachB.totalTokens === 0
|
|
639
|
+
? 0
|
|
640
|
+
: 1 - baseline.approachC.tokens / baseline.approachB.totalTokens;
|
|
641
|
+
console.log(` ${pad("vs full dump:", 20)}${pct(vsFullDump)} fewer tokens`);
|
|
642
|
+
console.log(` ${pad("vs cold session:", 20)}${baseline.approachC.factsCovered} facts injected`);
|
|
643
|
+
console.log(subDivider);
|
|
644
|
+
console.log("");
|
|
645
|
+
|
|
646
|
+
// 1. Token efficiency ------------------------------------------------------
|
|
647
|
+
const tokenResult = benchmarkTokenEfficiency(allMemories);
|
|
648
|
+
console.log("1. TOKEN EFFICIENCY");
|
|
649
|
+
for (const row of tokenResult.perProject) {
|
|
650
|
+
const saved = row.rawTokens === 0 ? 0 : 1 - row.injectedTokens / row.rawTokens;
|
|
651
|
+
console.log(
|
|
652
|
+
` ${pad(row.project, 20)} raw ${row.rawTokens.toLocaleString().padStart(6)} injected ${row.injectedTokens
|
|
653
|
+
.toLocaleString()
|
|
654
|
+
.padStart(6)} saved ${pct(saved)}`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
console.log("");
|
|
658
|
+
const reductionRatio = tokenResult.totalInjected === 0 ? 0 : tokenResult.totalRaw / tokenResult.totalInjected;
|
|
659
|
+
const tokenSavings = tokenResult.totalRaw === 0 ? 0 : 1 - tokenResult.totalInjected / tokenResult.totalRaw;
|
|
660
|
+
console.log(` ${pad("Raw tokens (no budget):")}${tokenResult.totalRaw.toLocaleString()}`);
|
|
661
|
+
console.log(` ${pad("Injected tokens (budgeted):")}${tokenResult.totalInjected.toLocaleString()}`);
|
|
662
|
+
console.log(` ${pad("Reduction ratio:")}${reductionRatio.toFixed(2)}x`);
|
|
663
|
+
console.log(` ${pad("Token savings:")}${pct(tokenSavings)}`);
|
|
664
|
+
if (tokenSavings === 0) {
|
|
665
|
+
console.log(
|
|
666
|
+
" (0% because every project's unresolved memories currently fit under"
|
|
667
|
+
);
|
|
668
|
+
console.log(
|
|
669
|
+
" the 2000-token budget on their own — the cap hasn't had to bind yet."
|
|
670
|
+
);
|
|
671
|
+
console.log(" It will show real savings once a project accumulates enough");
|
|
672
|
+
console.log(" live memories to exceed the budget.)");
|
|
673
|
+
}
|
|
674
|
+
console.log("");
|
|
675
|
+
|
|
676
|
+
// 2. Context relevance ------------------------------------------------------
|
|
677
|
+
const relevanceResult = benchmarkContextRelevance(allMemories);
|
|
678
|
+
console.log("2. CONTEXT RELEVANCE (Precision@5)");
|
|
679
|
+
relevanceResult.results.forEach((r, i) => {
|
|
680
|
+
console.log(` Task ${i + 1}: ${r.hits}/5 relevant (${pct(r.precisionAt5)}) — "${r.task}"`);
|
|
681
|
+
});
|
|
682
|
+
console.log("");
|
|
683
|
+
console.log(` ${pad("Average Precision@5:")}${pct(relevanceResult.average)}`);
|
|
684
|
+
const avgPrecisionAt1 =
|
|
685
|
+
relevanceResult.results.reduce((sum, r) => sum + r.precisionAt1, 0) / relevanceResult.results.length;
|
|
686
|
+
console.log(` ${pad("Average Precision@1:")}${pct(avgPrecisionAt1)}`);
|
|
687
|
+
console.log(
|
|
688
|
+
" (note: with only ~13 live memories, most tasks have at most one truly"
|
|
689
|
+
);
|
|
690
|
+
console.log(
|
|
691
|
+
" relevant match, so the top-ranked result is usually right even when"
|
|
692
|
+
);
|
|
693
|
+
console.log(" padding ranks 2-5 with unrelated memories drags Precision@5 down.)");
|
|
694
|
+
console.log("");
|
|
695
|
+
|
|
696
|
+
// Shared sample for sections 3 and 4.
|
|
697
|
+
const unrelatedPairs = seededShuffle(generateAllPairs(allMemories), BENCHMARK_SEED).slice(0, SAMPLE_SIZE);
|
|
698
|
+
|
|
699
|
+
// 3. Duplicate detection ------------------------------------------------------
|
|
700
|
+
const dupResult = benchmarkDuplicateDetection(allMemories, unrelatedPairs);
|
|
701
|
+
console.log("3. DUPLICATE DETECTION");
|
|
702
|
+
console.log(` TP: ${dupResult.tp} FP: ${dupResult.fp} TN: ${dupResult.tn} FN: ${dupResult.fn}`);
|
|
703
|
+
console.log(` ${pad("Precision:")}${pct(dupResult.precision)}`);
|
|
704
|
+
console.log(` ${pad("Recall:")}${pct(dupResult.recall)}`);
|
|
705
|
+
console.log(` ${pad("F1 Score:")}${pct(dupResult.f1)}`);
|
|
706
|
+
if (dupResult.fn > 0) {
|
|
707
|
+
console.log(` FN breakdown (${dupResult.fn} missed self-duplicates):`);
|
|
708
|
+
console.log(
|
|
709
|
+
` ${dupResult.fnViaNegationCarveout} via negation-word carve-out (content like "removed dependency: X" reads as superseding itself)`
|
|
710
|
+
);
|
|
711
|
+
console.log(
|
|
712
|
+
` ${dupResult.fnViaNoExtractableEntities} via no extractable entities (content under the 5-char entity minimum, e.g. "wip")`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
console.log("");
|
|
716
|
+
|
|
717
|
+
// 4. Contradiction detection ------------------------------------------------
|
|
718
|
+
const contradictionResult = benchmarkContradictionDetection(allMemories, unrelatedPairs);
|
|
719
|
+
console.log("4. CONTRADICTION DETECTION");
|
|
720
|
+
console.log(
|
|
721
|
+
` TP: ${contradictionResult.tp} FP: ${contradictionResult.fp} TN: ${contradictionResult.tn} FN: ${contradictionResult.fn}`
|
|
722
|
+
);
|
|
723
|
+
console.log(` ${pad("Precision:")}${pct(contradictionResult.precision)}`);
|
|
724
|
+
console.log(` ${pad("Recall:")}${pct(contradictionResult.recall)}`);
|
|
725
|
+
console.log(` ${pad("F1 Score:")}${pct(contradictionResult.f1)}`);
|
|
726
|
+
if (contradictionResult.fp > 0) {
|
|
727
|
+
console.log(` FP breakdown (${contradictionResult.fp} unrelated pairs flagged as contradictions):`);
|
|
728
|
+
console.log(
|
|
729
|
+
` ${contradictionResult.fpViaTemplateCollision} via template collision (both share a hook-generated prefix like "removed dependency: X", so shared template words alone cross the contradiction threshold)`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
console.log("");
|
|
733
|
+
|
|
734
|
+
// 5. Semantic linking quality -------------------------------------------------
|
|
735
|
+
const linkingResult = benchmarkSemanticLinking(allMemories, allLinks);
|
|
736
|
+
console.log("5. SEMANTIC LINKING QUALITY");
|
|
737
|
+
console.log(
|
|
738
|
+
` ${pad("Avg link score:")}${linkingResult.avgLinkScore.toFixed(2)} (n=${linkingResult.linkCount})`
|
|
739
|
+
);
|
|
740
|
+
console.log(
|
|
741
|
+
` ${pad("Avg random pair score:")}${linkingResult.avgRandomPairScore.toFixed(2)} (n=${linkingResult.baselinePairCount})`
|
|
742
|
+
);
|
|
743
|
+
console.log(
|
|
744
|
+
` ${pad("Signal-to-noise ratio:")}${
|
|
745
|
+
Number.isFinite(linkingResult.signalToNoiseRatio) ? `${linkingResult.signalToNoiseRatio.toFixed(1)}x` : "∞"
|
|
746
|
+
}`
|
|
747
|
+
);
|
|
748
|
+
console.log("");
|
|
749
|
+
|
|
750
|
+
// 6. Automation rate ----------------------------------------------------------
|
|
751
|
+
const automationResult = benchmarkAutomationRate(allMemories);
|
|
752
|
+
console.log("6. AUTOMATION RATE");
|
|
753
|
+
if (automationResult.hookFound) {
|
|
754
|
+
console.log(` Hook captures ${automationResult.capturedCategories.length} categories:`);
|
|
755
|
+
for (const label of automationResult.capturedCategories) {
|
|
756
|
+
console.log(` - ${label}`);
|
|
757
|
+
}
|
|
758
|
+
console.log(" - raw commit message → decision (quality-filtered)");
|
|
759
|
+
} else {
|
|
760
|
+
console.log(` (hook file not found at ${HOOK_PATH} — category list unavailable)`);
|
|
761
|
+
}
|
|
762
|
+
if (!automationResult.gitLogAvailable) {
|
|
763
|
+
console.log(" (git log unavailable — commit-message decisions counted as manual)");
|
|
764
|
+
}
|
|
765
|
+
console.log("");
|
|
766
|
+
console.log(
|
|
767
|
+
` ${pad("Auto-captured memories:")}${pct(automationResult.automationRate)} (${automationResult.autoCount}/${automationResult.totalMemories})`
|
|
768
|
+
);
|
|
769
|
+
console.log(
|
|
770
|
+
` ${pad("Manually saved memories:")}${pct(1 - automationResult.automationRate)} (${automationResult.manualCount}/${automationResult.totalMemories})`
|
|
771
|
+
);
|
|
772
|
+
console.log("");
|
|
773
|
+
|
|
774
|
+
// Head-to-head comparison -----------------------------------------------
|
|
775
|
+
console.log("APPROACH COMPARISON");
|
|
776
|
+
printTable(
|
|
777
|
+
["", "Cold", "Full dump", "stackmem"],
|
|
778
|
+
[
|
|
779
|
+
[
|
|
780
|
+
"Tokens used",
|
|
781
|
+
baseline.approachA.tokens.toLocaleString(),
|
|
782
|
+
baseline.approachB.totalTokens.toLocaleString(),
|
|
783
|
+
baseline.approachC.tokens.toLocaleString(),
|
|
784
|
+
],
|
|
785
|
+
["Key facts", "0", "~all", pct(baseline.approachC.coveragePct)],
|
|
786
|
+
["Stale content", "N/A", "possible", "auto-updated"],
|
|
787
|
+
["Setup required", "none", "manual", "cm init"],
|
|
788
|
+
]
|
|
789
|
+
);
|
|
790
|
+
console.log("");
|
|
791
|
+
|
|
792
|
+
console.log(DIVIDER);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
main().catch((error) => {
|
|
796
|
+
console.error("Benchmark failed:", error instanceof Error ? error.message : error);
|
|
797
|
+
process.exit(1);
|
|
798
|
+
});
|