@ngockhoale/ukit 3.0.8 → 3.0.9

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env node
2
+ // TASK-004 — retrieval ablation variants + --compare runner
3
+ // (SPEC §5 FR-007/FR-008, §8, §10).
4
+ //
5
+ // Variant adapter contract (consumed by memory-bench.mjs --variant-module and
6
+ // by the --compare mode below):
7
+ // createVariant(name, {recordsPath, projectRoot})
8
+ // → { name, index(), query(text, {scope, projectId, limit}) → hits[],
9
+ // invalidate(generation), close() }
10
+ // name ∈ 'lexical' | 'inverted' | 'sqlite-fts5'
11
+ // hits: {id, project_id, status, labels[], score}
12
+ //
13
+ // All three variants apply the SAME imported eligible() policy before
14
+ // ranking — correctness parity is measured, not assumed (SPEC §15).
15
+ // sqlite-fts5 probes `node:sqlite` dynamically; when unavailable the variant
16
+ // is {name, supported:false, reason} and --compare records `unsupported`,
17
+ // never silently dropping it.
18
+ //
19
+ // CLI:
20
+ // node scripts/bench/memory-ablation.mjs --fixture-root <dir> --compare
21
+ // [--runs <n>] --out <file.json>
22
+ //
23
+ // Exit codes: 0 = compare written, 1 = usage/fixture error, 2 = crash.
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import {
29
+ precisionAtK,
30
+ recallAtK,
31
+ scopeLeakRate,
32
+ falseAuthorityRate,
33
+ staleHitRate,
34
+ summarizeRuns,
35
+ } from './memory-metrics.mjs';
36
+ import { readRecordStore } from '../../src/core/memory/recordStore.js';
37
+ import { eligible, resolveEffectiveScope } from '../../src/core/memory/policy.js';
38
+
39
+ export const VARIANT_NAMES = ['lexical', 'inverted', 'sqlite-fts5'];
40
+
41
+ const TOP_K = 5;
42
+ const SEARCH_LIMIT = 10;
43
+ const DEFAULT_RUNS = 3;
44
+ // Same binding the bench runner uses (SPEC §7 boundProjectId).
45
+ const BENCH_PROJECT_ID = 'proj-alpha';
46
+
47
+ const USAGE = `Usage: node scripts/bench/memory-ablation.mjs --fixture-root <dir> --compare --out <file.json> [--runs <n>]
48
+
49
+ Runs every retrieval variant (lexical, inverted, sqlite-fts5) over the corpus
50
+ manifest queries and writes a per-variant correctness+latency report.
51
+ Unavailable variants are recorded as 'unsupported', never dropped.
52
+
53
+ Options:
54
+ --fixture-root <dir> Corpus dir with records.json + manifest.json (required)
55
+ --compare Run all variants and emit the comparison report
56
+ --out <file.json> Report output path (required)
57
+ --runs <n> Latency repetitions per query (default ${DEFAULT_RUNS})
58
+ --help Show this help
59
+
60
+ Exit codes: 0 = report written, 1 = usage/fixture error, 2 = crash.`;
61
+
62
+ // ---------- shared scoring (mirrors retrieval.js semantics) ----------
63
+
64
+ const STOPWORDS = new Set([
65
+ 'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
66
+ 'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
67
+ 'cần', 'và', 'là', 'cho', 'một', 'những', 'dùng',
68
+ ]);
69
+ const RECORD_TYPE_WEIGHTS = Object.freeze({
70
+ project_rule: 7, derived_fact: 5, procedure: 5, episode: 3,
71
+ });
72
+ const LEGACY_SCOPE_MATCH = {
73
+ user: (record) => record.scope === 'user',
74
+ project: (record) => record.scope === 'repo' || record.scope === 'task',
75
+ session: (record) => record.type === 'episode' || record.scope === 'session',
76
+ };
77
+
78
+ function normalize(text) {
79
+ return String(text ?? '')
80
+ .toLowerCase()
81
+ .replace(/[^\p{L}\p{N}\s./:_-]/gu, ' ')
82
+ .replace(/\s+/g, ' ')
83
+ .trim();
84
+ }
85
+
86
+ function tokenize(text) {
87
+ return normalize(text)
88
+ .split(/\s+/)
89
+ .filter((token) => token && !STOPWORDS.has(token) && token.length > 1);
90
+ }
91
+
92
+ function scoreRecord(record, queryTokens) {
93
+ const typeWeight = RECORD_TYPE_WEIGHTS[record.type] ?? 1;
94
+ if (queryTokens.length === 0) {
95
+ return typeWeight * Math.max(0.1, record.confidence ?? 1);
96
+ }
97
+ const recordTokens = new Set(tokenize(`${record.text ?? ''} ${record.provenance ?? ''}`));
98
+ let matched = 0;
99
+ for (const token of queryTokens) {
100
+ if (recordTokens.has(token)) matched += 1;
101
+ }
102
+ if (matched === 0) return 0;
103
+ const recencyBonus = record.created_at > 0 ? Math.min(1, record.created_at / Date.now()) : 0;
104
+ return typeWeight * matched * Math.max(0.1, record.confidence ?? 1) + recencyBonus;
105
+ }
106
+
107
+ function recordMatchesRequestedScope(record, scope) {
108
+ if (scope === 'all') return true;
109
+ const matcher = LEGACY_SCOPE_MATCH[scope];
110
+ return matcher ? matcher(record) : (record.scope === scope || record.type === scope);
111
+ }
112
+
113
+ function toHit(record, score) {
114
+ const label = record.meta?.bench_label;
115
+ return {
116
+ id: record.id,
117
+ project_id: record.project_id,
118
+ status: record.status,
119
+ labels: Array.isArray(label) ? label : (label ? [label] : []),
120
+ score,
121
+ };
122
+ }
123
+
124
+ function rankRecords(records, text, { scope = 'all', projectId = null, limit = SEARCH_LIMIT } = {}) {
125
+ const effective = resolveEffectiveScope({ projectId }, scope);
126
+ if (effective.denied) return [];
127
+ const ctx = { projectId: effective.projectId, includeUser: effective.includeUser };
128
+ const queryTokens = tokenize(text);
129
+ return records
130
+ .filter((record) => eligible(record, ctx).ok)
131
+ .filter((record) => recordMatchesRequestedScope(record, scope))
132
+ .map((record) => ({ record, score: scoreRecord(record, queryTokens) }))
133
+ .filter((entry) => entry.score > 0)
134
+ .sort((a, b) => b.score - a.score
135
+ || (b.record.created_at ?? 0) - (a.record.created_at ?? 0)
136
+ || String(b.record.text ?? '').localeCompare(String(a.record.text ?? '')))
137
+ .slice(0, limit)
138
+ .map(({ record, score }) => toHit(record, score));
139
+ }
140
+
141
+ // ---------- lexical variant (full scan per query) ----------
142
+
143
+ function createLexical({ recordsPath }) {
144
+ let records = [];
145
+ return {
146
+ name: 'lexical',
147
+ async index() {
148
+ const { records: loaded } = await readRecordStore(recordsPath);
149
+ records = loaded;
150
+ },
151
+ async query(text, opts = {}) {
152
+ return rankRecords(records, text, opts);
153
+ },
154
+ async invalidate() {},
155
+ async close() {},
156
+ };
157
+ }
158
+
159
+ // ---------- inverted variant (token → postings map) ----------
160
+
161
+ function createInverted({ recordsPath }) {
162
+ let records = [];
163
+ let postings = new Map();
164
+ let generation = null;
165
+
166
+ async function build() {
167
+ const { records: loaded, generation: gen } = await readRecordStore(recordsPath);
168
+ records = loaded;
169
+ generation = gen;
170
+ postings = new Map();
171
+ records.forEach((record, i) => {
172
+ for (const token of new Set(tokenize(`${record.text ?? ''} ${record.provenance ?? ''}`))) {
173
+ let list = postings.get(token);
174
+ if (!list) postings.set(token, (list = []));
175
+ list.push(i);
176
+ }
177
+ });
178
+ }
179
+
180
+ return {
181
+ name: 'inverted',
182
+ index: build,
183
+ async query(text, opts = {}) {
184
+ const { scope = 'all', projectId = null, limit = SEARCH_LIMIT } = opts;
185
+ const effective = resolveEffectiveScope({ projectId }, scope);
186
+ if (effective.denied) return [];
187
+ const ctx = { projectId: effective.projectId, includeUser: effective.includeUser };
188
+ const queryTokens = tokenize(text);
189
+ let candidates;
190
+ if (queryTokens.length === 0) {
191
+ candidates = records.map((_, i) => i);
192
+ } else {
193
+ const seen = new Set();
194
+ for (const token of queryTokens) {
195
+ for (const i of postings.get(token) ?? []) seen.add(i);
196
+ }
197
+ candidates = [...seen];
198
+ }
199
+ return candidates
200
+ .map((i) => records[i])
201
+ .filter((record) => eligible(record, ctx).ok)
202
+ .filter((record) => recordMatchesRequestedScope(record, scope))
203
+ .map((record) => ({ record, score: scoreRecord(record, queryTokens) }))
204
+ .filter((entry) => entry.score > 0)
205
+ .sort((a, b) => b.score - a.score
206
+ || (b.record.created_at ?? 0) - (a.record.created_at ?? 0)
207
+ || String(b.record.text ?? '').localeCompare(String(a.record.text ?? '')))
208
+ .slice(0, limit)
209
+ .map(({ record, score }) => toHit(record, score));
210
+ },
211
+ async invalidate(newGeneration) {
212
+ if (newGeneration !== generation) await build();
213
+ },
214
+ async close() {},
215
+ };
216
+ }
217
+
218
+ // ---------- sqlite-fts5 variant (prototype; FTS is a ranking aid only) ----------
219
+
220
+ async function defaultImportSqlite() {
221
+ return import('node:sqlite');
222
+ }
223
+ async function createSqliteFts5({ recordsPath, importSqlite = defaultImportSqlite }) {
224
+ let sqlite;
225
+ try {
226
+ sqlite = await importSqlite();
227
+ } catch (error) {
228
+ return {
229
+ name: 'sqlite-fts5',
230
+ supported: false,
231
+ reason: `node:sqlite unavailable: ${error?.message ?? error}`,
232
+ };
233
+ }
234
+ if (typeof sqlite?.DatabaseSync !== 'function') {
235
+ return {
236
+ name: 'sqlite-fts5',
237
+ supported: false,
238
+ reason: 'node:sqlite loaded but DatabaseSync is missing',
239
+ };
240
+ }
241
+
242
+ let db = null;
243
+ let generation = null;
244
+
245
+ async function build() {
246
+ const { records, generation: gen } = await readRecordStore(recordsPath);
247
+ generation = gen;
248
+ if (db) { try { db.close(); } catch { /* reopen */ } }
249
+ db = new sqlite.DatabaseSync(':memory:');
250
+ db.exec('CREATE TABLE records_mirror (id TEXT PRIMARY KEY, doc TEXT NOT NULL)');
251
+ db.exec("CREATE VIRTUAL TABLE records_fts USING fts5(text, content='')");
252
+ const insertMirror = db.prepare('INSERT INTO records_mirror (id, doc) VALUES (?, ?)');
253
+ const insertFts = db.prepare('INSERT INTO records_fts (rowid, text) VALUES (?, ?)');
254
+ // FTS indexes eligible text only: context-independent denials (status,
255
+ // expiry, unverified provenance) are excluded at index time; the full
256
+ // context-dependent eligible() check still runs before ranking.
257
+ const indexable = (record) => {
258
+ if (record == null || typeof record !== 'object') return false;
259
+ if (record.status !== 'active') return false;
260
+ if (record.valid_until != null && record.valid_until <= Date.now()) return false;
261
+ return !['learning-observation', 'learning-candidate', 'delta-overlay']
262
+ .includes(record.provenance);
263
+ };
264
+ let row = 0;
265
+ for (const record of records) {
266
+ row += 1;
267
+ insertMirror.run(record.id, JSON.stringify(record));
268
+ if (indexable(record)) {
269
+ insertFts.run(row, `${record.text ?? ''} ${record.provenance ?? ''}`);
270
+ }
271
+ }
272
+ }
273
+
274
+ return {
275
+ name: 'sqlite-fts5',
276
+ index: build,
277
+ async query(text, opts = {}) {
278
+ const { scope = 'all', projectId = null, limit = SEARCH_LIMIT } = opts;
279
+ const effective = resolveEffectiveScope({ projectId }, scope);
280
+ if (effective.denied) return [];
281
+ const ctx = { projectId: effective.projectId, includeUser: effective.includeUser };
282
+ const queryTokens = tokenize(text);
283
+
284
+ let rows;
285
+ if (queryTokens.length === 0) {
286
+ rows = db.prepare('SELECT doc FROM records_mirror').all();
287
+ } else {
288
+ // Phrase-match each token so FTS tokenizer differences cannot drop a
289
+ // candidate our tokenizer would keep; OR mirrors token-overlap recall.
290
+ const match = queryTokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
291
+ rows = db.prepare(
292
+ 'SELECT m.doc FROM records_fts JOIN records_mirror m ON m.rowid = records_fts.rowid '
293
+ + 'WHERE records_fts MATCH ?',
294
+ ).all(match);
295
+ }
296
+ return rows
297
+ .map((row) => JSON.parse(row.doc))
298
+ .filter((record) => eligible(record, ctx).ok)
299
+ .filter((record) => recordMatchesRequestedScope(record, scope))
300
+ .map((record) => ({ record, score: scoreRecord(record, queryTokens) }))
301
+ .filter((entry) => entry.score > 0)
302
+ .sort((a, b) => b.score - a.score
303
+ || (b.record.created_at ?? 0) - (a.record.created_at ?? 0)
304
+ || String(b.record.text ?? '').localeCompare(String(a.record.text ?? '')))
305
+ .slice(0, limit)
306
+ .map(({ record, score }) => toHit(record, score));
307
+ },
308
+ async invalidate(newGeneration) {
309
+ if (newGeneration !== generation) await build();
310
+ },
311
+ async close() {
312
+ if (db) { try { db.close(); } catch { /* already closed */ } db = null; }
313
+ },
314
+ };
315
+ }
316
+
317
+ // ---------- adapter factory ----------
318
+
319
+ export async function createVariant(name, { recordsPath, projectRoot, importSqlite } = {}) {
320
+ switch (name) {
321
+ case 'lexical': return createLexical({ recordsPath, projectRoot });
322
+ case 'inverted': return createInverted({ recordsPath, projectRoot });
323
+ case 'sqlite-fts5': return createSqliteFts5({ recordsPath, projectRoot, importSqlite });
324
+ default: {
325
+ const err = new Error(`unknown variant: ${name}`);
326
+ err.unsupported = true;
327
+ return { name, supported: false, reason: err.message };
328
+ }
329
+ }
330
+ }
331
+
332
+ // ---------- --compare mode ----------
333
+
334
+ function loadCorpus(fixtureRoot) {
335
+ const recordsPath = path.join(fixtureRoot, 'records.json');
336
+ const manifestPath = path.join(fixtureRoot, 'manifest.json');
337
+ if (!fs.existsSync(recordsPath)) throw new Error(`records.json not found: ${recordsPath}`);
338
+ if (!fs.existsSync(manifestPath)) throw new Error(`manifest.json not found: ${manifestPath}`);
339
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
340
+ if (!Array.isArray(manifest.queries)) throw new Error('manifest.json has no queries[] array');
341
+ return { recordsPath, manifest };
342
+ }
343
+
344
+ function meanDefined(values) {
345
+ const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
346
+ if (nums.length === 0) return 'UNKNOWN';
347
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
348
+ }
349
+
350
+ async function measureVariant(name, { recordsPath, projectRoot, queries, manifest, runs, importSqlite }) {
351
+ const entry = {
352
+ verdict: 'measured',
353
+ correctness: {
354
+ precisionAt5: 'UNKNOWN', recallAt5: 'UNKNOWN', scopeLeakRate: 'UNKNOWN',
355
+ falseAuthorityRate: 'UNKNOWN', staleHitRate: 'UNKNOWN',
356
+ },
357
+ latency: { n: 0, p50: 'UNKNOWN', p95: 'UNKNOWN', p99: 'UNKNOWN' },
358
+ notes: [],
359
+ };
360
+
361
+ const variant = await createVariant(name, { recordsPath, projectRoot, importSqlite });
362
+ if (variant.supported === false) {
363
+ entry.verdict = 'unsupported';
364
+ entry.reason = variant.reason;
365
+ entry.notes.push(variant.reason);
366
+ return entry;
367
+ }
368
+
369
+ try {
370
+ await variant.index();
371
+ const allHits = [];
372
+ const p5 = [];
373
+ const r5 = [];
374
+ const durations = [];
375
+ for (const q of queries) {
376
+ const scope = q.scope ?? 'all';
377
+ const projectId = q.boundProjectId ?? BENCH_PROJECT_ID;
378
+ const hits = await variant.query(q.text, { scope, projectId, limit: SEARCH_LIMIT });
379
+ allHits.push(...hits);
380
+ const rankedIds = hits.map((h) => h.id);
381
+ p5.push(precisionAtK(rankedIds, q.relevantIds, TOP_K));
382
+ r5.push(recallAtK(rankedIds, q.relevantIds, TOP_K));
383
+ for (let run = 0; run < runs; run += 1) {
384
+ const started = performance.now();
385
+ await variant.query(q.text, { scope, projectId, limit: SEARCH_LIMIT });
386
+ durations.push(performance.now() - started);
387
+ }
388
+ }
389
+ entry.correctness.precisionAt5 = meanDefined(p5);
390
+ entry.correctness.recallAt5 = meanDefined(r5);
391
+ entry.correctness.scopeLeakRate = scopeLeakRate(allHits, BENCH_PROJECT_ID);
392
+ entry.correctness.falseAuthorityRate = falseAuthorityRate(allHits);
393
+ entry.correctness.staleHitRate = staleHitRate(allHits, manifest);
394
+ entry.latency = summarizeRuns(durations);
395
+ } catch (error) {
396
+ entry.verdict = 'error';
397
+ entry.notes.push(String(error?.message ?? error));
398
+ } finally {
399
+ try { await variant.close?.(); } catch { /* close errors are not data */ }
400
+ }
401
+ return entry;
402
+ }
403
+
404
+ export async function runCompare({ fixtureRoot, runs = DEFAULT_RUNS, importSqlite } = {}) {
405
+ const { recordsPath, manifest } = loadCorpus(path.resolve(fixtureRoot));
406
+ const report = {
407
+ schemaVersion: 1,
408
+ kind: 'ablation-compare',
409
+ corpus: {
410
+ seed: manifest.seed ?? 'UNKNOWN',
411
+ tier: manifest.tier ?? 'UNKNOWN',
412
+ recordCount: manifest.recordCount ?? 'UNKNOWN',
413
+ },
414
+ variants: {},
415
+ };
416
+ for (const name of VARIANT_NAMES) {
417
+ report.variants[name] = await measureVariant(name, {
418
+ recordsPath,
419
+ projectRoot: path.resolve(fixtureRoot),
420
+ queries: manifest.queries,
421
+ manifest,
422
+ runs,
423
+ importSqlite,
424
+ });
425
+ }
426
+ return report;
427
+ }
428
+
429
+ // ---------- CLI ----------
430
+
431
+ function parseArgs(argv) {
432
+ const opts = { compare: false, runs: DEFAULT_RUNS };
433
+ for (let i = 0; i < argv.length; i += 1) {
434
+ const arg = argv[i];
435
+ if (arg === '--help' || arg === '-h') return { help: true };
436
+ const takeValue = () => {
437
+ i += 1;
438
+ if (i >= argv.length) throw new Error(`Missing value for ${arg}`);
439
+ return argv[i];
440
+ };
441
+ if (arg === '--fixture-root') opts.fixtureRoot = takeValue();
442
+ else if (arg === '--out') opts.out = takeValue();
443
+ else if (arg === '--compare') opts.compare = true;
444
+ else if (arg === '--runs') opts.runs = Number(takeValue());
445
+ else throw new Error(`Unknown argument: ${arg}`);
446
+ }
447
+ return opts;
448
+ }
449
+
450
+ async function main() {
451
+ let opts;
452
+ try {
453
+ opts = parseArgs(process.argv.slice(2));
454
+ } catch (error) {
455
+ console.error(`[memory-ablation] ${error.message}\n\n${USAGE}`);
456
+ process.exit(1);
457
+ }
458
+ if (opts.help) {
459
+ console.log(USAGE);
460
+ process.exit(0);
461
+ }
462
+ if (!opts.compare || !opts.fixtureRoot || !opts.out) {
463
+ console.error(`[memory-ablation] --compare, --fixture-root and --out are required.\n\n${USAGE}`);
464
+ process.exit(1);
465
+ }
466
+ if (!Number.isFinite(opts.runs) || opts.runs < 1) {
467
+ console.error('[memory-ablation] --runs must be a positive integer.');
468
+ process.exit(1);
469
+ }
470
+ if (!fs.existsSync(path.resolve(opts.fixtureRoot))) {
471
+ console.error(`[memory-ablation] fixture root not found: ${opts.fixtureRoot}`);
472
+ process.exit(1);
473
+ }
474
+
475
+ const report = await runCompare({ fixtureRoot: opts.fixtureRoot, runs: opts.runs });
476
+ for (const [name, v] of Object.entries(report.variants)) {
477
+ console.log(`[memory-ablation] ${name}: p@5=${v.correctness?.precisionAt5} `
478
+ + `p95=${v.latency?.p95} verdict=${v.verdict}`);
479
+ }
480
+
481
+ const outPath = path.resolve(opts.out);
482
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
483
+ fs.writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`);
484
+ console.log(`[memory-ablation] report written: ${outPath}`);
485
+ process.exit(0);
486
+ }
487
+
488
+ const invokedAsScript = process.argv[1]
489
+ && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
490
+ if (invokedAsScript) {
491
+ main().catch((error) => {
492
+ console.error(`[memory-ablation] harness crash: ${error?.stack ?? error}`);
493
+ process.exit(2);
494
+ });
495
+ }