@ngockhoale/ukit 3.4.47 → 3.4.49

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 (62) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/worktree-preflight.mjs +20 -1
  6. package/src/core/diffPlan.js +38 -12
  7. package/src/core/executionContracts.js +79 -18
  8. package/src/core/metadata.js +4 -0
  9. package/src/core/runInstallPipeline.js +19 -2
  10. package/src/core/runtimeConfig.js +1 -1
  11. package/src/index/playbookRegistry.js +22 -3
  12. package/src/index/taskRouting.js +2 -2
  13. package/template_project/.claude/agents/bug-debugger.md +1 -0
  14. package/template_project/.claude/agents/code-reviewer.md +1 -0
  15. package/template_project/.claude/agents/feature-implementer.md +1 -0
  16. package/template_project/.claude/agents/handoff-planner.md +1 -0
  17. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  18. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  19. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  20. package/template_project/.claude/hooks/skill-router.sh +159 -8
  21. package/template_project/.claude/settings.json +8 -8
  22. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  23. package/template_project/.claude/ukit/index/route-resolver.mjs +79 -17
  24. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  25. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  26. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  27. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +331 -21
  28. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  29. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  30. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  31. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  32. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
  33. package/template_project/.omp/agents/bug-debugger.md +1 -0
  34. package/template_project/.omp/agents/code-reviewer.md +1 -0
  35. package/template_project/.omp/agents/feature-implementer.md +1 -0
  36. package/template_project/.omp/agents/handoff-planner.md +1 -0
  37. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  39. package/template_project/ukit/storage/config.json +1 -1
  40. package/scripts/bench/data-foundation.mjs +0 -929
  41. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  42. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  43. package/scripts/bench/goldTasks.json +0 -38
  44. package/scripts/bench/memory-ablation.mjs +0 -495
  45. package/scripts/bench/memory-baseline.mjs +0 -596
  46. package/scripts/bench/memory-bench.mjs +0 -661
  47. package/scripts/bench/memory-canary.mjs +0 -331
  48. package/scripts/bench/memory-corpus.mjs +0 -354
  49. package/scripts/bench/memory-gate.mjs +0 -389
  50. package/scripts/bench/memory-metrics.mjs +0 -179
  51. package/scripts/bench/outline-savings.mjs +0 -107
  52. package/scripts/bench/parallel-agents.mjs +0 -224
  53. package/scripts/bench/recorder-overhead.mjs +0 -204
  54. package/scripts/bench/runGold.mjs +0 -238
  55. package/scripts/bench/sqlite-spike.mjs +0 -451
  56. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  57. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  58. package/scripts/bench/v3-ceremony.mjs +0 -220
  59. package/scripts/perf/audit-perf.mjs +0 -1195
  60. package/scripts/perf/diff-perf-findings.mjs +0 -136
  61. package/scripts/perf/perf-findings.json +0 -474
  62. package/scripts/perf/perf-measure.md +0 -271
