@ngockhoale/ukit 3.0.5 → 3.0.7

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 (43) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +1 -1
  3. package/scripts/bench/data-foundation.mjs +562 -0
  4. package/src/core/observability/adapters/common.js +75 -0
  5. package/src/core/observability/adapters/contextAdapter.js +55 -0
  6. package/src/core/observability/adapters/decisionAdapter.js +61 -0
  7. package/src/core/observability/adapters/routeAdapter.js +135 -0
  8. package/src/core/observability/analytics/digest.js +186 -0
  9. package/src/core/observability/analytics/fingerprints.js +126 -0
  10. package/src/core/observability/analytics/opportunities.js +329 -0
  11. package/src/core/observability/analytics/rebuild.js +56 -0
  12. package/src/core/observability/analytics/summary.js +298 -0
  13. package/src/core/observability/emit/config.js +29 -0
  14. package/src/core/observability/emit/recorder.js +297 -0
  15. package/src/core/observability/evaluation/aiPacket.js +230 -0
  16. package/src/core/observability/evaluation/optimizationKnowledge.js +172 -0
  17. package/src/core/observability/evaluation/replay.js +143 -0
  18. package/src/core/observability/evaluation/scorecard.js +445 -0
  19. package/src/core/observability/privacy/allowlist.js +185 -0
  20. package/src/core/observability/privacy/redaction.js +113 -0
  21. package/src/core/observability/privacy/sanitizeForSupport.js +133 -0
  22. package/src/core/observability/privacy/sanitizeObserved.js +134 -0
  23. package/src/core/observability/rollout.js +155 -0
  24. package/src/core/observability/schema/constants.js +66 -0
  25. package/src/core/observability/schema/registry.js +223 -0
  26. package/src/core/observability/schema/validate.js +227 -0
  27. package/src/core/observability/segments/internal.js +241 -0
  28. package/src/core/observability/segments/readSegments.js +215 -0
  29. package/src/core/observability/segments/recovery.js +123 -0
  30. package/src/core/observability/segments/retention.js +381 -0
  31. package/src/core/observability/support/import.js +402 -0
  32. package/src/core/observability/support/manifest.js +135 -0
  33. package/src/core/observability/support/paths.js +94 -0
  34. package/src/core/observability/support/projector.js +483 -0
  35. package/src/core/observability/support/renderer.js +130 -0
  36. package/src/core/observability/support/retention.js +155 -0
  37. package/src/core/runtimeConfig.js +6 -3
  38. package/src/decision/client.js +11 -3
  39. package/template_project/.claude/ukit/index/unic-decision.mjs +6 -2
  40. package/template_project/.omp/RULES.md +6 -6
  41. package/template_project/.omp/config.yml +6 -0
  42. package/template_project/docs/UKIT_INTERNALS.md +9 -0
  43. package/template_project/instructions/overlays/omp-rules.md +6 -6
