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.
- package/dist/benchmark.js +568 -0
- package/dist/cli.js +101 -21
- package/dist/index.js +20 -1
- package/dist/scoring.js +81 -12
- package/dist/tools.js +1 -0
- package/package.json +1 -1
- package/src/benchmark.ts +816 -0
- package/src/cli.ts +107 -20
- package/src/index.ts +24 -1
- package/src/scoring.ts +86 -13
|
@@ -0,0 +1,568 @@
|
|
|
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 { applyContextBudget, computeDecayScore, decideSaveAction, estimateTokens, extractEntities, hybridScore, scoreMemoryRelevance, } from "./scoring.js";
|
|
12
|
+
import { supabase } from "./storage.js";
|
|
13
|
+
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
14
|
+
const REPO_ROOT = nodePath.join(nodePath.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
// Literal content prefixes the git hook template (buildHookLogicScript in
|
|
16
|
+
// src/cli.ts) generates. Any memory starting with one of these was
|
|
17
|
+
// unambiguously auto-captured, regardless of who ran the commit. Shared by
|
|
18
|
+
// section 4 (template-collision false positives) and section 6 (automation
|
|
19
|
+
// rate / captured-category listing).
|
|
20
|
+
const HOOK_TEMPLATE_PREFIXES = [
|
|
21
|
+
{ label: "new auth-related file → discovery", prefix: "new auth-related file:" },
|
|
22
|
+
{ label: "new config file → constraint", prefix: "new config:" },
|
|
23
|
+
{ label: "schema change → discovery", prefix: "schema change:" },
|
|
24
|
+
{ label: "removed dependency → rejection", prefix: "removed dependency:" },
|
|
25
|
+
{ label: "added dependency → decision", prefix: "added dependency:" },
|
|
26
|
+
{ label: "database change → discovery", prefix: "database change:" },
|
|
27
|
+
{ label: "open TODO/FIXME/HACK/XXX comment → discovery", prefix: "open issue from commit:" },
|
|
28
|
+
];
|
|
29
|
+
// --- deterministic sampling -------------------------------------------------
|
|
30
|
+
// A "publishable" benchmark should give the same numbers on every run, but
|
|
31
|
+
// the duplicate/contradiction tests below need a varied cross-section of
|
|
32
|
+
// real content rather than always the first N rows. A seeded PRNG gets
|
|
33
|
+
// both: varied sampling that's identical every time this file is run.
|
|
34
|
+
const BENCHMARK_SEED = 1337;
|
|
35
|
+
function seededRandom(seed) {
|
|
36
|
+
let state = seed | 0;
|
|
37
|
+
return () => {
|
|
38
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
39
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
40
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
41
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function seededShuffle(items, seed) {
|
|
45
|
+
const rng = seededRandom(seed);
|
|
46
|
+
const copy = [...items];
|
|
47
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
48
|
+
const j = Math.floor(rng() * (i + 1));
|
|
49
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
50
|
+
}
|
|
51
|
+
return copy;
|
|
52
|
+
}
|
|
53
|
+
function generateAllPairs(items) {
|
|
54
|
+
const pairs = [];
|
|
55
|
+
for (let i = 0; i < items.length; i++) {
|
|
56
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
57
|
+
pairs.push([items[i], items[j]]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return pairs;
|
|
61
|
+
}
|
|
62
|
+
function pairKey(a, b) {
|
|
63
|
+
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
64
|
+
}
|
|
65
|
+
// --- shared data fetch -------------------------------------------------------
|
|
66
|
+
async function fetchAllMemories() {
|
|
67
|
+
const { data, error } = await supabase.from("memories").select("*");
|
|
68
|
+
if (error)
|
|
69
|
+
throw new Error(`Failed to fetch memories: ${error.message}`);
|
|
70
|
+
return data ?? [];
|
|
71
|
+
}
|
|
72
|
+
async function fetchAllLinks() {
|
|
73
|
+
const { data, error } = await supabase.from("memory_links").select("source_id, target_id, score");
|
|
74
|
+
if (error)
|
|
75
|
+
throw new Error(`Failed to fetch memory_links: ${error.message}`);
|
|
76
|
+
return data ?? [];
|
|
77
|
+
}
|
|
78
|
+
// --- shared: cm start's exact injection pipeline ------------------------------
|
|
79
|
+
// Mirrors cmdStart's exact pipeline (decay-sort, then applyContextBudget) so
|
|
80
|
+
// anything built on this measures the real injection path, not an idealized
|
|
81
|
+
// one. Shared by section 0 (stackmem's token count there must match section
|
|
82
|
+
// 1's, since they're both "what cm start actually injects for this project")
|
|
83
|
+
// and section 1 itself.
|
|
84
|
+
function getBudgetedMemoriesForProject(allMemories, project) {
|
|
85
|
+
const rows = allMemories.filter((m) => m.project === project && !m.resolved);
|
|
86
|
+
const scored = rows
|
|
87
|
+
.map((m) => ({
|
|
88
|
+
...m,
|
|
89
|
+
decay_score: computeDecayScore(new Date(m.created_at), m.access_count ?? 0),
|
|
90
|
+
}))
|
|
91
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
92
|
+
return applyContextBudget(scored, CONTEXT_BUDGET_TOKENS);
|
|
93
|
+
}
|
|
94
|
+
// --- 0. comparative baseline --------------------------------------------------
|
|
95
|
+
const COMPARATIVE_PROJECT = "coding-memory";
|
|
96
|
+
const EXCLUDED_SCAN_DIRS = new Set(["node_modules", "dist", ".git"]);
|
|
97
|
+
function listTsFiles(dir) {
|
|
98
|
+
const results = [];
|
|
99
|
+
for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
|
|
100
|
+
if (EXCLUDED_SCAN_DIRS.has(entry.name))
|
|
101
|
+
continue;
|
|
102
|
+
const fullPath = nodePath.join(dir, entry.name);
|
|
103
|
+
if (entry.isDirectory())
|
|
104
|
+
results.push(...listTsFiles(fullPath));
|
|
105
|
+
else if (entry.isFile() && entry.name.endsWith(".ts"))
|
|
106
|
+
results.push(fullPath);
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
// What a developer would actually paste if they tried to hand-feed Claude
|
|
111
|
+
// the whole codebase for context, instead of using stackmem.
|
|
112
|
+
function benchmarkFullCodebaseDump() {
|
|
113
|
+
const files = listTsFiles(REPO_ROOT);
|
|
114
|
+
const totalTokens = files.reduce((sum, file) => sum + estimateTokens(nodeFs.readFileSync(file, "utf-8")), 0);
|
|
115
|
+
return { filesScanned: files.length, totalTokens };
|
|
116
|
+
}
|
|
117
|
+
function computeKeyFactsCoverage(budgeted) {
|
|
118
|
+
return {
|
|
119
|
+
techStack: budgeted.some((m) => /^tech stack:/i.test(m.content)),
|
|
120
|
+
activeConstraints: budgeted.some((m) => m.type === "constraint"),
|
|
121
|
+
recentDecisions: budgeted.some((m) => m.type === "decision"),
|
|
122
|
+
// cmdStart (src/cli.ts) only ever selects from the memories table —
|
|
123
|
+
// execution_log is never part of what `cm start` injects, so this is
|
|
124
|
+
// categorically false here regardless of how many real fixes exist in
|
|
125
|
+
// execution_log, not a sampling artifact of this benchmark.
|
|
126
|
+
knownFixes: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function benchmarkComparativeBaseline(allMemories) {
|
|
130
|
+
const dump = benchmarkFullCodebaseDump();
|
|
131
|
+
const budgeted = getBudgetedMemoriesForProject(allMemories, COMPARATIVE_PROJECT);
|
|
132
|
+
const tokens = budgeted.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
|
133
|
+
const coverage = computeKeyFactsCoverage(budgeted);
|
|
134
|
+
const factsCovered = Object.values(coverage).filter(Boolean).length;
|
|
135
|
+
return {
|
|
136
|
+
approachA: { tokens: 0, coveragePct: 0 },
|
|
137
|
+
approachB: dump,
|
|
138
|
+
approachC: { tokens, coverage, factsCovered, coveragePct: factsCovered / 4 },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function benchmarkTokenEfficiency(allMemories) {
|
|
142
|
+
const unresolved = allMemories.filter((m) => !m.resolved);
|
|
143
|
+
const projects = [...new Set(unresolved.map((m) => m.project))];
|
|
144
|
+
const perProject = [];
|
|
145
|
+
let totalRaw = 0;
|
|
146
|
+
let totalInjected = 0;
|
|
147
|
+
for (const project of projects) {
|
|
148
|
+
const rows = unresolved.filter((m) => m.project === project);
|
|
149
|
+
const rawTokens = rows.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
|
150
|
+
const budgeted = getBudgetedMemoriesForProject(allMemories, project);
|
|
151
|
+
const injectedTokens = budgeted.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
|
152
|
+
perProject.push({ project, rawTokens, injectedTokens });
|
|
153
|
+
totalRaw += rawTokens;
|
|
154
|
+
totalInjected += injectedTokens;
|
|
155
|
+
}
|
|
156
|
+
perProject.sort((a, b) => b.rawTokens - a.rawTokens);
|
|
157
|
+
return { perProject, totalRaw, totalInjected };
|
|
158
|
+
}
|
|
159
|
+
// --- 2. context relevance (precision@5) --------------------------------------
|
|
160
|
+
const CONTEXT_RELEVANCE_PROJECT = "coding-memory";
|
|
161
|
+
const CONTEXT_TASKS = [
|
|
162
|
+
{
|
|
163
|
+
description: "setting up npm publishing and versioning",
|
|
164
|
+
relevantIds: ["a5aab9dc-49b6-4bbc-aac2-5e65ace3e4ac"], // hosted Supabase backend, npm publish as stackmem
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
description: "fixing git hook to prevent recursion",
|
|
168
|
+
// No memory documents the [skip ci] recursion guard: the commit that
|
|
169
|
+
// introduced it used a "test:" prefix, which the quality filter drops.
|
|
170
|
+
relevantIds: [],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
description: "adding Supabase database storage",
|
|
174
|
+
relevantIds: ["a5aab9dc-49b6-4bbc-aac2-5e65ace3e4ac"], // hosted Supabase backend, npm publish as stackmem
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
description: "implementing decay scoring algorithm",
|
|
178
|
+
// No memory documents computeDecayScore's implementation itself.
|
|
179
|
+
relevantIds: [],
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
description: "building cursor and windsurf support",
|
|
183
|
+
relevantIds: ["86006f9a-159a-4d88-bc94-018edc32cbf4"], // write .cursorrules and .windsurfrules on cm init
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
function benchmarkContextRelevance(allMemories) {
|
|
187
|
+
const pool = allMemories.filter((m) => m.project === CONTEXT_RELEVANCE_PROJECT && !m.resolved);
|
|
188
|
+
const results = CONTEXT_TASKS.map((task) => {
|
|
189
|
+
const ranked = pool
|
|
190
|
+
.map((m) => ({ ...m, relevance: scoreMemoryRelevance(m.content, task.description) }))
|
|
191
|
+
.sort((a, b) => b.relevance - a.relevance);
|
|
192
|
+
const top5 = ranked.slice(0, 5);
|
|
193
|
+
const relevantSet = new Set(task.relevantIds);
|
|
194
|
+
const hits = top5.filter((m) => relevantSet.has(m.id)).length;
|
|
195
|
+
const top1Hit = ranked.length > 0 && relevantSet.has(ranked[0].id);
|
|
196
|
+
return {
|
|
197
|
+
task: task.description,
|
|
198
|
+
hits,
|
|
199
|
+
precisionAt5: hits / 5,
|
|
200
|
+
precisionAt1: top1Hit ? 1 : 0,
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
const average = results.reduce((sum, r) => sum + r.precisionAt5, 0) / results.length;
|
|
204
|
+
return { results, average };
|
|
205
|
+
}
|
|
206
|
+
function scoreClassification(tp, fp, tn, fn) {
|
|
207
|
+
const precision = tp + fp === 0 ? 0 : tp / (tp + fp);
|
|
208
|
+
const recall = tp + fn === 0 ? 0 : tp / (tp + fn);
|
|
209
|
+
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
|
|
210
|
+
return { tp, fp, tn, fn, precision, recall, f1 };
|
|
211
|
+
}
|
|
212
|
+
const SAMPLE_SIZE = 20;
|
|
213
|
+
// The system refuses to persist true duplicate rows in the first place
|
|
214
|
+
// (decideSaveAction skips the insert), so there's no "same content saved
|
|
215
|
+
// twice" to mine directly from the table. The faithful equivalent is
|
|
216
|
+
// re-submitting a real memory's exact content as if saving it again —
|
|
217
|
+
// literally the scenario the duplicate check exists to catch.
|
|
218
|
+
function benchmarkDuplicateDetection(allMemories, unrelatedPairs) {
|
|
219
|
+
const positiveSample = seededShuffle(allMemories, BENCHMARK_SEED + 1).slice(0, SAMPLE_SIZE);
|
|
220
|
+
let tp = 0;
|
|
221
|
+
let fn = 0;
|
|
222
|
+
let fnViaNegationCarveout = 0;
|
|
223
|
+
let fnViaNoExtractableEntities = 0;
|
|
224
|
+
for (const memory of positiveSample) {
|
|
225
|
+
const existing = [{ id: memory.id, content: memory.content }];
|
|
226
|
+
const result = decideSaveAction(memory.content, existing);
|
|
227
|
+
if (result.action === "duplicate") {
|
|
228
|
+
tp++;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
fn++;
|
|
232
|
+
if (result.action === "superseded")
|
|
233
|
+
fnViaNegationCarveout++;
|
|
234
|
+
else if (extractEntities(memory.content).size === 0)
|
|
235
|
+
fnViaNoExtractableEntities++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let tn = 0;
|
|
239
|
+
let fp = 0;
|
|
240
|
+
for (const [a, b] of unrelatedPairs) {
|
|
241
|
+
const existing = [{ id: b.id, content: b.content }];
|
|
242
|
+
const result = decideSaveAction(a.content, existing);
|
|
243
|
+
if (result.action === "duplicate")
|
|
244
|
+
fp++;
|
|
245
|
+
else
|
|
246
|
+
tn++;
|
|
247
|
+
}
|
|
248
|
+
return { ...scoreClassification(tp, fp, tn, fn), fnViaNegationCarveout, fnViaNoExtractableEntities };
|
|
249
|
+
}
|
|
250
|
+
// --- 4. contradiction detection accuracy --------------------------------------
|
|
251
|
+
// Prepending to the full original content guarantees the new entity set is
|
|
252
|
+
// a superset of the old one, so containment is ~1.0 by construction — this
|
|
253
|
+
// isolates whether the negation-word carve-out in decideSaveAction fires at
|
|
254
|
+
// all, not whether it can detect partial-overlap contradictions (e.g.
|
|
255
|
+
// "switched from Postgres to Supabase" against "using Postgres"), which is
|
|
256
|
+
// a harder, unverified case this benchmark doesn't cover.
|
|
257
|
+
const CONTRADICTION_PREFIXES = ["switched from ", "no longer using "];
|
|
258
|
+
function benchmarkContradictionDetection(allMemories, unrelatedPairs) {
|
|
259
|
+
// Hook-generated templates (dependency changes, config/schema/TODO
|
|
260
|
+
// discoveries) are structural event records, not decisions — decideSaveAction
|
|
261
|
+
// deliberately exempts dependency templates from ever routing to
|
|
262
|
+
// "superseded" (see DEPENDENCY_TEMPLATE_RE in scoring.ts), and prepending
|
|
263
|
+
// "switched from X" to any of these templates isn't a phrase anyone would
|
|
264
|
+
// actually write. Sampling them here would just measure whether that
|
|
265
|
+
// exemption holds, not whether contradiction detection works — so they're
|
|
266
|
+
// excluded, keeping this test on genuine decision/discovery-style content.
|
|
267
|
+
const contradictableMemories = allMemories.filter((m) => !HOOK_TEMPLATE_PREFIXES.some((p) => m.content.startsWith(p.prefix)));
|
|
268
|
+
const baseSample = seededShuffle(contradictableMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
|
|
269
|
+
let tp = 0;
|
|
270
|
+
let fn = 0;
|
|
271
|
+
for (const memory of baseSample) {
|
|
272
|
+
for (const prefix of CONTRADICTION_PREFIXES) {
|
|
273
|
+
const existing = [{ id: memory.id, content: memory.content }];
|
|
274
|
+
const result = decideSaveAction(`${prefix}${memory.content}`, existing);
|
|
275
|
+
if (result.action === "superseded")
|
|
276
|
+
tp++;
|
|
277
|
+
else
|
|
278
|
+
fn++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Reuses the same unrelated-pair sample from section 3 as the negative
|
|
282
|
+
// set: if two unrelated memories get flagged "superseded" against each
|
|
283
|
+
// other, that's a false positive here just as "duplicate" was there.
|
|
284
|
+
let tn = 0;
|
|
285
|
+
let fp = 0;
|
|
286
|
+
let fpViaTemplateCollision = 0;
|
|
287
|
+
for (const [a, b] of unrelatedPairs) {
|
|
288
|
+
const existing = [{ id: b.id, content: b.content }];
|
|
289
|
+
const result = decideSaveAction(a.content, existing);
|
|
290
|
+
if (result.action === "superseded") {
|
|
291
|
+
fp++;
|
|
292
|
+
const sharedPrefix = HOOK_TEMPLATE_PREFIXES.find((p) => a.content.startsWith(p.prefix) && b.content.startsWith(p.prefix));
|
|
293
|
+
if (sharedPrefix)
|
|
294
|
+
fpViaTemplateCollision++;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
tn++;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { ...scoreClassification(tp, fp, tn, fn), fpViaTemplateCollision };
|
|
301
|
+
}
|
|
302
|
+
// Baseline is every same-project pair that ISN'T linked, computed
|
|
303
|
+
// exhaustively rather than randomly sampled — at this data size (a few
|
|
304
|
+
// thousand possible pairs at most) exhaustive is cheap and gives a more
|
|
305
|
+
// stable number than a small random draw would.
|
|
306
|
+
//
|
|
307
|
+
// CAVEAT: avgLinkScore below reads memory_links.score as stored — i.e.
|
|
308
|
+
// whatever computeMemoryLinks computed at save time. Rows saved before
|
|
309
|
+
// computeMemoryLinks switched from containmentScore to hybridScore still
|
|
310
|
+
// hold old-formula scores; only the baseline here is guaranteed to reflect
|
|
311
|
+
// the current formula (it's computed live). A mixed-vintage avgLinkScore
|
|
312
|
+
// vs. a current-formula baseline is still the most honest comparison
|
|
313
|
+
// available without backfilling every historical link.
|
|
314
|
+
function benchmarkSemanticLinking(allMemories, allLinks) {
|
|
315
|
+
const avgLinkScore = allLinks.length === 0 ? 0 : allLinks.reduce((sum, l) => sum + l.score, 0) / allLinks.length;
|
|
316
|
+
const linkedPairKeys = new Set(allLinks.map((l) => pairKey(l.source_id, l.target_id)));
|
|
317
|
+
const byProject = new Map();
|
|
318
|
+
for (const memory of allMemories) {
|
|
319
|
+
if (!byProject.has(memory.project))
|
|
320
|
+
byProject.set(memory.project, []);
|
|
321
|
+
byProject.get(memory.project).push(memory);
|
|
322
|
+
}
|
|
323
|
+
let baselineSum = 0;
|
|
324
|
+
let baselineCount = 0;
|
|
325
|
+
for (const rows of byProject.values()) {
|
|
326
|
+
for (let i = 0; i < rows.length; i++) {
|
|
327
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
328
|
+
const key = pairKey(rows[i].id, rows[j].id);
|
|
329
|
+
if (linkedPairKeys.has(key))
|
|
330
|
+
continue;
|
|
331
|
+
baselineSum += hybridScore(rows[i].content, rows[j].content);
|
|
332
|
+
baselineCount++;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const avgRandomPairScore = baselineCount === 0 ? 0 : baselineSum / baselineCount;
|
|
337
|
+
const signalToNoiseRatio = avgRandomPairScore === 0 ? (avgLinkScore > 0 ? Infinity : 0) : avgLinkScore / avgRandomPairScore;
|
|
338
|
+
return {
|
|
339
|
+
avgLinkScore,
|
|
340
|
+
avgRandomPairScore,
|
|
341
|
+
signalToNoiseRatio,
|
|
342
|
+
linkCount: allLinks.length,
|
|
343
|
+
baselinePairCount: baselineCount,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// --- 6. git hook capture rate / automation rate -------------------------------
|
|
347
|
+
const HOOK_PATH = nodePath.join(REPO_ROOT, ".coding-memory", "post-commit-hook.mjs");
|
|
348
|
+
const AUTOMATION_PROJECT = "coding-memory";
|
|
349
|
+
function benchmarkAutomationRate(allMemories) {
|
|
350
|
+
const hookFound = nodeFs.existsSync(HOOK_PATH);
|
|
351
|
+
const hookSource = hookFound ? nodeFs.readFileSync(HOOK_PATH, "utf-8") : "";
|
|
352
|
+
const capturedCategories = HOOK_TEMPLATE_PREFIXES.filter((p) => hookSource.includes(p.prefix)).map((p) => p.label);
|
|
353
|
+
// The hook's 8th capture category — the raw commit message, saved as a
|
|
354
|
+
// decision when it passes the quality filter — isn't a template, so it's
|
|
355
|
+
// indistinguishable from a manual `cm save` by content shape alone. Cross
|
|
356
|
+
// -reference this repo's real git history instead: if a decision's
|
|
357
|
+
// content exactly matches a real commit message, it was hook-captured.
|
|
358
|
+
let commitMessages = new Set();
|
|
359
|
+
let gitLogAvailable = true;
|
|
360
|
+
try {
|
|
361
|
+
const log = execSync("git log --pretty=%B%x00", { cwd: REPO_ROOT, encoding: "utf-8" });
|
|
362
|
+
for (const message of log.split("\x00")) {
|
|
363
|
+
const trimmed = message.trim();
|
|
364
|
+
if (trimmed)
|
|
365
|
+
commitMessages.add(trimmed);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
gitLogAvailable = false;
|
|
370
|
+
}
|
|
371
|
+
const projectMemories = allMemories.filter((m) => m.project === AUTOMATION_PROJECT);
|
|
372
|
+
let autoCount = 0;
|
|
373
|
+
for (const memory of projectMemories) {
|
|
374
|
+
const templateMatch = HOOK_TEMPLATE_PREFIXES.some((p) => memory.content.startsWith(p.prefix));
|
|
375
|
+
const commitMatch = memory.type === "decision" && commitMessages.has(memory.content.trim());
|
|
376
|
+
if (templateMatch || commitMatch)
|
|
377
|
+
autoCount++;
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
totalMemories: projectMemories.length,
|
|
381
|
+
autoCount,
|
|
382
|
+
manualCount: projectMemories.length - autoCount,
|
|
383
|
+
automationRate: projectMemories.length === 0 ? 0 : autoCount / projectMemories.length,
|
|
384
|
+
hookFound,
|
|
385
|
+
capturedCategories,
|
|
386
|
+
gitLogAvailable,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
// --- report formatting ---------------------------------------------------
|
|
390
|
+
const DIVIDER = "═".repeat(63);
|
|
391
|
+
function pct(ratio) {
|
|
392
|
+
return `${(ratio * 100).toFixed(1)}%`;
|
|
393
|
+
}
|
|
394
|
+
function pad(label, width = 28) {
|
|
395
|
+
return label.padEnd(width);
|
|
396
|
+
}
|
|
397
|
+
function printHeader() {
|
|
398
|
+
console.log(DIVIDER);
|
|
399
|
+
console.log("STACKMEM BENCHMARK REPORT");
|
|
400
|
+
console.log(DIVIDER);
|
|
401
|
+
console.log("");
|
|
402
|
+
}
|
|
403
|
+
function printTable(headers, rows) {
|
|
404
|
+
const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((row) => row[i].length)));
|
|
405
|
+
const border = (left, mid, right) => left + widths.map((w) => "─".repeat(w + 2)).join(mid) + right;
|
|
406
|
+
const formatRow = (cells) => "│" + cells.map((cell, i) => ` ${cell.padEnd(widths[i])} `).join("│") + "│";
|
|
407
|
+
console.log(border("┌", "┬", "┐"));
|
|
408
|
+
console.log(formatRow(headers));
|
|
409
|
+
console.log(border("├", "┼", "┤"));
|
|
410
|
+
for (const row of rows)
|
|
411
|
+
console.log(formatRow(row));
|
|
412
|
+
console.log(border("└", "┴", "┘"));
|
|
413
|
+
}
|
|
414
|
+
const KEY_FACT_LABELS = {
|
|
415
|
+
techStack: "tech stack",
|
|
416
|
+
activeConstraints: "active constraints",
|
|
417
|
+
recentDecisions: "recent decisions",
|
|
418
|
+
knownFixes: "known fixes",
|
|
419
|
+
};
|
|
420
|
+
async function main() {
|
|
421
|
+
const [allMemories, allLinks] = await Promise.all([fetchAllMemories(), fetchAllLinks()]);
|
|
422
|
+
printHeader();
|
|
423
|
+
// 0. Comparative baseline ----------------------------------------------------
|
|
424
|
+
const baseline = benchmarkComparativeBaseline(allMemories);
|
|
425
|
+
const subDivider = " " + "─".repeat(43);
|
|
426
|
+
console.log("0. COMPARATIVE BASELINE");
|
|
427
|
+
console.log(subDivider);
|
|
428
|
+
console.log(" Approach A (cold session):");
|
|
429
|
+
console.log(` ${pad("Tokens injected:", 20)}${baseline.approachA.tokens}`);
|
|
430
|
+
console.log(` ${pad("Context coverage:", 20)}${pct(baseline.approachA.coveragePct)}`);
|
|
431
|
+
console.log("");
|
|
432
|
+
console.log(" Approach B (full codebase dump):");
|
|
433
|
+
console.log(` ${pad("Files scanned:", 20)}${baseline.approachB.filesScanned}`);
|
|
434
|
+
console.log(` ${pad("Tokens required:", 20)}${baseline.approachB.totalTokens.toLocaleString()}`);
|
|
435
|
+
console.log(` ${pad("Context coverage:", 20)}~100% (structural)`);
|
|
436
|
+
console.log(" Problem: exceeds context window for");
|
|
437
|
+
console.log(" large projects, no semantic");
|
|
438
|
+
console.log(" ranking, includes noise");
|
|
439
|
+
console.log("");
|
|
440
|
+
console.log(" Approach C (stackmem):");
|
|
441
|
+
console.log(` ${pad("Tokens injected:", 20)}${baseline.approachC.tokens.toLocaleString()}`);
|
|
442
|
+
console.log(` ${pad("Context coverage:", 20)}${pct(baseline.approachC.coveragePct)} (semantic) — ${baseline.approachC.factsCovered}/4 key facts`);
|
|
443
|
+
const coveredFacts = Object.entries(baseline.approachC.coverage)
|
|
444
|
+
.filter(([, covered]) => covered)
|
|
445
|
+
.map(([key]) => KEY_FACT_LABELS[key]);
|
|
446
|
+
const missingFacts = Object.entries(baseline.approachC.coverage)
|
|
447
|
+
.filter(([, covered]) => !covered)
|
|
448
|
+
.map(([key]) => KEY_FACT_LABELS[key]);
|
|
449
|
+
console.log(` covered: ${coveredFacts.length > 0 ? coveredFacts.join(", ") : "(none)"}`);
|
|
450
|
+
console.log(` missing: ${missingFacts.length > 0 ? missingFacts.join(", ") : "(none)"}`);
|
|
451
|
+
const vsFullDump = baseline.approachB.totalTokens === 0
|
|
452
|
+
? 0
|
|
453
|
+
: 1 - baseline.approachC.tokens / baseline.approachB.totalTokens;
|
|
454
|
+
console.log(` ${pad("vs full dump:", 20)}${pct(vsFullDump)} fewer tokens`);
|
|
455
|
+
console.log(` ${pad("vs cold session:", 20)}${baseline.approachC.factsCovered} facts injected`);
|
|
456
|
+
console.log(subDivider);
|
|
457
|
+
console.log("");
|
|
458
|
+
// 1. Token efficiency ------------------------------------------------------
|
|
459
|
+
const tokenResult = benchmarkTokenEfficiency(allMemories);
|
|
460
|
+
console.log("1. TOKEN EFFICIENCY");
|
|
461
|
+
for (const row of tokenResult.perProject) {
|
|
462
|
+
const saved = row.rawTokens === 0 ? 0 : 1 - row.injectedTokens / row.rawTokens;
|
|
463
|
+
console.log(` ${pad(row.project, 20)} raw ${row.rawTokens.toLocaleString().padStart(6)} injected ${row.injectedTokens
|
|
464
|
+
.toLocaleString()
|
|
465
|
+
.padStart(6)} saved ${pct(saved)}`);
|
|
466
|
+
}
|
|
467
|
+
console.log("");
|
|
468
|
+
const reductionRatio = tokenResult.totalInjected === 0 ? 0 : tokenResult.totalRaw / tokenResult.totalInjected;
|
|
469
|
+
const tokenSavings = tokenResult.totalRaw === 0 ? 0 : 1 - tokenResult.totalInjected / tokenResult.totalRaw;
|
|
470
|
+
console.log(` ${pad("Raw tokens (no budget):")}${tokenResult.totalRaw.toLocaleString()}`);
|
|
471
|
+
console.log(` ${pad("Injected tokens (budgeted):")}${tokenResult.totalInjected.toLocaleString()}`);
|
|
472
|
+
console.log(` ${pad("Reduction ratio:")}${reductionRatio.toFixed(2)}x`);
|
|
473
|
+
console.log(` ${pad("Token savings:")}${pct(tokenSavings)}`);
|
|
474
|
+
if (tokenSavings === 0) {
|
|
475
|
+
console.log(" (0% because every project's unresolved memories currently fit under");
|
|
476
|
+
console.log(" the 2000-token budget on their own — the cap hasn't had to bind yet.");
|
|
477
|
+
console.log(" It will show real savings once a project accumulates enough");
|
|
478
|
+
console.log(" live memories to exceed the budget.)");
|
|
479
|
+
}
|
|
480
|
+
console.log("");
|
|
481
|
+
// 2. Context relevance ------------------------------------------------------
|
|
482
|
+
const relevanceResult = benchmarkContextRelevance(allMemories);
|
|
483
|
+
console.log("2. CONTEXT RELEVANCE (Precision@5)");
|
|
484
|
+
relevanceResult.results.forEach((r, i) => {
|
|
485
|
+
console.log(` Task ${i + 1}: ${r.hits}/5 relevant (${pct(r.precisionAt5)}) — "${r.task}"`);
|
|
486
|
+
});
|
|
487
|
+
console.log("");
|
|
488
|
+
console.log(` ${pad("Average Precision@5:")}${pct(relevanceResult.average)}`);
|
|
489
|
+
const avgPrecisionAt1 = relevanceResult.results.reduce((sum, r) => sum + r.precisionAt1, 0) / relevanceResult.results.length;
|
|
490
|
+
console.log(` ${pad("Average Precision@1:")}${pct(avgPrecisionAt1)}`);
|
|
491
|
+
console.log(" (note: with only ~13 live memories, most tasks have at most one truly");
|
|
492
|
+
console.log(" relevant match, so the top-ranked result is usually right even when");
|
|
493
|
+
console.log(" padding ranks 2-5 with unrelated memories drags Precision@5 down.)");
|
|
494
|
+
console.log("");
|
|
495
|
+
// Shared sample for sections 3 and 4.
|
|
496
|
+
const unrelatedPairs = seededShuffle(generateAllPairs(allMemories), BENCHMARK_SEED).slice(0, SAMPLE_SIZE);
|
|
497
|
+
// 3. Duplicate detection ------------------------------------------------------
|
|
498
|
+
const dupResult = benchmarkDuplicateDetection(allMemories, unrelatedPairs);
|
|
499
|
+
console.log("3. DUPLICATE DETECTION");
|
|
500
|
+
console.log(` TP: ${dupResult.tp} FP: ${dupResult.fp} TN: ${dupResult.tn} FN: ${dupResult.fn}`);
|
|
501
|
+
console.log(` ${pad("Precision:")}${pct(dupResult.precision)}`);
|
|
502
|
+
console.log(` ${pad("Recall:")}${pct(dupResult.recall)}`);
|
|
503
|
+
console.log(` ${pad("F1 Score:")}${pct(dupResult.f1)}`);
|
|
504
|
+
if (dupResult.fn > 0) {
|
|
505
|
+
console.log(` FN breakdown (${dupResult.fn} missed self-duplicates):`);
|
|
506
|
+
console.log(` ${dupResult.fnViaNegationCarveout} via negation-word carve-out (content like "removed dependency: X" reads as superseding itself)`);
|
|
507
|
+
console.log(` ${dupResult.fnViaNoExtractableEntities} via no extractable entities (content under the 5-char entity minimum, e.g. "wip")`);
|
|
508
|
+
}
|
|
509
|
+
console.log("");
|
|
510
|
+
// 4. Contradiction detection ------------------------------------------------
|
|
511
|
+
const contradictionResult = benchmarkContradictionDetection(allMemories, unrelatedPairs);
|
|
512
|
+
console.log("4. CONTRADICTION DETECTION");
|
|
513
|
+
console.log(` TP: ${contradictionResult.tp} FP: ${contradictionResult.fp} TN: ${contradictionResult.tn} FN: ${contradictionResult.fn}`);
|
|
514
|
+
console.log(` ${pad("Precision:")}${pct(contradictionResult.precision)}`);
|
|
515
|
+
console.log(` ${pad("Recall:")}${pct(contradictionResult.recall)}`);
|
|
516
|
+
console.log(` ${pad("F1 Score:")}${pct(contradictionResult.f1)}`);
|
|
517
|
+
if (contradictionResult.fp > 0) {
|
|
518
|
+
console.log(` FP breakdown (${contradictionResult.fp} unrelated pairs flagged as contradictions):`);
|
|
519
|
+
console.log(` ${contradictionResult.fpViaTemplateCollision} via template collision (both share a hook-generated prefix like "removed dependency: X", so shared template words alone cross the contradiction threshold)`);
|
|
520
|
+
}
|
|
521
|
+
console.log("");
|
|
522
|
+
// 5. Semantic linking quality -------------------------------------------------
|
|
523
|
+
const linkingResult = benchmarkSemanticLinking(allMemories, allLinks);
|
|
524
|
+
console.log("5. SEMANTIC LINKING QUALITY");
|
|
525
|
+
console.log(` ${pad("Avg link score:")}${linkingResult.avgLinkScore.toFixed(2)} (n=${linkingResult.linkCount})`);
|
|
526
|
+
console.log(` ${pad("Avg random pair score:")}${linkingResult.avgRandomPairScore.toFixed(2)} (n=${linkingResult.baselinePairCount})`);
|
|
527
|
+
console.log(` ${pad("Signal-to-noise ratio:")}${Number.isFinite(linkingResult.signalToNoiseRatio) ? `${linkingResult.signalToNoiseRatio.toFixed(1)}x` : "∞"}`);
|
|
528
|
+
console.log("");
|
|
529
|
+
// 6. Automation rate ----------------------------------------------------------
|
|
530
|
+
const automationResult = benchmarkAutomationRate(allMemories);
|
|
531
|
+
console.log("6. AUTOMATION RATE");
|
|
532
|
+
if (automationResult.hookFound) {
|
|
533
|
+
console.log(` Hook captures ${automationResult.capturedCategories.length} categories:`);
|
|
534
|
+
for (const label of automationResult.capturedCategories) {
|
|
535
|
+
console.log(` - ${label}`);
|
|
536
|
+
}
|
|
537
|
+
console.log(" - raw commit message → decision (quality-filtered)");
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
console.log(` (hook file not found at ${HOOK_PATH} — category list unavailable)`);
|
|
541
|
+
}
|
|
542
|
+
if (!automationResult.gitLogAvailable) {
|
|
543
|
+
console.log(" (git log unavailable — commit-message decisions counted as manual)");
|
|
544
|
+
}
|
|
545
|
+
console.log("");
|
|
546
|
+
console.log(` ${pad("Auto-captured memories:")}${pct(automationResult.automationRate)} (${automationResult.autoCount}/${automationResult.totalMemories})`);
|
|
547
|
+
console.log(` ${pad("Manually saved memories:")}${pct(1 - automationResult.automationRate)} (${automationResult.manualCount}/${automationResult.totalMemories})`);
|
|
548
|
+
console.log("");
|
|
549
|
+
// Head-to-head comparison -----------------------------------------------
|
|
550
|
+
console.log("APPROACH COMPARISON");
|
|
551
|
+
printTable(["", "Cold", "Full dump", "stackmem"], [
|
|
552
|
+
[
|
|
553
|
+
"Tokens used",
|
|
554
|
+
baseline.approachA.tokens.toLocaleString(),
|
|
555
|
+
baseline.approachB.totalTokens.toLocaleString(),
|
|
556
|
+
baseline.approachC.tokens.toLocaleString(),
|
|
557
|
+
],
|
|
558
|
+
["Key facts", "0", "~all", pct(baseline.approachC.coveragePct)],
|
|
559
|
+
["Stale content", "N/A", "possible", "auto-updated"],
|
|
560
|
+
["Setup required", "none", "manual", "cm init"],
|
|
561
|
+
]);
|
|
562
|
+
console.log("");
|
|
563
|
+
console.log(DIVIDER);
|
|
564
|
+
}
|
|
565
|
+
main().catch((error) => {
|
|
566
|
+
console.error("Benchmark failed:", error instanceof Error ? error.message : error);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
});
|