@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,661 @@
1
+ #!/usr/bin/env node
2
+ // TASK-003 — W3 memory benchmark runner (SPEC §5 FR-005/FR-006).
3
+ //
4
+ // Loads a generated corpus (TASK-001: records.json + manifest.json) into a
5
+ // mkdtemp project store, runs each requested retrieval variant over every
6
+ // manifest query, and emits report.json + report.md with env block,
7
+ // correctness metrics, latency percentiles, disk footprint, and a 4×10
8
+ // write-burst result.
9
+ //
10
+ // Isolation contract (FR-006, same as memory-baseline.mjs): every probe runs
11
+ // with cwd/HOME pointed at the temp fixture — the repo's own .ukit/ and the
12
+ // real HOME are never touched. Probe arguments are redacted to `<query-id>`;
13
+ // record bodies and query text never reach the report.
14
+ //
15
+ // Variant adapter contract (SPEC §8, TASK-004):
16
+ // createVariant(name, {recordsPath, projectRoot})
17
+ // → {index(), query(text, {scope, projectId, limit}) → hits[],
18
+ // invalidate(generation), close()}
19
+ // `--variant-module` defaults to scripts/bench/memory-ablation.mjs; a missing
20
+ // module or missing createVariant marks non-inline variants `unsupported`.
21
+ // The `lexical` variant is implemented inline (same eligible() policy + same
22
+ // token-overlap scoring as src/core/memory/retrieval.js) so the runner is
23
+ // usable before TASK-004 lands.
24
+ //
25
+ // Exit codes: 0 = ran to completion (verdicts are data; interrupted/failed
26
+ // probes → {complete:false}), 1 = usage/fixture error, 2 = harness crash.
27
+
28
+ import { execSync, spawn } from 'node:child_process';
29
+ import fs from 'node:fs';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+ import { pathToFileURL, fileURLToPath } from 'node:url';
33
+
34
+ import {
35
+ precisionAtK,
36
+ recallAtK,
37
+ scopeLeakRate,
38
+ falseAuthorityRate,
39
+ staleHitRate,
40
+ summarizeRuns,
41
+ footprintBytes,
42
+ } from './memory-metrics.mjs';
43
+ import { eligible, resolveEffectiveScope } from '../../src/core/memory/policy.js';
44
+
45
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
46
+ const NODE = process.execPath;
47
+ const UKIT_BIN = path.join(REPO_ROOT, 'bin/ukit');
48
+ const ROUTE_TASK = path.join(REPO_ROOT, 'template_project/.claude/ukit/index/route-task.mjs');
49
+ const REINJECT = path.join(REPO_ROOT, 'template_project/.claude/ukit/runtime/reinject-context.mjs');
50
+ const DEFAULT_VARIANT_MODULE = path.join(REPO_ROOT, 'scripts/bench/memory-ablation.mjs');
51
+
52
+ const DEFAULT_RUNS = 9;
53
+ const DEFAULT_TIMEOUT_MS = 30_000;
54
+ const BURST_PROCESSES = 4;
55
+ const BURST_WRITES_PER_PROCESS = 10;
56
+ const TOP_K = 5;
57
+ const SEARCH_LIMIT = 10;
58
+ const ALL_SURFACES = ['inproc', 'cli', 'route', 'reinject'];
59
+ // The temp project is bound to proj-alpha — the project the corpus's
60
+ // scope-boundary query is bound against (SPEC §7 boundProjectId).
61
+ const BENCH_PROJECT_ID = 'proj-alpha';
62
+
63
+ const USAGE = `Usage: node scripts/bench/memory-bench.mjs --fixture-root <dir> --out <file.json> [options]
64
+
65
+ Runs the W3 memory benchmark (SPEC §5 FR-005) over a generated corpus and
66
+ writes <file.json> plus a sibling .md report.
67
+
68
+ Options:
69
+ --fixture-root <dir> Corpus dir with records.json + manifest.json (required)
70
+ --out <file.json> Report output path; <file>.md written beside it (required)
71
+ --variants <csv> Variant names (default: lexical)
72
+ --variant-module <path> Adapter module exporting createVariant
73
+ (default: scripts/bench/memory-ablation.mjs)
74
+ --surfaces <csv> inproc,cli,route,reinject (default: inproc,cli)
75
+ --runs <n> Latency repetitions per probe (default ${DEFAULT_RUNS})
76
+ --timeout <ms> Per-probe timeout (default ${DEFAULT_TIMEOUT_MS})
77
+ --help Show this help
78
+
79
+ Exit codes: 0 = completed (verdicts are data), 1 = usage/fixture error, 2 = crash.`;
80
+
81
+ // ---------- small helpers ----------
82
+
83
+ function parseArgs(argv) {
84
+ const opts = {
85
+ variants: ['lexical'],
86
+ surfaces: ['inproc', 'cli'],
87
+ variantModule: DEFAULT_VARIANT_MODULE,
88
+ runs: DEFAULT_RUNS,
89
+ timeoutMs: DEFAULT_TIMEOUT_MS,
90
+ };
91
+ for (let i = 0; i < argv.length; i += 1) {
92
+ const arg = argv[i];
93
+ if (arg === '--help' || arg === '-h') return { help: true };
94
+ const takeValue = () => {
95
+ i += 1;
96
+ if (i >= argv.length) throw new Error(`Missing value for ${arg}`);
97
+ return argv[i];
98
+ };
99
+ if (arg === '--fixture-root') opts.fixtureRoot = takeValue();
100
+ else if (arg === '--out') opts.out = takeValue();
101
+ else if (arg === '--variants') opts.variants = takeValue().split(',').map((s) => s.trim()).filter(Boolean);
102
+ else if (arg === '--surfaces') opts.surfaces = takeValue().split(',').map((s) => s.trim()).filter(Boolean);
103
+ else if (arg === '--variant-module') opts.variantModule = path.resolve(takeValue());
104
+ else if (arg === '--runs') opts.runs = Number(takeValue());
105
+ else if (arg === '--timeout') opts.timeoutMs = Number(takeValue());
106
+ else throw new Error(`Unknown argument: ${arg}`);
107
+ }
108
+ return opts;
109
+ }
110
+
111
+ /**
112
+ * Spawn one probe command. `redactArgs` lists argv indices replaced by
113
+ * `<query-id>` in the recorded cmd string so query text never reaches the
114
+ * report.
115
+ */
116
+ function runCommand({ id, argv, cwd, env, stdin, timeoutMs, redactArgs = [] }) {
117
+ const redact = new Set(redactArgs);
118
+ const cmd = argv.map((a, i) => (redact.has(i) ? '<query-id>' : a)).join(' ');
119
+ return new Promise((resolve) => {
120
+ const started = Date.now();
121
+ let timedOut = false;
122
+ let spawnError = null;
123
+ let child;
124
+ try {
125
+ child = spawn(argv[0], argv.slice(1), {
126
+ cwd,
127
+ env: { ...process.env, ...env },
128
+ stdio: ['pipe', 'pipe', 'pipe'],
129
+ });
130
+ } catch (error) {
131
+ resolve({ id, cmd, exitCode: -1, durationMs: Date.now() - started, verdict: 'error', note: String(error?.message ?? error) });
132
+ return;
133
+ }
134
+ const timer = setTimeout(() => {
135
+ timedOut = true;
136
+ child.kill('SIGKILL');
137
+ }, timeoutMs);
138
+ child.stdout.resume();
139
+ child.stderr.resume();
140
+ child.on('error', (error) => { spawnError = error; });
141
+ if (stdin != null) child.stdin.write(stdin);
142
+ child.stdin.end();
143
+ child.on('close', (code) => {
144
+ clearTimeout(timer);
145
+ const durationMs = Date.now() - started;
146
+ let verdict;
147
+ if (timedOut || spawnError) verdict = 'error';
148
+ else verdict = code === 0 ? 'measured' : 'fail';
149
+ const entry = { id, cmd, exitCode: timedOut || code == null ? -1 : code, durationMs, verdict };
150
+ if (spawnError) entry.note = String(spawnError.message ?? spawnError);
151
+ resolve(entry);
152
+ });
153
+ });
154
+ }
155
+
156
+ // ---------- inline lexical variant (same semantics as retrieval.js) ----------
157
+
158
+ const STOPWORDS = new Set([
159
+ 'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
160
+ 'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
161
+ 'cần', 'và', 'là', 'cho', 'một', 'những', 'dùng',
162
+ ]);
163
+ const RECORD_TYPE_WEIGHTS = Object.freeze({
164
+ project_rule: 7, derived_fact: 5, procedure: 5, episode: 3,
165
+ });
166
+ const LEGACY_SCOPE_MATCH = {
167
+ user: (record) => record.scope === 'user',
168
+ project: (record) => record.scope === 'repo' || record.scope === 'task',
169
+ session: (record) => record.type === 'episode' || record.scope === 'session',
170
+ };
171
+
172
+ function tokenize(text) {
173
+ return String(text ?? '')
174
+ .toLowerCase()
175
+ .replace(/[^\p{L}\p{N}\s./:_-]/gu, ' ')
176
+ .replace(/\s+/g, ' ')
177
+ .trim()
178
+ .split(/\s+/)
179
+ .filter((token) => token && !STOPWORDS.has(token) && token.length > 1);
180
+ }
181
+
182
+ function scoreRecord(record, queryTokens) {
183
+ const typeWeight = RECORD_TYPE_WEIGHTS[record.type] ?? 1;
184
+ if (queryTokens.length === 0) {
185
+ return typeWeight * Math.max(0.1, record.confidence ?? 1);
186
+ }
187
+ const recordTokens = new Set(tokenize(`${record.text ?? ''} ${record.provenance ?? ''}`));
188
+ let matched = 0;
189
+ for (const token of queryTokens) {
190
+ if (recordTokens.has(token)) matched += 1;
191
+ }
192
+ if (matched === 0) return 0;
193
+ const recencyBonus = record.created_at > 0 ? Math.min(1, record.created_at / Date.now()) : 0;
194
+ return typeWeight * matched * Math.max(0.1, record.confidence ?? 1) + recencyBonus;
195
+ }
196
+
197
+ function recordMatchesRequestedScope(record, scope) {
198
+ if (scope === 'all') return true;
199
+ const matcher = LEGACY_SCOPE_MATCH[scope];
200
+ return matcher ? matcher(record) : (record.scope === scope || record.type === scope);
201
+ }
202
+
203
+ function createInlineLexical({ recordsPath }) {
204
+ let records = [];
205
+ return {
206
+ name: 'lexical',
207
+ async index() {
208
+ const doc = JSON.parse(fs.readFileSync(recordsPath, 'utf8'));
209
+ records = Array.isArray(doc.records) ? doc.records : [];
210
+ },
211
+ async query(text, { scope = 'all', projectId = null, limit = SEARCH_LIMIT } = {}) {
212
+ const effective = resolveEffectiveScope({ projectId }, scope);
213
+ if (effective.denied) return [];
214
+ const ctx = { projectId: effective.projectId, includeUser: effective.includeUser };
215
+ const queryTokens = tokenize(text);
216
+ return records
217
+ .filter((record) => eligible(record, ctx).ok)
218
+ .filter((record) => recordMatchesRequestedScope(record, scope))
219
+ .map((record) => ({ record, score: scoreRecord(record, queryTokens) }))
220
+ .filter((entry) => entry.score > 0)
221
+ .sort((a, b) => b.score - a.score
222
+ || (b.record.created_at ?? 0) - (a.record.created_at ?? 0)
223
+ || String(b.record.text ?? '').localeCompare(String(a.record.text ?? '')))
224
+ .slice(0, limit)
225
+ .map(({ record }) => ({
226
+ id: record.id,
227
+ project_id: record.project_id,
228
+ status: record.status,
229
+ labels: Array.isArray(record.meta?.bench_label) ? record.meta.bench_label
230
+ : (record.meta?.bench_label ? [record.meta.bench_label] : []),
231
+ }));
232
+ },
233
+ async invalidate() {},
234
+ async close() {},
235
+ };
236
+ }
237
+
238
+ // ---------- fixture / env ----------
239
+
240
+ function loadCorpus(fixtureRoot) {
241
+ const recordsPath = path.join(fixtureRoot, 'records.json');
242
+ const manifestPath = path.join(fixtureRoot, 'manifest.json');
243
+ if (!fs.existsSync(recordsPath)) throw new Error(`records.json not found: ${recordsPath}`);
244
+ if (!fs.existsSync(manifestPath)) throw new Error(`manifest.json not found: ${manifestPath}`);
245
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
246
+ if (!Array.isArray(manifest.queries)) throw new Error('manifest.json has no queries[] array');
247
+ return { recordsPath, manifest };
248
+ }
249
+
250
+ function envBlock() {
251
+ const env = {
252
+ sha: 'unknown', package: 'unknown', node: process.version,
253
+ os: process.platform, arch: process.arch, fs: 'local',
254
+ };
255
+ try {
256
+ env.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
257
+ } catch { /* leave 'unknown' */ }
258
+ try {
259
+ env.package = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version ?? 'unknown';
260
+ } catch { /* leave 'unknown' */ }
261
+ return env;
262
+ }
263
+
264
+ /**
265
+ * Temp project store: <tmp>/project/.ukit/storage/memory/v2/records.json plus
266
+ * a minimal config; temp HOME registry binds the project root to
267
+ * BENCH_PROJECT_ID so the CLI surface resolves the same identity the inproc
268
+ * probes use.
269
+ */
270
+ function seedTempStores(tmpRoot, recordsPath) {
271
+ const projectRoot = path.join(tmpRoot, 'project');
272
+ const v2Dir = path.join(projectRoot, '.ukit', 'storage', 'memory', 'v2');
273
+ fs.mkdirSync(v2Dir, { recursive: true });
274
+ fs.copyFileSync(recordsPath, path.join(v2Dir, 'records.json'));
275
+ fs.writeFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), '{}\n');
276
+
277
+ const home = path.join(tmpRoot, 'home');
278
+ const registryDir = path.join(home, '.ukit', 'storage', 'projects');
279
+ fs.mkdirSync(registryDir, { recursive: true });
280
+ const realRoot = fs.realpathSync(projectRoot);
281
+ const now = new Date().toISOString();
282
+ fs.writeFileSync(path.join(registryDir, 'registry.json'), `${JSON.stringify({
283
+ schemaVersion: 1,
284
+ projects: {
285
+ [BENCH_PROJECT_ID]: {
286
+ roots: [realRoot], gitCommonDirs: [], aliases: [], createdAt: now, updatedAt: now,
287
+ },
288
+ },
289
+ aliasIndex: {},
290
+ }, null, 2)}\n`);
291
+
292
+ // User-level store for the write burst (memory --user add).
293
+ const userV2 = path.join(home, '.ukit', 'storage', 'memory', 'v2');
294
+ fs.mkdirSync(userV2, { recursive: true });
295
+ fs.writeFileSync(path.join(userV2, 'records.json'),
296
+ '{"schemaVersion":2,"generation":1,"tombstones":[],"records":[]}\n');
297
+
298
+ return { projectRoot, home };
299
+ }
300
+
301
+ // ---------- variant loading ----------
302
+
303
+ async function loadVariantFactory(opts, report) {
304
+ let createVariant = null;
305
+ try {
306
+ const mod = await import(pathToFileURL(opts.variantModule).href);
307
+ if (typeof mod.createVariant === 'function') createVariant = mod.createVariant;
308
+ } catch (error) {
309
+ report.verdicts.push({
310
+ id: 'variant-module', verdict: 'unsupported',
311
+ note: `variant module unavailable: ${error?.message ?? error}`,
312
+ });
313
+ }
314
+ return (name, args) => {
315
+ if (name === 'lexical' && createVariant === null) return createInlineLexical(args);
316
+ if (createVariant === null) {
317
+ const err = new Error(`variant '${name}' unsupported: no adapter module`);
318
+ err.unsupported = true;
319
+ throw err;
320
+ }
321
+ return createVariant(name, args);
322
+ };
323
+ }
324
+
325
+ // ---------- probes ----------
326
+
327
+ function meanDefined(values) {
328
+ const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
329
+ if (nums.length === 0) return 'UNKNOWN';
330
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
331
+ }
332
+
333
+ async function measureVariant(ctx, name) {
334
+ const { queries, recordsPath, projectRoot, runs } = ctx;
335
+ const entry = {
336
+ correctness: {
337
+ precisionAt5: 'UNKNOWN', recallAt5: 'UNKNOWN', scopeLeakRate: 'UNKNOWN',
338
+ falseAuthorityRate: 'UNKNOWN', staleHitRate: 'UNKNOWN',
339
+ },
340
+ latency: { n: 0, p50: 'UNKNOWN', p95: 'UNKNOWN', p99: 'UNKNOWN' },
341
+ footprintBytes: 'UNKNOWN',
342
+ notes: [],
343
+ verdict: 'measured',
344
+ };
345
+
346
+ let variant;
347
+ try {
348
+ variant = await ctx.makeVariant(name, { recordsPath, projectRoot });
349
+ await variant.index();
350
+ } catch (error) {
351
+ entry.verdict = error?.unsupported ? 'unsupported' : 'error';
352
+ entry.notes.push(String(error?.message ?? error));
353
+ ctx.report.verdicts.push({ id: `variant:${name}`, verdict: entry.verdict, note: entry.notes[0] });
354
+ return entry;
355
+ }
356
+
357
+ try {
358
+ const allHits = [];
359
+ const p5 = [];
360
+ const r5 = [];
361
+ const durations = [];
362
+ for (const q of queries) {
363
+ const scope = q.scope ?? 'all';
364
+ const projectId = q.boundProjectId ?? BENCH_PROJECT_ID;
365
+ let hits;
366
+ try {
367
+ hits = await variant.query(q.text, { scope, projectId, limit: SEARCH_LIMIT });
368
+ } catch (error) {
369
+ entry.verdict = 'error';
370
+ entry.notes.push(`${q.id}: ${error?.message ?? error}`);
371
+ ctx.report.verdicts.push({ id: `query:${q.id}`, verdict: 'error', note: String(error?.message ?? error) });
372
+ continue;
373
+ }
374
+ hits = Array.isArray(hits) ? hits : [];
375
+ allHits.push(...hits);
376
+ const rankedIds = hits.map((h) => h.id);
377
+ p5.push(precisionAtK(rankedIds, q.relevantIds, TOP_K));
378
+ r5.push(recallAtK(rankedIds, q.relevantIds, TOP_K));
379
+ for (let run = 0; run < runs; run += 1) {
380
+ const started = performance.now();
381
+ await variant.query(q.text, { scope, projectId, limit: SEARCH_LIMIT });
382
+ durations.push(performance.now() - started);
383
+ }
384
+ ctx.report.verdicts.push({ id: `query:${q.id}`, variant: name, verdict: 'measured', hits: hits.length });
385
+ }
386
+ entry.correctness.precisionAt5 = meanDefined(p5);
387
+ entry.correctness.recallAt5 = meanDefined(r5);
388
+ entry.correctness.scopeLeakRate = scopeLeakRate(allHits, BENCH_PROJECT_ID);
389
+ entry.correctness.falseAuthorityRate = falseAuthorityRate(allHits);
390
+ entry.correctness.staleHitRate = staleHitRate(allHits, ctx.manifest);
391
+ entry.latency = summarizeRuns(durations);
392
+ entry.footprintBytes = await footprintBytes(path.join(projectRoot, '.ukit'));
393
+ } finally {
394
+ try { await variant.close?.(); } catch { /* close errors are not data */ }
395
+ }
396
+ return entry;
397
+ }
398
+
399
+ async function probeCliSurface(ctx) {
400
+ const { queries, projectRoot, home, runs, timeoutMs } = ctx;
401
+ const cold = [];
402
+ const warm = [];
403
+ for (const q of queries) {
404
+ for (let run = 0; run < runs; run += 1) {
405
+ const entry = await runCommand({
406
+ id: `cli-search:${q.id}`,
407
+ argv: [NODE, UKIT_BIN, 'memory', 'search', q.text],
408
+ cwd: projectRoot,
409
+ env: { HOME: home, UKIT_CLI_DEADLINE_MS: String(Math.min(timeoutMs, 25_000)) },
410
+ timeoutMs,
411
+ redactArgs: [4],
412
+ });
413
+ ctx.report.verdicts.push({ ...entry, surface: 'cli' });
414
+ if (entry.verdict === 'measured') (run === 0 ? cold : warm).push(entry.durationMs);
415
+ }
416
+ }
417
+ return { cold: summarizeRuns(cold), warm: summarizeRuns(warm) };
418
+ }
419
+
420
+ async function probeRouteSurface(ctx) {
421
+ const { queries, projectRoot, home, timeoutMs } = ctx;
422
+ for (const q of queries) {
423
+ const entry = await runCommand({
424
+ id: `route:${q.id}`,
425
+ argv: [NODE, ROUTE_TASK, q.text, '--root', projectRoot],
426
+ cwd: ctx.tmpRoot,
427
+ env: { HOME: home },
428
+ timeoutMs,
429
+ redactArgs: [2],
430
+ });
431
+ ctx.report.verdicts.push({ ...entry, surface: 'route' });
432
+ }
433
+ }
434
+
435
+ async function probeReinjectSurface(ctx) {
436
+ const { projectRoot, home, timeoutMs } = ctx;
437
+ const entry = await runCommand({
438
+ id: 'reinject-context',
439
+ argv: [NODE, REINJECT],
440
+ cwd: ctx.tmpRoot,
441
+ env: { HOME: home, CLAUDE_PROJECT_DIR: projectRoot },
442
+ stdin: JSON.stringify({ session_id: 'bench' }),
443
+ timeoutMs,
444
+ });
445
+ ctx.report.verdicts.push({ ...entry, surface: 'reinject' });
446
+ }
447
+
448
+ async function probeWriteBurst(ctx) {
449
+ const { home, timeoutMs } = ctx;
450
+ const expected = BURST_PROCESSES * BURST_WRITES_PER_PROCESS;
451
+ const started = Date.now();
452
+ let spawnFailures = 0;
453
+ const lanes = [];
454
+ for (let lane = 0; lane < BURST_PROCESSES; lane += 1) {
455
+ lanes.push((async () => {
456
+ for (let w = 0; w < BURST_WRITES_PER_PROCESS; w += 1) {
457
+ const marker = `burst-${lane}-${w}`;
458
+ const entry = await runCommand({
459
+ id: 'write-burst',
460
+ argv: [NODE, UKIT_BIN, 'memory', '--user', 'add', '--type', 'derived_fact', marker],
461
+ cwd: ctx.tmpRoot,
462
+ env: { HOME: home, UKIT_CLI_DEADLINE_MS: '25000' },
463
+ timeoutMs,
464
+ redactArgs: [7],
465
+ });
466
+ if (entry.verdict !== 'measured') spawnFailures += 1;
467
+ }
468
+ })());
469
+ }
470
+ await Promise.all(lanes);
471
+ const durationMs = Date.now() - started;
472
+
473
+ let persisted = 0;
474
+ let parseOk = true;
475
+ try {
476
+ const doc = JSON.parse(fs.readFileSync(
477
+ path.join(home, '.ukit', 'storage', 'memory', 'v2', 'records.json'), 'utf8'));
478
+ persisted = (doc.records ?? [])
479
+ .filter((r) => typeof r.text === 'string' && r.text.startsWith('burst-')).length;
480
+ } catch {
481
+ parseOk = false;
482
+ }
483
+ const lostUpdates = Math.max(0, expected - persisted);
484
+ const verdict = !parseOk || spawnFailures === expected
485
+ ? 'error'
486
+ : lostUpdates > 0 ? 'reproduced' : 'not-reproduced';
487
+ ctx.report.verdicts.push({
488
+ id: 'write-burst',
489
+ cmd: `${NODE} bin/ukit memory --user add <query-id> ×${expected} (${BURST_PROCESSES}×${BURST_WRITES_PER_PROCESS})`,
490
+ exitCode: spawnFailures === 0 ? 0 : 1,
491
+ durationMs,
492
+ verdict,
493
+ });
494
+ return { expected, persisted, lostUpdates, spawnFailures, verdict };
495
+ }
496
+
497
+ // ---------- report ----------
498
+
499
+ function renderMarkdown(report) {
500
+ const lines = [];
501
+ lines.push('# Memory bench report');
502
+ lines.push('');
503
+ lines.push('## Env');
504
+ lines.push('');
505
+ lines.push(`- sha: \`${report.env.sha}\` · package: \`${report.env.package}\``);
506
+ lines.push(`- node: \`${report.env.node}\` · os: \`${report.env.os}\` · arch: \`${report.env.arch}\` · fs: \`${report.env.fs}\``);
507
+ lines.push(`- corpus: tier=\`${report.corpus.tier}\` seed=\`${report.corpus.seed}\` records=${report.corpus.recordCount}`);
508
+ lines.push(`- complete: **${report.complete}**`);
509
+ lines.push('');
510
+ lines.push('## Variants');
511
+ lines.push('');
512
+ lines.push('| variant | verdict | p@5 | r@5 | scopeLeak | falseAuth | stale | p50 | p95 | p99 | footprint |');
513
+ lines.push('|---------|---------|-----|-----|-----------|-----------|-------|-----|-----|-----|-----------|');
514
+ for (const [name, v] of Object.entries(report.variants)) {
515
+ const c = v.correctness ?? {};
516
+ const l = v.latency ?? {};
517
+ const fmt = (x) => (typeof x === 'number' ? x.toFixed(3) : (x ?? 'n/a'));
518
+ lines.push(`| ${name} | ${v.verdict} | ${fmt(c.precisionAt5)} | ${fmt(c.recallAt5)} `
519
+ + `| ${fmt(c.scopeLeakRate)} | ${fmt(c.falseAuthorityRate)} | ${fmt(c.staleHitRate)} `
520
+ + `| ${fmt(l.p50)} | ${fmt(l.p95)} | ${fmt(l.p99)} | ${v.footprintBytes} |`);
521
+ }
522
+ lines.push('');
523
+ if (report.surfaces?.cli) {
524
+ lines.push('## CLI surface (ms)');
525
+ lines.push('');
526
+ lines.push('| probe | n | p50 | p95 | p99 |');
527
+ lines.push('|-------|---|-----|-----|-----|');
528
+ for (const [id, s] of Object.entries(report.surfaces.cli)) {
529
+ lines.push(`| cli-${id} | ${s.n} | ${s.p50} | ${s.p95} | ${s.p99} |`);
530
+ }
531
+ lines.push('');
532
+ }
533
+ lines.push('## Write burst');
534
+ lines.push('');
535
+ const wb = report.writeBurst;
536
+ lines.push(`- expected: ${wb.expected} · persisted: ${wb.persisted} · lostUpdates: ${wb.lostUpdates}`);
537
+ lines.push(`- verdict: **${wb.verdict ?? 'n/a'}**`);
538
+ lines.push('');
539
+ lines.push('## Verdicts');
540
+ lines.push('');
541
+ for (const v of report.verdicts) {
542
+ lines.push(`- ${v.id}${v.variant ? ` (${v.variant})` : ''}: ${v.verdict}`);
543
+ }
544
+ lines.push('');
545
+ return lines.join('\n');
546
+ }
547
+
548
+ // ---------- main ----------
549
+
550
+ async function main() {
551
+ let opts;
552
+ try {
553
+ opts = parseArgs(process.argv.slice(2));
554
+ } catch (error) {
555
+ console.error(`[memory-bench] ${error.message}\n\n${USAGE}`);
556
+ process.exit(1);
557
+ }
558
+ if (opts.help) {
559
+ console.log(USAGE);
560
+ process.exit(0);
561
+ }
562
+ if (!opts.fixtureRoot || !opts.out) {
563
+ console.error(`[memory-bench] --fixture-root and --out are required.\n\n${USAGE}`);
564
+ process.exit(1);
565
+ }
566
+ if (!Number.isFinite(opts.runs) || opts.runs < 1) {
567
+ console.error('[memory-bench] --runs must be a positive integer.');
568
+ process.exit(1);
569
+ }
570
+ const badSurface = opts.surfaces.find((s) => !ALL_SURFACES.includes(s));
571
+ if (badSurface) {
572
+ console.error(`[memory-bench] unknown surface: ${badSurface} (expected ${ALL_SURFACES.join(',')})`);
573
+ process.exit(1);
574
+ }
575
+
576
+ const fixtureRoot = path.resolve(opts.fixtureRoot);
577
+ if (!fs.existsSync(fixtureRoot)) {
578
+ console.error(`[memory-bench] fixture root not found: ${fixtureRoot}`);
579
+ process.exit(1);
580
+ }
581
+ let corpus;
582
+ try {
583
+ corpus = loadCorpus(fixtureRoot);
584
+ } catch (error) {
585
+ console.error(`[memory-bench] fixture error: ${error.message}`);
586
+ process.exit(1);
587
+ }
588
+
589
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ukit-bench-'));
590
+ const { projectRoot, home } = seedTempStores(tmpRoot, corpus.recordsPath);
591
+
592
+ const report = {
593
+ schemaVersion: 1,
594
+ complete: true,
595
+ env: envBlock(),
596
+ corpus: {
597
+ seed: corpus.manifest.seed ?? 'UNKNOWN',
598
+ tier: corpus.manifest.tier ?? 'UNKNOWN',
599
+ recordCount: corpus.manifest.recordCount ?? 'UNKNOWN',
600
+ },
601
+ variants: {},
602
+ surfaces: {},
603
+ writeBurst: {
604
+ expected: BURST_PROCESSES * BURST_WRITES_PER_PROCESS,
605
+ persisted: 0, lostUpdates: 0, verdict: 'skipped',
606
+ },
607
+ verdicts: [],
608
+ };
609
+
610
+ const ctx = {
611
+ manifest: corpus.manifest,
612
+ queries: corpus.manifest.queries,
613
+ recordsPath: path.join(projectRoot, '.ukit', 'storage', 'memory', 'v2', 'records.json'),
614
+ projectRoot,
615
+ home,
616
+ tmpRoot,
617
+ runs: opts.runs,
618
+ timeoutMs: opts.timeoutMs,
619
+ report,
620
+ };
621
+
622
+ ctx.makeVariant = await loadVariantFactory(opts, report);
623
+
624
+ for (const name of opts.variants) {
625
+ report.variants[name] = await measureVariant(ctx, name);
626
+ const v = report.variants[name];
627
+ console.log(`[memory-bench] ${name}: p@5=${v.correctness?.precisionAt5} `
628
+ + `p95=${v.latency?.p95} verdict=${v.verdict}`);
629
+ }
630
+
631
+ if (opts.surfaces.includes('cli')) {
632
+ report.surfaces.cli = await probeCliSurface(ctx);
633
+ }
634
+ if (opts.surfaces.includes('route')) {
635
+ await probeRouteSurface(ctx);
636
+ }
637
+ if (opts.surfaces.includes('reinject')) {
638
+ await probeReinjectSurface(ctx);
639
+ }
640
+ report.writeBurst = await probeWriteBurst(ctx);
641
+
642
+ if (report.verdicts.some((v) => v.verdict === 'error' || v.verdict === 'fail')) {
643
+ report.complete = false;
644
+ }
645
+
646
+ const outPath = path.resolve(opts.out);
647
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
648
+ fs.writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`);
649
+ const mdPath = outPath.replace(/\.json$/i, '') + '.md';
650
+ fs.writeFileSync(mdPath, renderMarkdown(report));
651
+
652
+ console.log(`[memory-bench] report written: ${outPath}`);
653
+ console.log(`[memory-bench] markdown: ${mdPath}`);
654
+ console.log(`[memory-bench] complete=${report.complete} writeBurst=${report.writeBurst.verdict}`);
655
+ process.exit(0);
656
+ }
657
+
658
+ main().catch((error) => {
659
+ console.error(`[memory-bench] harness crash: ${error?.stack ?? error}`);
660
+ process.exit(2);
661
+ });