@@ -0,0 +1,329 @@
1
+ /**
2
+ * opportunities.js (TASK-012, SPEC §5 DF-FR09/DF-FR12, §8) — hypothesis backlog.
3
+ *
4
+ * detectOpportunities(summaries): Opportunity[]
5
+ *
6
+ * Groups TraceSummaries by structural fingerprint, stratifies each cluster
7
+ * into cohorts, and ranks optimization CANDIDATES by measured critical-path
8
+ * impact — never by largest standalone duration. Output is a hypothesis
9
+ * backlog for human/evaluator review (TASK-013 persists it); nothing here
10
+ * may auto-modify router, memory, or runtime config.
11
+ *
12
+ * Opportunity = {
13
+ * id, fingerprint, frequency, cohorts[],
14
+ * estimated_savings: { value, method },
15
+ * quality_risk, coverage, evidence_refs[], trace_refs[],
16
+ * confounded, status: 'hypothesis' | 'inconclusive'
17
+ * }
18
+ *
19
+ * Honesty rules (SPEC §7/§12):
20
+ * - Savings are measured, not asserted: only the critical-path-attributable
21
+ * duration of pattern spans is claimed, and the `method` field says how.
22
+ * - A cluster below MIN_COHORT_TRACES or containing any trace with
23
+ * telemetry_complete=false is `inconclusive`: no improvement claim is
24
+ * populated and it never ranks above a conclusive item.
25
+ * - A cluster mixing cohort keys (model/version) is `confounded: true` —
26
+ * Simpson's-paradox guard: only per-cohort numbers are trustworthy, the
27
+ * aggregate is flagged, and it ranks below unconfounded items.
28
+ * - Every evidence ref must resolve to a real record ID carried by the
29
+ * input summaries; a pattern span with no resolvable refs is a contract
30
+ * violation → TypeError, never a dangling citation.
31
+ */
32
+
33
+ import { computeFingerprint } from './fingerprints.js';
34
+
35
+ export const MIN_COHORT_TRACES = 3;
36
+
37
+ const QUALITY_RISK = Object.freeze({
38
+ dropped: 'high',
39
+ failure: 'high',
40
+ retry: 'medium',
41
+ 'cache-miss': 'medium',
42
+ clean: 'low',
43
+ });
44
+
45
+ const SAVINGS_METHOD = 'critical_path_attributed_ms';
46
+ const NO_CLAIM_METHOD = 'none — insufficient coverage';
47
+
48
+ function isPlainObject(value) {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
50
+ }
51
+
52
+ function num(value) {
53
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
54
+ }
55
+
56
+ /**
57
+ * Recompute the causal critical path over a summary's spans (same rules as
58
+ * summarizeTrace) and return the set of span_ids that lie on it. A node is
59
+ * on the path when its own measured duration is the winning contribution,
60
+ * or when the winning contribution flows through one of its children.
61
+ */
62
+ function criticalPathSpanIds(summary) {
63
+ const spans = summary.spans.filter(isPlainObject);
64
+ const byId = new Map(spans.map((s) => [s.span_id, s]));
65
+ const children = new Map();
66
+ const roots = [];
67
+ for (const span of spans) {
68
+ if (typeof span.parent_span_id === 'string' && byId.has(span.parent_span_id)) {
69
+ const list = children.get(span.parent_span_id) || [];
70
+ list.push(span);
71
+ children.set(span.parent_span_id, list);
72
+ } else {
73
+ roots.push(span);
74
+ }
75
+ }
76
+
77
+ const memo = new Map();
78
+ function pathOf(span) {
79
+ if (memo.has(span.span_id)) return memo.get(span.span_id);
80
+ let best = typeof span.duration_ms === 'number' ? span.duration_ms : null;
81
+ let minStart = Infinity;
82
+ let maxEnd = -Infinity;
83
+ for (const kid of children.get(span.span_id) || []) {
84
+ const kidPath = pathOf(kid);
85
+ if (kidPath !== null && (best === null || kidPath > best)) best = kidPath;
86
+ if (typeof kid.start_ns === 'number' && typeof kid.end_ns === 'number') {
87
+ if (kid.start_ns < minStart) minStart = kid.start_ns;
88
+ if (kid.end_ns > maxEnd) maxEnd = kid.end_ns;
89
+ }
90
+ }
91
+ if (maxEnd >= minStart) {
92
+ const extent = (maxEnd - minStart) / 1e6;
93
+ if (best === null || extent > best) best = extent;
94
+ }
95
+ memo.set(span.span_id, best);
96
+ return best;
97
+ }
98
+
99
+ const onPath = new Set();
100
+ function mark(span) {
101
+ const kids = children.get(span.span_id) || [];
102
+ const own = typeof span.duration_ms === 'number' ? span.duration_ms : null;
103
+ let bestKid = null;
104
+ let bestKidPath = null;
105
+ let minStart = Infinity;
106
+ let maxEnd = -Infinity;
107
+ for (const kid of kids) {
108
+ const kidPath = pathOf(kid);
109
+ if (kidPath !== null && (bestKidPath === null || kidPath > bestKidPath)) {
110
+ bestKidPath = kidPath;
111
+ bestKid = kid;
112
+ }
113
+ if (typeof kid.start_ns === 'number' && typeof kid.end_ns === 'number') {
114
+ if (kid.start_ns < minStart) minStart = kid.start_ns;
115
+ if (kid.end_ns > maxEnd) maxEnd = kid.end_ns;
116
+ }
117
+ }
118
+ const extent = maxEnd >= minStart ? (maxEnd - minStart) / 1e6 : null;
119
+ const winner = pathOf(span);
120
+ if (winner === null) return;
121
+ onPath.add(span.span_id);
122
+ // Deterministic precedence on ties: own duration, then child path,
123
+ // then wall-clock extent (which marks every measured child).
124
+ if (own !== null && own === winner) return;
125
+ if (bestKid !== null && bestKidPath === winner) {
126
+ mark(bestKid);
127
+ return;
128
+ }
129
+ if (extent !== null && extent === winner) {
130
+ for (const kid of kids) {
131
+ if (typeof kid.start_ns === 'number' && typeof kid.end_ns === 'number') mark(kid);
132
+ }
133
+ }
134
+ }
135
+
136
+ for (const root of roots) mark(root);
137
+ return onPath;
138
+ }
139
+
140
+ /** The single largest measured NON-ROOT span — the candidate for patterns
141
+ * that are not attributable to a specific span kind (clean, cache-miss,
142
+ * dropped). A root span's duration is the trace's own denominator: claiming
143
+ * it as savings would double-count its children and assert the whole
144
+ * episode is waste. Falls back to the dominant root only when the trace
145
+ * has no measured child spans at all. */
146
+ function dominantSpan(summary) {
147
+ let best = null;
148
+ let bestRoot = null;
149
+ for (const span of summary.spans) {
150
+ if (!isPlainObject(span) || typeof span.duration_ms !== 'number') continue;
151
+ if (typeof span.parent_span_id === 'string' && span.parent_span_id.length > 0) {
152
+ if (best === null || span.duration_ms > best.duration_ms) best = span;
153
+ } else if (bestRoot === null || span.duration_ms > bestRoot.duration_ms) {
154
+ bestRoot = span;
155
+ }
156
+ }
157
+ return best || bestRoot;
158
+ }
159
+
160
+
161
+ /**
162
+ * Spans that embody the fingerprint's pattern. Savings are claimed only for
163
+ * pattern spans that also lie on the critical path — an expensive parallel
164
+ * branch contributes zero.
165
+ */
166
+ function patternSpans(summary, kind) {
167
+ const spans = summary.spans.filter(isPlainObject);
168
+ switch (kind) {
169
+ case 'retry':
170
+ return spans.filter((s) => Number.isInteger(s.attempt_index) && s.attempt_index > 1);
171
+ case 'failure':
172
+ return spans.filter((s) => s.status === 'failed');
173
+ default: {
174
+ const dominant = dominantSpan(summary);
175
+ return dominant ? [dominant] : [];
176
+ }
177
+ }
178
+ }
179
+
180
+ function recordRefsOf(span) {
181
+ const refs = [];
182
+ if (typeof span.start_record_id === 'string' && span.start_record_id.length > 0) {
183
+ refs.push(span.start_record_id);
184
+ }
185
+ if (typeof span.end_record_id === 'string' && span.end_record_id.length > 0) {
186
+ refs.push(span.end_record_id);
187
+ }
188
+ return refs;
189
+ }
190
+
191
+ /** Cohort key: model-span operation (the version axis adapters emit) plus
192
+ * the metric version that produced the summary. */
193
+ function cohortKeyOf(summary) {
194
+ const modelOps = new Set();
195
+ for (const span of summary.spans) {
196
+ if (isPlainObject(span) && span.family === 'model' && typeof span.operation === 'string') {
197
+ modelOps.add(span.operation);
198
+ }
199
+ }
200
+ const model = modelOps.size === 1 ? [...modelOps][0] : modelOps.size === 0 ? 'unknown' : 'mixed';
201
+ const version = typeof summary.metric_version === 'string' ? summary.metric_version : 'unknown';
202
+ return `${model}|${version}`;
203
+ }
204
+
205
+ function savingsOf(summary, kind, onPath) {
206
+ let total = 0;
207
+ for (const span of patternSpans(summary, kind)) {
208
+ if (onPath.has(span.span_id) && typeof span.duration_ms === 'number') {
209
+ total += span.duration_ms;
210
+ }
211
+ }
212
+ return total;
213
+ }
214
+
215
+ export function detectOpportunities(summaries) {
216
+ if (!Array.isArray(summaries)) {
217
+ throw new TypeError('detectOpportunities: summaries must be an array of TraceSummary');
218
+ }
219
+ for (const summary of summaries) {
220
+ if (!isPlainObject(summary) || !Array.isArray(summary.spans)) {
221
+ throw new TypeError('detectOpportunities: each summary must be a TraceSummary object with a spans array');
222
+ }
223
+ }
224
+
225
+ // Cluster by structural fingerprint.
226
+ const clusters = new Map();
227
+ for (const summary of summaries) {
228
+ const fingerprint = computeFingerprint(summary);
229
+ let cluster = clusters.get(fingerprint.hash);
230
+ if (!cluster) {
231
+ cluster = { fingerprint, summaries: [] };
232
+ clusters.set(fingerprint.hash, cluster);
233
+ }
234
+ cluster.summaries.push(summary);
235
+ }
236
+
237
+ const opportunities = [];
238
+ for (const cluster of clusters.values()) {
239
+ const { fingerprint } = cluster;
240
+ const kind = fingerprint.kind;
241
+
242
+ const traceRefs = [];
243
+ const evidenceRefs = [];
244
+ const cohortsByKey = new Map();
245
+ let sampled = 0;
246
+ let totalSavings = 0;
247
+
248
+ for (const summary of cluster.summaries) {
249
+ if (typeof summary.trace_id === 'string') traceRefs.push(summary.trace_id);
250
+ if (summary.telemetry_complete !== true) sampled += 1;
251
+
252
+ const onPath = criticalPathSpanIds(summary);
253
+ const savings = savingsOf(summary, kind, onPath);
254
+ totalSavings += savings;
255
+
256
+ const refs = [];
257
+ for (const span of patternSpans(summary, kind)) {
258
+ refs.push(...recordRefsOf(span));
259
+ }
260
+ if (refs.length === 0) {
261
+ throw new TypeError(
262
+ `detectOpportunities: dangling evidence — pattern spans of trace ` +
263
+ `${summary.trace_id ?? 'unknown'} carry no resolvable record IDs`
264
+ );
265
+ }
266
+ evidenceRefs.push(...refs);
267
+
268
+ const key = cohortKeyOf(summary);
269
+ let cohort = cohortsByKey.get(key);
270
+ if (!cohort) {
271
+ cohort = { key, traces: 0, sampled: 0, estimated_savings: { value: 0, method: SAVINGS_METHOD } };
272
+ cohortsByKey.set(key, cohort);
273
+ }
274
+ cohort.traces += 1;
275
+ if (summary.telemetry_complete !== true) cohort.sampled += 1;
276
+ cohort.estimated_savings.value += savings;
277
+ }
278
+
279
+ const cohorts = [...cohortsByKey.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
280
+ const confounded = cohorts.length > 1;
281
+ const inconclusive = cluster.summaries.length < MIN_COHORT_TRACES || sampled > 0;
282
+
283
+ const coverage = {
284
+ traces: cluster.summaries.length,
285
+ telemetry_complete: cluster.summaries.length - sampled,
286
+ sampled,
287
+ min_traces: MIN_COHORT_TRACES,
288
+ };
289
+
290
+ const estimated_savings = inconclusive
291
+ ? { value: null, method: NO_CLAIM_METHOD }
292
+ : { value: totalSavings, method: SAVINGS_METHOD };
293
+ if (inconclusive) {
294
+ for (const cohort of cohorts) {
295
+ cohort.estimated_savings = { value: null, method: NO_CLAIM_METHOD };
296
+ }
297
+ }
298
+
299
+ opportunities.push({
300
+ id: `opp-${fingerprint.hash}`,
301
+ fingerprint,
302
+ frequency: cluster.summaries.length,
303
+ cohorts,
304
+ estimated_savings,
305
+ quality_risk: QUALITY_RISK[kind] || 'low',
306
+ coverage,
307
+ evidence_refs: [...new Set(evidenceRefs)].sort(),
308
+ trace_refs: traceRefs.sort(),
309
+ confounded,
310
+ status: inconclusive ? 'inconclusive' : 'hypothesis',
311
+ metric_version: fingerprint.metric_version,
312
+ });
313
+ }
314
+
315
+ // Rank: conclusive first, then confounded, then inconclusive; within a
316
+ // tier by measured savings (null last), then id for determinism.
317
+ const rank = (opp) => (opp.status === 'inconclusive' ? 2 : opp.confounded ? 1 : 0);
318
+ opportunities.sort((a, b) => {
319
+ const dr = rank(a) - rank(b);
320
+ if (dr !== 0) return dr;
321
+ const av = a.estimated_savings.value;
322
+ const bv = b.estimated_savings.value;
323
+ const ds = (bv === null ? -1 : bv) - (av === null ? -1 : av);
324
+ if (ds !== 0) return ds;
325
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
326
+ });
327
+
328
+ return opportunities;
329
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * rebuild.js (TASK-008, SPEC §5 DF-FR05/DF-FR09, §8) — derived index rebuild.
3
+ *
4
+ * rebuildIndex(root): { summaries, coverage, ok }
5
+ *
6
+ * Streams every retained record through readSegments (TASK-006), groups by
7
+ * trace_id, and re-derives each TraceSummary from facts alone. Rebuild is
8
+ * recovery, not mutation: it writes nothing and is byte-deterministic over
9
+ * the same retained segments — read order is sealed-seq then active, and
10
+ * summaries are emitted sorted by trace_id.
11
+ *
12
+ * Coverage is the honest denominator: records read, untraced records,
13
+ * traces summarized, plus the reader's own gap counters (corrupt lines,
14
+ * partial tail bytes, quarantined/expired segments, degraded entries).
15
+ * A future schema_version never reaches this layer — the reader rejects it
16
+ * as a corrupt line and the gap shows up in coverage, never in a summary.
17
+ */
18
+
19
+ import { readSegments } from '../segments/readSegments.js';
20
+ import { summarizeTrace } from './summary.js';
21
+
22
+ export async function rebuildIndex(root, opts = {}) {
23
+ const byTrace = new Map();
24
+ let recordsRead = 0;
25
+ let untraced = 0;
26
+
27
+ const stream = readSegments(root, opts);
28
+ for await (const record of stream) {
29
+ recordsRead += 1;
30
+ const traceId = typeof record.trace_id === 'string' && record.trace_id.length > 0
31
+ ? record.trace_id
32
+ : null;
33
+ if (traceId === null) {
34
+ untraced += 1;
35
+ continue;
36
+ }
37
+ const list = byTrace.get(traceId);
38
+ if (list) list.push(record);
39
+ else byTrace.set(traceId, [record]);
40
+ }
41
+
42
+ const summaries = [...byTrace.keys()]
43
+ .sort()
44
+ .map((traceId) => summarizeTrace(byTrace.get(traceId)));
45
+
46
+ return {
47
+ summaries,
48
+ coverage: {
49
+ records_read: recordsRead,
50
+ untraced_records: untraced,
51
+ traces: summaries.length,
52
+ ...stream.coverage,
53
+ },
54
+ ok: stream.ok,
55
+ };
56
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * summary.js (TASK-008, SPEC §5 DF-FR09 / §8) — deterministic trace analyzer.
3
+ *
4
+ * summarizeTrace(records): TraceSummary
5
+ *
6
+ * Pure function over the retained facts of ONE trace. No IO, no clock, no
7
+ * randomness — the same records always produce a byte-identical summary.
8
+ *
9
+ * TraceSummary shape (SPEC §8):
10
+ * { trace_id, spans[], durations{}, critical_path_ms, retries, drops,
11
+ * cache{}, resource{}, telemetry_complete, coverage{}, metric_version }
12
+ *
13
+ * Rules that make this a flight recorder and not a dashboard:
14
+ * - critical_path_ms is CAUSAL: a span contributes its own measured
15
+ * duration_ms; children contribute the max single-branch path and the
16
+ * wall-clock extent they span. Parallel branches are never summed.
17
+ * - Percentiles are reported only when the sample can support them
18
+ * (min n = ceil(1/(1-p/100)): p50→2, p95→20, p99→100). Below the
19
+ * minimum the value is null — never a fabricated number.
20
+ * - Missing host data stays unknown: resource totals are null when no
21
+ * numeric value was reported; cache.hit_rate is null when no cache
22
+ * records exist; telemetry_complete=false on any drop/gap/UNKNOWN.
23
+ */
24
+
25
+ export const METRIC_VERSION = 'df-m1';
26
+
27
+ const STARTED = new Set(['execution.started', 'tool.started', 'model.started']);
28
+ const ENDED_STATUS = Object.freeze({
29
+ 'execution.completed': 'completed',
30
+ 'execution.failed': 'failed',
31
+ 'execution.blocked': 'blocked',
32
+ 'tool.completed': 'completed',
33
+ 'tool.failed': 'failed',
34
+ 'model.completed': 'completed',
35
+ 'model.failed': 'failed',
36
+ });
37
+
38
+ function isPlainObject(value) {
39
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
40
+ }
41
+
42
+ function familyOf(semanticName) {
43
+ const dot = String(semanticName).indexOf('.');
44
+ return dot > 0 ? String(semanticName).slice(0, dot) : 'unknown';
45
+ }
46
+
47
+ /** Deterministic record order: causal clock first, then writer identity. */
48
+ function compareRecords(a, b) {
49
+ const an = typeof a.monotonic_ns === 'number' ? a.monotonic_ns : Infinity;
50
+ const bn = typeof b.monotonic_ns === 'number' ? b.monotonic_ns : Infinity;
51
+ if (an !== bn) return an - bn;
52
+ const boot = String(a.boot_id).localeCompare(String(b.boot_id));
53
+ if (boot !== 0) return boot;
54
+ const writer = String(a.writer_id).localeCompare(String(b.writer_id));
55
+ if (writer !== 0) return writer;
56
+ if (a.sequence !== b.sequence) return a.sequence - b.sequence;
57
+ return String(a.record_id).localeCompare(String(b.record_id));
58
+ }
59
+
60
+ function compareSpans(a, b) {
61
+ const an = typeof a.start_ns === 'number' ? a.start_ns : Infinity;
62
+ const bn = typeof b.start_ns === 'number' ? b.start_ns : Infinity;
63
+ if (an !== bn) return an - bn;
64
+ return a.span_id.localeCompare(b.span_id);
65
+ }
66
+
67
+ /**
68
+ * Nearest-rank percentile with a minimum sample size. Below the minimum the
69
+ * percentile does not exist statistically — return null, never invent one.
70
+ */
71
+ export function percentileOf(sorted, p) {
72
+ const minN = Math.ceil(1 / (1 - p / 100));
73
+ if (!Array.isArray(sorted) || sorted.length < minN) return null;
74
+ const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
75
+ return sorted[Math.max(0, idx)];
76
+ }
77
+
78
+ function durationStats(values) {
79
+ const sorted = values.slice().sort((a, b) => a - b);
80
+ return {
81
+ count: sorted.length,
82
+ min: sorted.length ? sorted[0] : null,
83
+ max: sorted.length ? sorted[sorted.length - 1] : null,
84
+ p50: percentileOf(sorted, 50),
85
+ p95: percentileOf(sorted, 95),
86
+ p99: percentileOf(sorted, 99),
87
+ };
88
+ }
89
+
90
+ export function summarizeTrace(records) {
91
+ const input = Array.isArray(records) ? records.filter(isPlainObject) : [];
92
+ const ordered = input.slice().sort(compareRecords);
93
+
94
+ const traceIds = new Set();
95
+ const spansById = new Map();
96
+ const durationsByFamily = { execution: [], tool: [], model: [] };
97
+ const allDurations = [];
98
+
99
+ const drops = { events: 0, dropped_count: 0, by_reason: {}, redacted: 0, recovered: 0 };
100
+ const cache = { observed: false, hits: 0, misses: 0, hit_rate: null };
101
+ const resource = { total_value: null, numeric_count: 0, unknown_count: 0, sources: {} };
102
+ const retries = { model_attempts: 0, retries: 0, failed_spans: 0 };
103
+ const coverage = {
104
+ records: ordered.length,
105
+ records_with_trace: 0,
106
+ spans: 0,
107
+ open_spans: 0,
108
+ orphan_spans: 0,
109
+ duplicate_ends: 0,
110
+ trace_ids: 0,
111
+ };
112
+
113
+ let explicitIncomplete = false;
114
+
115
+ const spanFor = (spanId) => {
116
+ let span = spansById.get(spanId);
117
+ if (!span) {
118
+ span = {
119
+ span_id: spanId,
120
+ parent_span_id: null,
121
+ family: 'unknown',
122
+ operation: null,
123
+ status: 'open',
124
+ attempt_index: null,
125
+ start_ns: null,
126
+ end_ns: null,
127
+ duration_ms: null,
128
+ start_record_id: null,
129
+ end_record_id: null,
130
+ };
131
+ spansById.set(spanId, span);
132
+ }
133
+ return span;
134
+ };
135
+
136
+ for (const record of ordered) {
137
+ if (typeof record.trace_id === 'string' && record.trace_id.length > 0) {
138
+ traceIds.add(record.trace_id);
139
+ coverage.records_with_trace += 1;
140
+ }
141
+ const payload = isPlainObject(record.payload) ? record.payload : {};
142
+ const name = record.semantic_name;
143
+
144
+ if (payload.telemetry_complete === false) explicitIncomplete = true;
145
+
146
+ // Typed resource usage (DF-FR08): reported on completed/failed records
147
+ // only — counting started records would double-count the same attempt.
148
+ if (ENDED_STATUS[name] && isPlainObject(payload.resource)) {
149
+ const source = typeof payload.resource.source === 'string' ? payload.resource.source : 'UNKNOWN';
150
+ resource.sources[source] = (resource.sources[source] || 0) + 1;
151
+ if (typeof payload.resource.value === 'number' && Number.isFinite(payload.resource.value)) {
152
+ resource.numeric_count += 1;
153
+ resource.total_value = (resource.total_value || 0) + payload.resource.value;
154
+ } else {
155
+ resource.unknown_count += 1;
156
+ }
157
+ }
158
+
159
+ if (name === 'cache.hit') cache.hits += 1;
160
+ if (name === 'cache.miss') cache.misses += 1;
161
+ if (name === 'telemetry.dropped') {
162
+ drops.events += 1;
163
+ const count = typeof payload.dropped_count === 'number' ? payload.dropped_count : 1;
164
+ drops.dropped_count += count;
165
+ const reason = typeof payload.reason_code === 'string' ? payload.reason_code : 'UNSPECIFIED';
166
+ drops.by_reason[reason] = (drops.by_reason[reason] || 0) + count;
167
+ }
168
+ if (name === 'telemetry.redacted') drops.redacted += 1;
169
+ if (name === 'telemetry.recovered') drops.recovered += 1;
170
+
171
+ if (typeof record.span_id !== 'string' || record.span_id.length === 0) continue;
172
+
173
+ if (STARTED.has(name)) {
174
+ const span = spanFor(record.span_id);
175
+ span.family = familyOf(name);
176
+ if (typeof record.parent_span_id === 'string') span.parent_span_id = record.parent_span_id;
177
+ if (typeof record.monotonic_ns === 'number') span.start_ns = record.monotonic_ns;
178
+ if (typeof payload.operation === 'string') span.operation = payload.operation;
179
+ else if (typeof payload.tool === 'string') span.operation = payload.tool;
180
+ if (Number.isInteger(payload.attempt_index)) span.attempt_index = payload.attempt_index;
181
+ span.start_record_id = record.record_id;
182
+ if (span.family === 'model') retries.model_attempts += 1;
183
+ } else if (ENDED_STATUS[name]) {
184
+ const span = spanFor(record.span_id);
185
+ if (span.family === 'unknown') span.family = familyOf(name);
186
+ if (span.parent_span_id === null && typeof record.parent_span_id === 'string') {
187
+ span.parent_span_id = record.parent_span_id;
188
+ }
189
+ if (span.status !== 'open') coverage.duplicate_ends += 1;
190
+ else {
191
+ span.status = ENDED_STATUS[name];
192
+ if (typeof record.monotonic_ns === 'number') span.end_ns = record.monotonic_ns;
193
+ if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) {
194
+ span.duration_ms = payload.duration_ms;
195
+ }
196
+ if (span.attempt_index === null && Number.isInteger(payload.attempt_index)) {
197
+ span.attempt_index = payload.attempt_index;
198
+ if (span.family === 'model') retries.model_attempts += 1;
199
+ }
200
+ span.end_record_id = record.record_id;
201
+ if (span.status === 'failed') retries.failed_spans += 1;
202
+ if (span.duration_ms !== null) {
203
+ allDurations.push(span.duration_ms);
204
+ if (durationsByFamily[span.family]) durationsByFamily[span.family].push(span.duration_ms);
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ const spans = [...spansById.values()].sort(compareSpans);
211
+ coverage.spans = spans.length;
212
+ coverage.trace_ids = traceIds.size;
213
+
214
+ // Causality tree: a parent link that points nowhere is a typed gap.
215
+ const children = new Map();
216
+ const roots = [];
217
+ for (const span of spans) {
218
+ if (span.status === 'open') coverage.open_spans += 1;
219
+ if (typeof span.attempt_index === 'number' && span.attempt_index > 1) {
220
+ retries.retries += 1;
221
+ }
222
+ if (span.parent_span_id && spansById.has(span.parent_span_id)) {
223
+ const list = children.get(span.parent_span_id) || [];
224
+ list.push(span);
225
+ children.set(span.parent_span_id, list);
226
+ } else {
227
+ if (span.parent_span_id) coverage.orphan_spans += 1;
228
+ roots.push(span);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Causal critical path (DF-FR09): a node's path weight is the max of
234
+ * - its own measured duration_ms (explicit only — never derived),
235
+ * - the longest single child branch (parallel children → max, not sum),
236
+ * - the wall-clock extent its children span (sequential children
237
+ * accumulate through their measured intervals, gaps included).
238
+ * A span with no measured duration and no measured children contributes
239
+ * nothing — unknown stays unknown.
240
+ */
241
+ function criticalPath(span) {
242
+ const kids = children.get(span.span_id) || [];
243
+ let best = typeof span.duration_ms === 'number' ? span.duration_ms : null;
244
+ let minStart = Infinity;
245
+ let maxEnd = -Infinity;
246
+ for (const kid of kids) {
247
+ const kidPath = criticalPath(kid);
248
+ if (kidPath !== null && (best === null || kidPath > best)) best = kidPath;
249
+ if (typeof kid.start_ns === 'number' && typeof kid.end_ns === 'number') {
250
+ if (kid.start_ns < minStart) minStart = kid.start_ns;
251
+ if (kid.end_ns > maxEnd) maxEnd = kid.end_ns;
252
+ }
253
+ }
254
+ if (maxEnd >= minStart) {
255
+ const extent = (maxEnd - minStart) / 1e6;
256
+ if (best === null || extent > best) best = extent;
257
+ }
258
+ return best;
259
+ }
260
+
261
+ let critical_path_ms = null;
262
+ for (const root of roots) {
263
+ const path = criticalPath(root);
264
+ if (path !== null && (critical_path_ms === null || path > critical_path_ms)) {
265
+ critical_path_ms = path;
266
+ }
267
+ }
268
+
269
+ const lookups = cache.hits + cache.misses;
270
+ cache.observed = lookups > 0;
271
+ cache.hit_rate = lookups > 0 ? cache.hits / lookups : null;
272
+
273
+ const telemetry_complete =
274
+ !explicitIncomplete &&
275
+ drops.events === 0 &&
276
+ resource.unknown_count === 0 &&
277
+ coverage.open_spans === 0 &&
278
+ coverage.orphan_spans === 0;
279
+
280
+ return {
281
+ trace_id: traceIds.size ? [...traceIds].sort()[0] : null,
282
+ spans,
283
+ durations: {
284
+ overall: durationStats(allDurations),
285
+ execution: durationStats(durationsByFamily.execution),
286
+ tool: durationStats(durationsByFamily.tool),
287
+ model: durationStats(durationsByFamily.model),
288
+ },
289
+ critical_path_ms,
290
+ retries,
291
+ drops,
292
+ cache,
293
+ resource,
294
+ telemetry_complete,
295
+ coverage,
296
+ metric_version: METRIC_VERSION,
297
+ };
298
+ }