@@ -1,929 +0,0 @@
1
- #!/usr/bin/env node
2
- // data-foundation bench harness (C54 / DF-00)
3
- //
4
- // --fixture Regenerate tests/fixtures/observability/corpus.json deterministically (seed=42).
5
- // --recorder Emit the corpus through createRecorder into a temp segment store.
6
- // --analytics Rebuild the derived index from retained segments and print summaries.
7
- // --evaluate Replay the golden corpus (tests/fixtures/observability/golden/cases.json)
8
- // under both pre-registered policies and print paired scorecards +
9
- // a fixed-seed variance report (TASK-014).
10
- // --perturb Inject seeded synthetic mutations into corpus summaries and measure
11
- // detection through computeFingerprint/detectOpportunities (TASK-002).
12
- // --anomalies Replay the golden cases, summarize each run, and report anomaly
13
- // detection counts per case (TASK-015). Degrades honestly to
14
- // `detector: 'unavailable'` while analytics/anomalies.js
15
- // (TASK-011) does not exist — never a stub detector.
16
- //
17
- // Flags run in argument order; the exit code is the first non-zero code.
18
- // --evaluate exits 1 by design while the corpus carries expected-negative
19
- // strata (a fail verdict is the honest scorecard), so a combined invocation
20
- // ending in --evaluate exits 1.
21
- //
22
- // The corpus is a synthetic, seeded fixture: no real user content. Records are
23
- // envelope-shaped per SPEC §7 (DF-FR01) but intentionally NOT schema-validated —
24
- // TASK-003 owns validation. Unknown host measurements are marked UNKNOWN, never 0.
25
-
26
- import fs from 'node:fs';
27
- import os from 'node:os';
28
- import path from 'node:path';
29
- import { fileURLToPath } from 'node:url';
30
- import { SEMANTIC_REGISTRY } from '../../src/core/observability/schema/registry.js';
31
- import { PRIVACY_CLASSES } from '../../src/core/observability/schema/constants.js';
32
-
33
- const SEED = 42;
34
- const METRIC_VERSION = 'df-m1';
35
- const SCHEMA_VERSION = 1;
36
- const BOOT_ID = 'boot-fixture-0001';
37
- const WRITER_ID = 'writer-fixture-0001';
38
- const SESSION_ID = 'session-fixture-0001';
39
- const PROJECT_REF = 'project-fixture-0001';
40
- const WALL_EPOCH_MS = Date.UTC(2026, 0, 1, 0, 0, 0); // fixed epoch → deterministic wall_time_utc
41
-
42
- const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
43
- const corpusPath = path.join(repoRoot, 'tests/fixtures/observability/corpus.json');
44
-
45
- // Records may raise privacy above the registry floor but never lower it —
46
- // validateSemanticRecord (TASK-003) rejects below-floor classes, so the
47
- // fixture clamps each record's declared class up to the registry floor.
48
- function clampToRegistryFloor(semanticName, privacyClass) {
49
- const floor = SEMANTIC_REGISTRY[semanticName]?.privacy_class;
50
- if (!floor) return privacyClass;
51
- return PRIVACY_CLASSES.indexOf(privacyClass) < PRIVACY_CLASSES.indexOf(floor) ? floor : privacyClass;
52
- }
53
-
54
- // Deterministic PRNG (mulberry32) — used only for token counts; IDs are counters.
55
- function mulberry32(seed) {
56
- let a = seed >>> 0;
57
- return function next() {
58
- a |= 0;
59
- a = (a + 0x6d2b79f5) | 0;
60
- let t = Math.imul(a ^ (a >>> 15), 1 | a);
61
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
62
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
63
- };
64
- }
65
-
66
- function makeCtx() {
67
- const rand = mulberry32(SEED);
68
- let seq = 0;
69
- let rec = 0;
70
- let span = 0;
71
- let trace = 0;
72
- let clockMs = 0;
73
- return {
74
- nextSeq: () => ++seq,
75
- recordId: () => `rec-${String(++rec).padStart(4, '0')}`,
76
- spanId: () => `span-${String(++span).padStart(4, '0')}`,
77
- traceId: () => `trace-${String(++trace).padStart(4, '0')}`,
78
- // advance the deterministic clock by `ms` and return { wall_time_utc, monotonic_ns }
79
- tick: (ms) => {
80
- clockMs += ms;
81
- return {
82
- wall_time_utc: new Date(WALL_EPOCH_MS + clockMs).toISOString(),
83
- monotonic_ns: clockMs * 1e6,
84
- };
85
- },
86
- tokens: (lo, hi) => lo + Math.floor(rand() * (hi - lo + 1)),
87
- };
88
- }
89
- function record(ctx, { semanticName, traceId, spanId, parentSpanId, executionId, agentId, importance, privacyClass, payload, dtMs }) {
90
- const t = ctx.tick(dtMs);
91
- const rec = {
92
- record_type: 'fact',
93
- semantic_name: semanticName,
94
- schema_version: SCHEMA_VERSION,
95
- record_id: ctx.recordId(),
96
- trace_id: traceId,
97
- span_id: spanId,
98
- parent_span_id: parentSpanId,
99
- execution_id: executionId,
100
- session_id: SESSION_ID,
101
- project_ref: PROJECT_REF,
102
- boot_id: BOOT_ID,
103
- writer_id: WRITER_ID,
104
- sequence: ctx.nextSeq(),
105
- wall_time_utc: t.wall_time_utc,
106
- monotonic_ns: t.monotonic_ns,
107
- importance,
108
- privacy_class: clampToRegistryFloor(semanticName, privacyClass),
109
- payload,
110
- };
111
- // DF2-FR01 v1.1: agent_id is the optional multi-agent correlation field —
112
- // emitted only when the scenario declares one.
113
- if (typeof agentId === 'string' && agentId.length > 0) rec.agent_id = agentId;
114
- return rec;
115
- }
116
-
117
- function execSpan(ctx, traceId, executionId, privacyClass, agentId) {
118
- const spanId = ctx.spanId();
119
- return {
120
- spanId,
121
- started: record(ctx, {
122
- semanticName: 'execution.started',
123
- traceId,
124
- spanId,
125
- parentSpanId: null,
126
- executionId,
127
- agentId,
128
- importance: 'normal',
129
- privacyClass,
130
- payload: { operation: 'task', duration_ms: null },
131
- dtMs: 10,
132
- }),
133
- };
134
- }
135
-
136
- function modelAttempt(ctx, { traceId, parentSpanId, executionId, agentId, attemptIndex, status, resource, privacyClass, extra = {} }) {
137
- const spanId = ctx.spanId();
138
- const started = record(ctx, {
139
- semanticName: 'model.started',
140
- traceId,
141
- spanId,
142
- parentSpanId,
143
- executionId,
144
- agentId,
145
- importance: 'normal',
146
- privacyClass,
147
- payload: { attempt_index: attemptIndex, ...extra },
148
- dtMs: 5,
149
- });
150
- const completed = record(ctx, {
151
- semanticName: status === 'ok' ? 'model.completed' : 'model.failed',
152
- traceId,
153
- spanId,
154
- parentSpanId,
155
- executionId,
156
- agentId,
157
- importance: 'normal',
158
- privacyClass,
159
- payload: {
160
- attempt_index: attemptIndex,
161
- duration_ms: 120 + attemptIndex * 30,
162
- resource,
163
- ...(status === 'ok' ? {} : { error_code: extra.error_code || 'MODEL_ERROR' }),
164
- ...extra,
165
- },
166
- dtMs: 120 + attemptIndex * 30,
167
- });
168
- return [started, completed];
169
- }
170
- function execEnd(ctx, { traceId, spanId, executionId, agentId, status, privacyClass, extra = {} }) {
171
- return record(ctx, {
172
- semanticName: `execution.${status}`,
173
- traceId,
174
- spanId,
175
- parentSpanId: null,
176
- executionId,
177
- agentId,
178
- importance: 'normal',
179
- privacyClass,
180
- payload: { duration_ms: 0, ...extra },
181
- dtMs: 10,
182
- });
183
- }
184
-
185
- function sumUsage(attempts) {
186
- const values = attempts
187
- .map((r) => r.payload.resource)
188
- .filter((res) => res && typeof res.value === 'number')
189
- .map((res) => res.value);
190
- return values.length ? values.reduce((a, b) => a + b, 0) : null;
191
- }
192
- function buildTrace(ctx, { scenario, privacyClass, agentId, build }) {
193
- const traceId = ctx.traceId();
194
- const executionId = `exec-${traceId}`;
195
- const exec = execSpan(ctx, traceId, executionId, privacyClass, agentId);
196
- const { records, attempts, succeeded } = build({ traceId, executionId, execSpanId: exec.spanId });
197
- const all = [exec.started, ...records];
198
- return {
199
- trace_id: traceId,
200
- scenario,
201
- privacy_class: privacyClass,
202
- records: all,
203
- metrics: {
204
- metric_version: METRIC_VERSION,
205
- tokens_to_success: succeeded ? sumUsage(attempts) : null,
206
- correction_observed: scenario === 'retry',
207
- },
208
- };
209
- }
210
-
211
- function buildCorpus() {
212
- const ctx = makeCtx();
213
- const traces = [];
214
-
215
- // success: single attempt, provider-reported usage.
216
- traces.push(
217
- buildTrace(ctx, {
218
- scenario: 'success',
219
- privacyClass: 'public',
220
- build: ({ traceId, executionId, execSpanId }) => {
221
- const resource = { value: ctx.tokens(800, 1200), source: 'PROVIDER' };
222
- const attempts = modelAttempt(ctx, {
223
- traceId, parentSpanId: execSpanId, executionId,
224
- attemptIndex: 1, status: 'ok', resource, privacyClass: 'public',
225
- });
226
- const end = execEnd(ctx, {
227
- traceId, spanId: execSpanId, executionId, status: 'completed',
228
- privacyClass: 'public', extra: { outcome: 'success' },
229
- });
230
- return { records: [...attempts, end], attempts, succeeded: true };
231
- },
232
- }),
233
- );
234
-
235
- // retry: user correction observed → second attempt; tokens_to_success sums both.
236
- traces.push(
237
- buildTrace(ctx, {
238
- scenario: 'retry',
239
- privacyClass: 'public',
240
- build: ({ traceId, executionId, execSpanId }) => {
241
- const res1 = { value: ctx.tokens(400, 700), source: 'PROVIDER' };
242
- const res2 = { value: ctx.tokens(500, 900), source: 'PROVIDER' };
243
- const a1 = modelAttempt(ctx, {
244
- traceId, parentSpanId: execSpanId, executionId,
245
- attemptIndex: 1, status: 'ok', resource: res1, privacyClass: 'public',
246
- });
247
- const correction = record(ctx, {
248
- semanticName: 'context.item.injected',
249
- traceId, spanId: ctx.spanId(), parentSpanId: execSpanId, executionId,
250
- importance: 'high', privacyClass: 'public',
251
- payload: { kind: 'user_correction', correction_observed: true },
252
- dtMs: 15,
253
- });
254
- const a2 = modelAttempt(ctx, {
255
- traceId, parentSpanId: execSpanId, executionId,
256
- attemptIndex: 2, status: 'ok', resource: res2, privacyClass: 'public',
257
- });
258
- const end = execEnd(ctx, {
259
- traceId, spanId: execSpanId, executionId, status: 'completed',
260
- privacyClass: 'public', extra: { outcome: 'success', attempts: 2 },
261
- });
262
- return { records: [...a1, correction, ...a2, end], attempts: [...a1, ...a2], succeeded: true };
263
- },
264
- }),
265
- );
266
-
267
- // failure: host-blind — usage UNKNOWN (never 0), execution failed.
268
- traces.push(
269
- buildTrace(ctx, {
270
- scenario: 'failure',
271
- privacyClass: 'public',
272
- build: ({ traceId, executionId, execSpanId }) => {
273
- const resource = { value: null, source: 'UNKNOWN' };
274
- const attempts = modelAttempt(ctx, {
275
- traceId, parentSpanId: execSpanId, executionId,
276
- attemptIndex: 1, status: 'error', resource, privacyClass: 'public',
277
- extra: { error_code: 'TOOL_TIMEOUT' },
278
- });
279
- const end = execEnd(ctx, {
280
- traceId, spanId: execSpanId, executionId, status: 'failed',
281
- privacyClass: 'public', extra: { outcome: 'failure', error_code: 'TOOL_TIMEOUT' },
282
- });
283
- return { records: [...attempts, end], attempts, succeeded: false };
284
- },
285
- }),
286
- );
287
-
288
- // dropped: queue overflow — telemetry.dropped counted, telemetry_complete=false.
289
- traces.push(
290
- buildTrace(ctx, {
291
- scenario: 'dropped',
292
- privacyClass: 'public',
293
- build: ({ traceId, executionId, execSpanId }) => {
294
- const resource = { value: ctx.tokens(300, 600), source: 'PROVIDER' };
295
- const attempts = modelAttempt(ctx, {
296
- traceId, parentSpanId: execSpanId, executionId,
297
- attemptIndex: 1, status: 'ok', resource, privacyClass: 'public',
298
- });
299
- const dropped = record(ctx, {
300
- semanticName: 'telemetry.dropped',
301
- traceId, spanId: ctx.spanId(), parentSpanId: execSpanId, executionId,
302
- importance: 'high', privacyClass: 'public',
303
- payload: { reason_code: 'QUEUE_OVERFLOW', dropped_count: 3, telemetry_complete: false },
304
- dtMs: 5,
305
- });
306
- const end = execEnd(ctx, {
307
- traceId, spanId: execSpanId, executionId, status: 'completed',
308
- privacyClass: 'public', extra: { outcome: 'success', telemetry_complete: false },
309
- });
310
- return { records: [...attempts, dropped, end], attempts, succeeded: true };
311
- },
312
- }),
313
- );
314
-
315
- // secret: payload carries a synthetic fixture secret (never a real credential);
316
- // host-blind usage → UNKNOWN. TASK-004 leak tests scan for the marker string.
317
- traces.push(
318
- buildTrace(ctx, {
319
- scenario: 'secret',
320
- privacyClass: 'sensitive',
321
- build: ({ traceId, executionId, execSpanId }) => {
322
- const resource = { value: null, source: 'UNKNOWN' };
323
- const attempts = modelAttempt(ctx, {
324
- traceId, parentSpanId: execSpanId, executionId,
325
- attemptIndex: 1, status: 'error', resource, privacyClass: 'sensitive',
326
- extra: {
327
- error_code: 'AUTH_FAILED',
328
- error_detail: 'credential rejected: FIXTURE_SECRET_DO_NOT_LEAK_7f3a9c',
329
- },
330
- });
331
- const end = execEnd(ctx, {
332
- traceId, spanId: execSpanId, executionId, status: 'failed',
333
- privacyClass: 'sensitive', extra: { outcome: 'failure', error_code: 'AUTH_FAILED' },
334
- });
335
- return { records: [...attempts, end], attempts, succeeded: false };
336
- },
337
- }),
338
- );
339
-
340
- // memory_outcome: retrieval.query → memory.retrieved hits → model →
341
- // memory.write → execution.completed → outcome.observed — metadata-only
342
- // payloads (counts, kinds, opaque refs), never content.
343
- traces.push(
344
- buildTrace(ctx, {
345
- scenario: 'memory_outcome',
346
- privacyClass: 'sensitive',
347
- build: ({ traceId, executionId, execSpanId }) => {
348
- const query = record(ctx, {
349
- semanticName: 'retrieval.query',
350
- traceId, spanId: ctx.spanId(), parentSpanId: execSpanId, executionId,
351
- importance: 'normal', privacyClass: 'internal',
352
- payload: {
353
- lane: 'v2', scope: 'all', indexed: true,
354
- candidate_count: ctx.tokens(10, 40), returned_count: 3,
355
- duration_ms: ctx.tokens(5, 20), freshness: {},
356
- },
357
- dtMs: 8,
358
- });
359
- const retrieved = [
360
- { kind: 'fact' }, { kind: 'decision' }, { kind: 'decision' },
361
- ].map((hit) => record(ctx, {
362
- semanticName: 'memory.retrieved',
363
- traceId, spanId: ctx.spanId(), parentSpanId: execSpanId, executionId,
364
- importance: 'normal', privacyClass: 'sensitive',
365
- payload: { kind: hit.kind, record_ref: `memref-${ctx.tokens(1000, 9999)}` },
366
- dtMs: 4,
367
- }));
368
- const resource = { value: ctx.tokens(800, 1200), source: 'PROVIDER' };
369
- const attempts = modelAttempt(ctx, {
370
- traceId, parentSpanId: execSpanId, executionId,
371
- attemptIndex: 1, status: 'ok', resource, privacyClass: 'public',
372
- });
373
- const write = record(ctx, {
374
- semanticName: 'memory.write',
375
- traceId, spanId: ctx.spanId(), parentSpanId: execSpanId, executionId,
376
- importance: 'normal', privacyClass: 'sensitive',
377
- payload: {
378
- op: 'write', record_count: ctx.tokens(1, 5), changed_count: 1,
379
- kinds: { fact: 1 }, record_refs: [`memref-${ctx.tokens(1000, 9999)}`],
380
- tombstone_count: 0,
381
- },
382
- dtMs: 6,
383
- });
384
- const end = execEnd(ctx, {
385
- traceId, spanId: execSpanId, executionId, status: 'completed',
386
- privacyClass: 'public', extra: { outcome: 'success' },
387
- });
388
- const outcome = record(ctx, {
389
- semanticName: 'outcome.observed',
390
- traceId, spanId: ctx.spanId(), parentSpanId: execSpanId, executionId,
391
- importance: 'high', privacyClass: 'internal',
392
- payload: {
393
- kind: 'feedback', verdict: 'accepted', source: 'user',
394
- evidence_refs: [end.record_id],
395
- },
396
- dtMs: 30,
397
- });
398
- return {
399
- records: [query, ...retrieved, ...attempts, write, end, outcome],
400
- attempts,
401
- succeeded: true,
402
- };
403
- },
404
- }),
405
- );
406
-
407
- // multi_agent: one trace spanning two agents — orchestrator runs the
408
- // retrieval query and owns the execution span, worker runs the model
409
- // attempt. agent_id is the v1.1 correlation field; trace_id stays single.
410
- traces.push(
411
- buildTrace(ctx, {
412
- scenario: 'multi_agent',
413
- privacyClass: 'public',
414
- agentId: 'agent-orchestrator',
415
- build: ({ traceId, executionId, execSpanId }) => {
416
- const query = record(ctx, {
417
- semanticName: 'retrieval.query',
418
- traceId, spanId: ctx.spanId(), parentSpanId: execSpanId, executionId,
419
- agentId: 'agent-orchestrator',
420
- importance: 'normal', privacyClass: 'internal',
421
- payload: {
422
- lane: 'v2', scope: 'all', indexed: true,
423
- candidate_count: ctx.tokens(10, 40), returned_count: 2,
424
- duration_ms: ctx.tokens(5, 20), freshness: {},
425
- },
426
- dtMs: 8,
427
- });
428
- const resource = { value: ctx.tokens(800, 1200), source: 'PROVIDER' };
429
- const attempts = modelAttempt(ctx, {
430
- traceId, parentSpanId: execSpanId, executionId,
431
- agentId: 'agent-worker',
432
- attemptIndex: 1, status: 'ok', resource, privacyClass: 'public',
433
- });
434
- const end = execEnd(ctx, {
435
- traceId, spanId: execSpanId, executionId, agentId: 'agent-orchestrator',
436
- status: 'completed', privacyClass: 'public', extra: { outcome: 'success' },
437
- });
438
- return { records: [query, ...attempts, end], attempts, succeeded: true };
439
- },
440
- }),
441
- );
442
-
443
- return {
444
- metric_version: METRIC_VERSION,
445
- seed: SEED,
446
- generated_by: 'scripts/bench/data-foundation.mjs --fixture',
447
- metric_definitions: {
448
- tokens_to_success: {
449
- metric_version: METRIC_VERSION,
450
- unit: 'tokens',
451
- definition:
452
- 'Sum of numeric resource.value across all model attempts in the episode; null when no attempt reported a numeric value or the episode did not succeed.',
453
- },
454
- correction_observed: {
455
- metric_version: METRIC_VERSION,
456
- unit: 'boolean',
457
- definition:
458
- 'True when a user correction was injected mid-episode and the episode required ≥2 model attempts.',
459
- },
460
- },
461
- traces,
462
- };
463
- }
464
-
465
- function writeFixture() {
466
- const corpus = buildCorpus();
467
- fs.mkdirSync(path.dirname(corpusPath), { recursive: true });
468
- fs.writeFileSync(corpusPath, `${JSON.stringify(corpus, null, 2)}\n`);
469
- process.stdout.write(`wrote ${path.relative(repoRoot, corpusPath)} (seed=${SEED}, traces=${corpus.traces.length})\n`);
470
- return 0;
471
- }
472
-
473
- function percentile(sorted, p) {
474
- if (sorted.length === 0) return 0;
475
- const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
476
- return sorted[Math.max(0, idx)];
477
- }
478
-
479
- // --recorder: emit the whole corpus through createRecorder into a temp
480
- // segment root, measure per-emit latency distribution + flush outcome, then
481
- // read the store back to prove the round-trip. Exit 0 on success, 1 when the
482
- // pipeline loses or corrupts records.
483
- async function runRecorderBench() {
484
- const { createRecorder } = await import('../../src/core/observability/emit/recorder.js');
485
- const { readSegments } = await import('../../src/core/observability/segments/readSegments.js');
486
-
487
- const corpus = JSON.parse(fs.readFileSync(corpusPath, 'utf8'));
488
- const inputs = corpus.traces.flatMap((t) => t.records);
489
-
490
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ukit-bench-rec-'));
491
- try {
492
- const recorder = createRecorder({
493
- root,
494
- config: { observability: { stage: 'default' } },
495
- });
496
-
497
- const latenciesMs = [];
498
- let accepted = 0;
499
- let dropped = 0;
500
- for (const record of inputs) {
501
- const t0 = process.hrtime.bigint();
502
- const r = recorder.emit(record);
503
- latenciesMs.push(Number(process.hrtime.bigint() - t0) / 1e6);
504
- if (r.status === 'accepted') accepted += 1;
505
- else dropped += 1;
506
- }
507
-
508
- const flushStart = process.hrtime.bigint();
509
- const flush = await recorder.flush({ deadlineMs: 60_000 });
510
- const flushMs = Number(process.hrtime.bigint() - flushStart) / 1e6;
511
-
512
- let readBack = 0;
513
- const it = readSegments(root);
514
- for await (const _record of it) readBack += 1;
515
-
516
- latenciesMs.sort((a, b) => a - b);
517
- const summary = {
518
- metric_version: METRIC_VERSION,
519
- bench: 'recorder',
520
- records: inputs.length,
521
- accepted,
522
- dropped,
523
- written: flush.written,
524
- read_back: readBack,
525
- flush_status: flush.status,
526
- flush_ms: Number(flushMs.toFixed(3)),
527
- emit_latency_ms: {
528
- p50: Number(percentile(latenciesMs, 50).toFixed(4)),
529
- p95: Number(percentile(latenciesMs, 95).toFixed(4)),
530
- p99: Number(percentile(latenciesMs, 99).toFixed(4)),
531
- max: Number((latenciesMs[latenciesMs.length - 1] || 0).toFixed(4)),
532
- },
533
- health: recorder.health(),
534
- };
535
- process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
536
- const okRun = flush.status === 'ok' && readBack === accepted && dropped === 0;
537
- return okRun ? 0 : 1;
538
- } finally {
539
- fs.rmSync(root, { recursive: true, force: true });
540
- }
541
- }
542
-
543
- // --analytics: emit the corpus through createRecorder into a temp segment
544
- // root, rebuild the derived index from retained facts, and print the
545
- // per-trace summaries + coverage denominators. Exit 0 when the rebuild is
546
- // complete and equivalent to direct summarization of the corpus records.
547
- async function runAnalyticsBench() {
548
- const { createRecorder } = await import('../../src/core/observability/emit/recorder.js');
549
- const { rebuildIndex } = await import('../../src/core/observability/analytics/rebuild.js');
550
- const { summarizeTrace } = await import('../../src/core/observability/analytics/summary.js');
551
-
552
- const corpus = JSON.parse(fs.readFileSync(corpusPath, 'utf8'));
553
- const inputs = corpus.traces.flatMap((t) => t.records);
554
-
555
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ukit-bench-analytics-'));
556
- try {
557
- const recorder = createRecorder({
558
- root,
559
- config: { observability: { stage: 'default' } },
560
- });
561
- for (const record of inputs) recorder.emit(record);
562
- const flush = await recorder.flush({ deadlineMs: 60_000 });
563
-
564
- const started = process.hrtime.bigint();
565
- const index = await rebuildIndex(root);
566
- const rebuildMs = Number(process.hrtime.bigint() - started) / 1e6;
567
-
568
- // Equivalence: rebuild-from-retained-facts must reproduce the same
569
- // summaries as direct analysis of the corpus records (sanitization only
570
- // rewrites sensitive free-text, never metric fields).
571
- const direct = corpus.traces
572
- .map((t) => summarizeTrace(t.records))
573
- .sort((a, b) => a.trace_id.localeCompare(b.trace_id));
574
- const equivalent = JSON.stringify(index.summaries) === JSON.stringify(direct);
575
-
576
- const report = {
577
- metric_version: METRIC_VERSION,
578
- bench: 'analytics',
579
- records: inputs.length,
580
- flush_status: flush.status,
581
- rebuild_ms: Number(rebuildMs.toFixed(3)),
582
- equivalent_to_direct: equivalent,
583
- coverage: index.coverage,
584
- summaries: index.summaries,
585
- };
586
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
587
- const okRun = flush.status === 'ok' && index.ok && equivalent
588
- && index.summaries.length === corpus.traces.length;
589
- return okRun ? 0 : 1;
590
- } finally {
591
- fs.rmSync(root, { recursive: true, force: true });
592
- }
593
- }
594
-
595
- // --evaluate: replay the golden case set under every pre-registered policy,
596
- // print each Scorecard plus the paired comparison (fixed-seed bootstrap
597
- // variance). Exit 0 when no cohort verdict is 'fail'; 1 on fail verdict or
598
- // an unloadable corpus. Evaluation is offline — no model calls, no writes.
599
- async function runEvaluateBench() {
600
- const { evaluateVariant, compareScorecards } = await import(
601
- '../../src/core/observability/evaluation/scorecard.js'
602
- );
603
-
604
- const goldenPath = path.join(repoRoot, 'tests/fixtures/observability/golden/cases.json');
605
- let corpus;
606
- try {
607
- corpus = JSON.parse(fs.readFileSync(goldenPath, 'utf8'));
608
- } catch (err) {
609
- process.stderr.write(`--evaluate: cannot load ${goldenPath}: ${err && err.message}\n`);
610
- return 1;
611
- }
612
-
613
- const policies = Object.keys(
614
- (corpus.pre_registered && corpus.pre_registered.policies) || {},
615
- ).sort();
616
- if (policies.length === 0) {
617
- process.stderr.write('--evaluate: corpus declares no pre_registered.policies\n');
618
- return 1;
619
- }
620
-
621
- const scorecards = {};
622
- for (const policyId of policies) {
623
- scorecards[policyId] = evaluateVariant(corpus, policyId);
624
- }
625
- const comparison = policies.length >= 2
626
- ? compareScorecards(scorecards[policies[0]], scorecards[policies[1]], { seed: SEED })
627
- : null;
628
-
629
- const report = {
630
- metric_version: METRIC_VERSION,
631
- bench: 'evaluate',
632
- seed: SEED,
633
- corpus: path.relative(repoRoot, goldenPath),
634
- policies,
635
- scorecards,
636
- comparison,
637
- };
638
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
639
- const failed = Object.values(scorecards).some((sc) => sc.verdict === 'fail');
640
- return failed ? 1 : 0;
641
- }
642
-
643
- // --perturb: summarize the corpus, inject seeded synthetic mutations via
644
- // mutateSummaries, then measure detection through the real
645
- // computeFingerprint/detectOpportunities path. Exit 0 when every injected
646
- // mutation is detected; 1 when missed[] is non-empty or the corpus is
647
- // unloadable. Deterministic at SEED; evaluation is offline — no model calls.
648
- export async function runPerturb({ quiet = false } = {}) {
649
- const { summarizeTrace } = await import('../../src/core/observability/analytics/summary.js');
650
- const { mutateSummaries, detectPerturbations } = await import(
651
- '../../src/core/observability/evaluation/perturbation.js'
652
- );
653
-
654
- let corpus;
655
- try {
656
- corpus = JSON.parse(fs.readFileSync(corpusPath, 'utf8'));
657
- } catch (err) {
658
- process.stderr.write(`--perturb: cannot load ${corpusPath}: ${err && err.message}\n`);
659
- return { code: 1, report: null };
660
- }
661
-
662
- const baseline = corpus.traces.map((t) => summarizeTrace(t.records));
663
- const { summaries: mutated, manifest } = mutateSummaries(baseline, { seed: SEED });
664
- const outcome = detectPerturbations(mutated, baseline, manifest);
665
-
666
- const report = {
667
- metric_version: METRIC_VERSION,
668
- bench: 'perturb',
669
- seed: SEED,
670
- corpus: path.relative(repoRoot, corpusPath),
671
- injected: outcome.injected,
672
- detected: outcome.detected,
673
- missed: outcome.missed,
674
- manifest,
675
- };
676
- if (!quiet) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
677
- return { code: outcome.missed.length > 0 ? 1 : 0, report };
678
- }
679
-
680
- // --anomalies: replay every golden case under each pre-registered policy,
681
- // summarize each materialized run, and report per-case anomaly detection
682
- // counts keyed by detector kind. The detector is imported defensively:
683
- // while src/core/observability/analytics/anomalies.js (TASK-011) does not
684
- // exist the report degrades to `detector: 'unavailable'` — never a stub.
685
- // When a detector IS available, per-case declarations in
686
- // `expect.anomalies.expected_kinds` act as a regression gate: unmet or
687
- // over-fired kinds exit 1. Evaluation is offline — no model calls.
688
- async function loadAnomalyDetector() {
689
- try {
690
- const mod = await import('../../src/core/observability/analytics/anomalies.js');
691
- return typeof mod.detectAnomalies === 'function' ? mod.detectAnomalies : null;
692
- } catch {
693
- return null;
694
- }
695
- }
696
-
697
- function anomalyKind(record) {
698
- const kind = record && record.payload && record.payload.kind !== undefined
699
- ? record.payload.kind
700
- : record && record.kind;
701
- return typeof kind === 'string' && kind.length > 0 ? kind : 'unknown';
702
- }
703
-
704
- async function runAnomaliesBench() {
705
- const { replayCase } = await import('../../src/core/observability/evaluation/replay.js');
706
- const { summarizeTrace } = await import('../../src/core/observability/analytics/summary.js');
707
- const detectAnomalies = await loadAnomalyDetector();
708
-
709
- const goldenPath = path.join(repoRoot, 'tests/fixtures/observability/golden/cases.json');
710
- let corpus;
711
- try {
712
- corpus = JSON.parse(fs.readFileSync(goldenPath, 'utf8'));
713
- } catch (err) {
714
- process.stderr.write(`--anomalies: cannot load ${goldenPath}: ${err && err.message}\n`);
715
- return 1;
716
- }
717
- const policies = Object.keys(
718
- (corpus.pre_registered && corpus.pre_registered.policies) || {},
719
- ).sort();
720
- if (policies.length === 0) {
721
- process.stderr.write('--anomalies: corpus declares no pre_registered.policies\n');
722
- return 1;
723
- }
724
-
725
- const runs = [];
726
- for (const goldenCase of corpus.cases || []) {
727
- const expectedKinds = goldenCase && goldenCase.expect && goldenCase.expect.anomalies
728
- && Array.isArray(goldenCase.expect.anomalies.expected_kinds)
729
- ? goldenCase.expect.anomalies.expected_kinds
730
- : null;
731
- const run = { case_id: goldenCase && goldenCase.case_id, expected_kinds: expectedKinds, policies: {} };
732
- for (const policyId of policies) {
733
- const replay = replayCase(goldenCase, { policyId });
734
- if (!replay.ok) {
735
- run.policies[policyId] = { ok: false, reason: replay.reason };
736
- continue;
737
- }
738
- const summary = summarizeTrace(replay.records);
739
- run.policies[policyId] = {
740
- ok: true,
741
- trace_id: summary.trace_id,
742
- summary,
743
- record_ids: replay.records.map((r) => r.record_id),
744
- };
745
- }
746
- runs.push(run);
747
- }
748
-
749
- // Detector wiring: one call over ALL materialized summaries (detectors are
750
- // cohort-relative), then attribution back to cases by trace_id or any
751
- // evidence_ref that resolves to a materialized record_id.
752
- const summaries = runs.flatMap((run) => policies
753
- .map((policyId) => run.policies[policyId])
754
- .filter((entry) => entry && entry.ok)
755
- .map((entry) => entry.summary));
756
-
757
- const byCase = new Map(runs.map((run) => [run.case_id, new Map()]));
758
- const byTrace = new Map();
759
- const byRecordId = new Map();
760
- for (const run of runs) {
761
- for (const policyId of policies) {
762
- const entry = run.policies[policyId];
763
- if (!entry || !entry.ok) continue;
764
- if (typeof entry.trace_id === 'string') byTrace.set(entry.trace_id, [run.case_id, policyId]);
765
- for (const recordId of entry.record_ids) byRecordId.set(recordId, [run.case_id, policyId]);
766
- }
767
- }
768
-
769
- let detection = null;
770
- if (detectAnomalies) {
771
- const anomalies = detectAnomalies(summaries) || [];
772
- const unattributed = [];
773
- for (const anomaly of anomalies) {
774
- const refs = anomaly && anomaly.payload && Array.isArray(anomaly.payload.evidence_refs)
775
- ? anomaly.payload.evidence_refs
776
- : [];
777
- const traceRef = anomaly && anomaly.payload && typeof anomaly.payload.trace_ref === 'string'
778
- ? anomaly.payload.trace_ref
779
- : (anomaly && typeof anomaly.trace_id === 'string' ? anomaly.trace_id : null);
780
- let target = traceRef ? byTrace.get(traceRef) : null;
781
- if (!target) {
782
- for (const ref of refs) {
783
- target = byRecordId.get(ref);
784
- if (target) break;
785
- }
786
- }
787
- if (!target) { unattributed.push(anomaly); continue; }
788
- const [caseId, policyId] = target;
789
- const policyMap = byCase.get(caseId);
790
- if (!policyMap.has(policyId)) policyMap.set(policyId, {});
791
- const counts = policyMap.get(policyId);
792
- const kind = anomalyKind(anomaly);
793
- counts[kind] = (counts[kind] || 0) + 1;
794
- }
795
- detection = { anomalies, unattributed };
796
- }
797
- // Per-case signals — the summary fields the detectors consume (TASK-011):
798
- // attempt/retry counters, cache posture, drops, critical path. Reported
799
- // per policy run so a detector regression is attributable to a case.
800
- function signalsOf(entry) {
801
- const s = entry && entry.summary;
802
- if (!s) return null;
803
- return {
804
- model_attempts: s.retries.model_attempts,
805
- retries: s.retries.retries,
806
- failed_spans: s.retries.failed_spans,
807
- cache_misses: s.cache.misses,
808
- cache_hits: s.cache.hits,
809
- hit_rate: s.cache.hit_rate,
810
- dropped_events: s.drops.events,
811
- dropped_count: s.drops.dropped_count,
812
- critical_path_ms: s.critical_path_ms,
813
- telemetry_complete: s.telemetry_complete,
814
- spans: s.coverage.spans,
815
- };
816
- }
817
-
818
- const casesReport = {};
819
- for (const run of runs) {
820
- const signals = {};
821
- for (const policyId of policies) {
822
- signals[policyId] = signalsOf(run.policies[policyId]);
823
- }
824
- let anomalies = null;
825
- if (detection) {
826
- anomalies = 0;
827
- for (const counts of byCase.get(run.case_id)?.values() || []) {
828
- for (const n of Object.values(counts)) anomalies += n;
829
- }
830
- }
831
- casesReport[run.case_id] = { signals, anomalies };
832
- }
833
-
834
- // Regression gate — only meaningful when a real detector ran. A case that
835
- // declares expect.anomalies.expected_kinds must see those kinds fire; a
836
- // case declaring an empty list must stay clean.
837
- const unmet = [];
838
- if (detection) {
839
- for (const run of runs) {
840
- if (!Array.isArray(run.expected_kinds)) continue;
841
- for (const [policyId, counts] of byCase.get(run.case_id) || []) {
842
- for (const kind of run.expected_kinds) {
843
- if (!counts[kind]) {
844
- unmet.push({ case_id: run.case_id, policy: policyId, kind, expected: '>=1', got: 0 });
845
- }
846
- }
847
- for (const kind of Object.keys(counts)) {
848
- if (!run.expected_kinds.includes(kind)) {
849
- unmet.push({ case_id: run.case_id, policy: policyId, kind, expected: 'absent', got: counts[kind] });
850
- }
851
- }
852
- }
853
- }
854
- unmet.sort((a, b) => String(a.case_id).localeCompare(String(b.case_id))
855
- || a.policy.localeCompare(b.policy) || a.kind.localeCompare(b.kind));
856
- }
857
-
858
- const report = {
859
- metric_version: METRIC_VERSION,
860
- bench: 'anomalies',
861
- seed: SEED,
862
- corpus: path.relative(repoRoot, goldenPath),
863
- policies,
864
- detector: detectAnomalies ? 'detectAnomalies' : 'unavailable',
865
- summaries: summaries.length,
866
- anomalies_total: detection ? detection.anomalies.length : null,
867
- unattributed: detection ? detection.unattributed.length : null,
868
- unmet_expectations: detection ? unmet : null,
869
- cases: casesReport,
870
- };
871
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
872
- if (!detection) return 0; // degraded — nothing to gate on
873
- return unmet.length > 0 ? 1 : 0;
874
- }
875
-
876
- const BENCH_FLAGS = Object.freeze([
877
- '--fixture',
878
- '--recorder',
879
- '--analytics',
880
- '--evaluate',
881
- '--perturb',
882
- '--anomalies',
883
- ]);
884
-
885
- async function runFlag(flag) {
886
- switch (flag) {
887
- case '--fixture': return writeFixture();
888
- case '--recorder': return runRecorderBench();
889
- case '--analytics': return runAnalyticsBench();
890
- case '--evaluate': return runEvaluateBench();
891
- case '--perturb': return runPerturb().then(({ code }) => code);
892
- case '--anomalies': return runAnomaliesBench();
893
- default:
894
- process.stderr.write(`unknown flag: ${flag}\n`);
895
- return 2;
896
- }
897
- }
898
-
899
- // Flags run in argument order; the exit code is the first non-zero result.
900
- // (PLAN §5 verification chains --recorder --analytics --evaluate.)
901
- async function main(argv) {
902
- const flags = argv.slice(2).filter((arg) => arg.startsWith('--'));
903
- if (flags.length === 0) {
904
- process.stderr.write(
905
- `usage: node scripts/bench/data-foundation.mjs ${BENCH_FLAGS.join('|')}\n`,
906
- );
907
- return 2;
908
- }
909
- let firstFailure = 0;
910
- for (const flag of flags) {
911
- try {
912
- const code = await runFlag(flag);
913
- if (code !== 0 && firstFailure === 0) firstFailure = code;
914
- } catch (err) {
915
- process.stderr.write(`${flag} failed: ${err && err.message}\n`);
916
- if (firstFailure === 0) firstFailure = 1;
917
- }
918
- }
919
- return firstFailure;
920
- }
921
- const invokedAs = process.argv[1] ? path.resolve(process.argv[1]) : '';
922
- if (invokedAs === fileURLToPath(import.meta.url)) {
923
- // Set the code then let the loop drain stdout — process.exit() hard-kills
924
- // pending pipe writes, which truncates reports past the 64 KiB buffer.
925
- Promise.resolve(main(process.argv)).then((code) => {
926
- process.exitCode = code;
927
- });
928
- }
929
-