@ngockhoale/ukit 3.0.6 → 3.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +562 -0
- package/src/core/observability/adapters/common.js +75 -0
- package/src/core/observability/adapters/contextAdapter.js +55 -0
- package/src/core/observability/adapters/decisionAdapter.js +61 -0
- package/src/core/observability/adapters/routeAdapter.js +135 -0
- package/src/core/observability/analytics/digest.js +186 -0
- package/src/core/observability/analytics/fingerprints.js +126 -0
- package/src/core/observability/analytics/opportunities.js +329 -0
- package/src/core/observability/analytics/rebuild.js +56 -0
- package/src/core/observability/analytics/summary.js +298 -0
- package/src/core/observability/emit/config.js +29 -0
- package/src/core/observability/emit/recorder.js +297 -0
- package/src/core/observability/evaluation/aiPacket.js +230 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +172 -0
- package/src/core/observability/evaluation/replay.js +143 -0
- package/src/core/observability/evaluation/scorecard.js +445 -0
- package/src/core/observability/privacy/allowlist.js +185 -0
- package/src/core/observability/privacy/redaction.js +113 -0
- package/src/core/observability/privacy/sanitizeForSupport.js +133 -0
- package/src/core/observability/privacy/sanitizeObserved.js +134 -0
- package/src/core/observability/rollout.js +155 -0
- package/src/core/observability/schema/constants.js +66 -0
- package/src/core/observability/schema/registry.js +223 -0
- package/src/core/observability/schema/validate.js +227 -0
- package/src/core/observability/segments/internal.js +241 -0
- package/src/core/observability/segments/readSegments.js +215 -0
- package/src/core/observability/segments/recovery.js +123 -0
- package/src/core/observability/segments/retention.js +381 -0
- package/src/core/observability/support/import.js +402 -0
- package/src/core/observability/support/manifest.js +135 -0
- package/src/core/observability/support/paths.js +94 -0
- package/src/core/observability/support/projector.js +483 -0
- package/src/core/observability/support/renderer.js +130 -0
- package/src/core/observability/support/retention.js +155 -0
- package/template_project/.omp/RULES.md +6 -6
- package/template_project/.omp/config.yml +6 -0
- package/template_project/instructions/overlays/omp-rules.md +6 -6
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// contextAdapter.js (TASK-010, SPEC §5 DF-FR07/DF-FR08) — read-only
|
|
2
|
+
// adapter over context-item provenance (retrieval lane / output history).
|
|
3
|
+
// It records which items were selected, injected, and later referenced —
|
|
4
|
+
// kind and opaque ref only, never item content.
|
|
5
|
+
//
|
|
6
|
+
// adaptContextItems(items, options?) → records[]
|
|
7
|
+
//
|
|
8
|
+
// Each item becomes one `context.item.injected` fact. Provenance fields
|
|
9
|
+
// stay distinct: `selected` (chosen by the lane), `injected` (entered the
|
|
10
|
+
// episode), `referenced` (observed in the output). An injected-but-
|
|
11
|
+
// unreferenced item is marked `usage_signal: 'potentially_unused'` with an
|
|
12
|
+
// `evaluator_caveat` — a heuristic signal for the evaluator, never a
|
|
13
|
+
// `wasted` verdict: the host may have consumed the item without leaving a
|
|
14
|
+
// visible reference.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
isPlainObject,
|
|
18
|
+
asString,
|
|
19
|
+
asIsoTime,
|
|
20
|
+
createAdapterContext,
|
|
21
|
+
emitRecord,
|
|
22
|
+
} from './common.js';
|
|
23
|
+
|
|
24
|
+
const UNUSED_CAVEAT =
|
|
25
|
+
'injected-not-referenced is a heuristic signal only; the host may have ' +
|
|
26
|
+
'consumed the item without a visible reference — requires evaluator confirmation';
|
|
27
|
+
|
|
28
|
+
export function adaptContextItems(items, options = {}) {
|
|
29
|
+
if (!Array.isArray(items)) return [];
|
|
30
|
+
const ctx = createAdapterContext({ writerId: 'adapter-context', ...options });
|
|
31
|
+
const records = [];
|
|
32
|
+
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
if (!isPlainObject(item)) continue;
|
|
35
|
+
const injected = item.injected === true;
|
|
36
|
+
const referenced = item.referenced === true;
|
|
37
|
+
const potentiallyUnused = injected && !referenced;
|
|
38
|
+
const record = emitRecord(ctx, {
|
|
39
|
+
semantic_name: 'context.item.injected',
|
|
40
|
+
wall_time_utc: asIsoTime(item.ts) ?? asIsoTime(item.injectedAt),
|
|
41
|
+
payload: {
|
|
42
|
+
kind: asString(item.kind) ?? asString(item.type),
|
|
43
|
+
item_ref: asString(item.ref) ?? asString(item.id),
|
|
44
|
+
selected: item.selected === true || injected,
|
|
45
|
+
injected,
|
|
46
|
+
referenced,
|
|
47
|
+
usage_signal: potentiallyUnused ? 'potentially_unused' : referenced ? 'referenced' : null,
|
|
48
|
+
evaluator_caveat: potentiallyUnused ? UNUSED_CAVEAT : null,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (record) records.push(record);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return records;
|
|
55
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// decisionAdapter.js (TASK-010, SPEC §5 DF-FR07) — read-only adapter over
|
|
2
|
+
// decision receipts produced by `src/decision/shadow.js`
|
|
3
|
+
// (runShadowDecisions → receipt) and `src/decision/preflight.js`
|
|
4
|
+
// (applyPreflight → receipt). Receipts are consumed as provenance inputs,
|
|
5
|
+
// never re-executed and never treated as a second completion gate.
|
|
6
|
+
//
|
|
7
|
+
// adaptDecisionReceipts(receipts, options?) → records[]
|
|
8
|
+
//
|
|
9
|
+
// Each receipt becomes one `decision.made` record (record_type 'decision',
|
|
10
|
+
// origin.experiment required by the schema). `applied` is true ONLY when
|
|
11
|
+
// the receipt carries host acknowledgement — `status: 'applied'` or
|
|
12
|
+
// `acknowledged: true` (preflight.js: a suggestion is never applied
|
|
13
|
+
// without acknowledgement). A shadow-stage or unacknowledged receipt
|
|
14
|
+
// records `applied: false`: selected ≠ applied.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
isPlainObject,
|
|
18
|
+
asString,
|
|
19
|
+
asStringArray,
|
|
20
|
+
asIsoTime,
|
|
21
|
+
createAdapterContext,
|
|
22
|
+
emitRecord,
|
|
23
|
+
} from './common.js';
|
|
24
|
+
|
|
25
|
+
export function adaptDecisionReceipts(receipts, options = {}) {
|
|
26
|
+
if (!Array.isArray(receipts)) return [];
|
|
27
|
+
const ctx = createAdapterContext({ writerId: 'adapter-decision', ...options });
|
|
28
|
+
const records = [];
|
|
29
|
+
|
|
30
|
+
for (const receipt of receipts) {
|
|
31
|
+
if (!isPlainObject(receipt)) continue;
|
|
32
|
+
const applied = receipt.status === 'applied' || receipt.acknowledged === true;
|
|
33
|
+
const record = emitRecord(ctx, {
|
|
34
|
+
record_type: 'decision',
|
|
35
|
+
semantic_name: 'decision.made',
|
|
36
|
+
wall_time_utc: asIsoTime(receipt.ts) ?? asIsoTime(receipt.createdAt),
|
|
37
|
+
origin: {
|
|
38
|
+
experiment: asString(receipt.experiment) ?? 'decision-plane',
|
|
39
|
+
stage: asString(receipt.stage),
|
|
40
|
+
checkpoint: asString(receipt.checkpoint),
|
|
41
|
+
},
|
|
42
|
+
payload: {
|
|
43
|
+
batch_id: asString(receipt.batchId),
|
|
44
|
+
stage: asString(receipt.stage),
|
|
45
|
+
outcome_class: asString(receipt.outcomeClass),
|
|
46
|
+
latency_class: asString(receipt.latencyClass),
|
|
47
|
+
fallback_code: asString(receipt.fallbackCode),
|
|
48
|
+
decision_keys: asStringArray(receipt.decisionKeys),
|
|
49
|
+
probability_bands: isPlainObject(receipt.probabilityBands) ? receipt.probabilityBands : {},
|
|
50
|
+
agreement: isPlainObject(receipt.agreement) ? receipt.agreement : null,
|
|
51
|
+
selected: true,
|
|
52
|
+
applied,
|
|
53
|
+
confirmation: asString(receipt.confirmation),
|
|
54
|
+
evidence_refs: asStringArray(receipt.evidenceRefs),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (record) records.push(record);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return records;
|
|
61
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// routeAdapter.js (TASK-010, SPEC §5 DF-FR07/DF-FR08) — read-only adapter
|
|
2
|
+
// over the route-audit + exec-ledger join owned by
|
|
3
|
+
// `src/diagnostics/routeOutcomes.js` (`collectRouteOutcomes(projectRoot,
|
|
4
|
+
// { limitLedgers })`). This module never reads files itself and never
|
|
5
|
+
// modifies the owner: callers pass the joined rows through and receive
|
|
6
|
+
// sanitized semantic records back.
|
|
7
|
+
//
|
|
8
|
+
// adaptRouteOutcomes(rows, options?) → records[]
|
|
9
|
+
//
|
|
10
|
+
// `rows` accepts either joined pairs `{ audit, ledger }` or flat owner
|
|
11
|
+
// rows carrying the same field names. Only safe metadata crosses the
|
|
12
|
+
// boundary: requestKey (opaque ref), taskType/executionMode, write and
|
|
13
|
+
// verification outcome booleans, repeat/rescue codes, receipt refs, and
|
|
14
|
+
// typed resource usage. Raw paths, transcripts, verdicts, blockers and
|
|
15
|
+
// receipt content are never copied — the privacy gate is the boundary,
|
|
16
|
+
// not field selection alone.
|
|
17
|
+
//
|
|
18
|
+
// DF-FR08: when a ledger row reports a model attempt, a `model.*` record
|
|
19
|
+
// is emitted with typed resource usage — provider-reported counts keep
|
|
20
|
+
// their source, estimates are marked ESTIMATED, and absent host data is
|
|
21
|
+
// `{ value: null, source: 'UNKNOWN' }` with `telemetry_complete: false`.
|
|
22
|
+
// Unknown is never 0 and never fabricated.
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
isPlainObject,
|
|
26
|
+
asString,
|
|
27
|
+
asStringArray,
|
|
28
|
+
asIsoTime,
|
|
29
|
+
createAdapterContext,
|
|
30
|
+
emitRecord,
|
|
31
|
+
} from './common.js';
|
|
32
|
+
|
|
33
|
+
const RESOURCE_SOURCES = new Set(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
|
|
34
|
+
|
|
35
|
+
function normalizeRow(row) {
|
|
36
|
+
if (!isPlainObject(row)) return null;
|
|
37
|
+
const audit = isPlainObject(row.audit) ? row.audit : row;
|
|
38
|
+
const ledger = isPlainObject(row.ledger) ? row.ledger : row;
|
|
39
|
+
const requestKey = asString(audit.requestKey) ?? asString(ledger.requestKey);
|
|
40
|
+
if (!requestKey) return null;
|
|
41
|
+
return { audit, ledger, requestKey };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function executionSemantic(audit, ledger) {
|
|
45
|
+
if (audit.rescueMode != null) return 'execution.blocked';
|
|
46
|
+
if (ledger.writeAttempted === true && ledger.writeSucceeded !== true) return 'execution.failed';
|
|
47
|
+
if (ledger.verificationFailed === true) return 'execution.failed';
|
|
48
|
+
if (ledger.writeSucceeded === true) return 'execution.completed';
|
|
49
|
+
// Audit-only row: the route happened but no ledger evidence exists —
|
|
50
|
+
// report the start, never a fabricated outcome.
|
|
51
|
+
return 'execution.started';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function receiptRefs(ledger) {
|
|
55
|
+
if (!Array.isArray(ledger.receipts)) return [];
|
|
56
|
+
const refs = [];
|
|
57
|
+
for (const receipt of ledger.receipts.slice(0, 32)) {
|
|
58
|
+
const ref = asString(receipt?.receiptId) ?? asString(receipt?.id);
|
|
59
|
+
if (ref) refs.push(ref);
|
|
60
|
+
}
|
|
61
|
+
return refs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function modelResource(model) {
|
|
65
|
+
const usage = isPlainObject(model.usage) ? model.usage : {};
|
|
66
|
+
const reported = usage.totalTokens ?? usage.tokens ?? model.totalTokens ?? model.tokens;
|
|
67
|
+
const estimated = usage.estimatedTokens ?? model.estimatedTokens;
|
|
68
|
+
let value = null;
|
|
69
|
+
let source = 'UNKNOWN';
|
|
70
|
+
if (typeof reported === 'number' && Number.isFinite(reported)) {
|
|
71
|
+
value = reported;
|
|
72
|
+
source = RESOURCE_SOURCES.has(usage.source) && usage.source !== 'UNKNOWN'
|
|
73
|
+
? usage.source
|
|
74
|
+
: 'PROVIDER';
|
|
75
|
+
} else if (typeof estimated === 'number' && Number.isFinite(estimated)) {
|
|
76
|
+
value = estimated;
|
|
77
|
+
source = 'ESTIMATED';
|
|
78
|
+
}
|
|
79
|
+
return { value, source };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function adaptRouteOutcomes(rows, options = {}) {
|
|
83
|
+
if (!Array.isArray(rows)) return [];
|
|
84
|
+
const ctx = createAdapterContext({ writerId: 'adapter-route', ...options });
|
|
85
|
+
const records = [];
|
|
86
|
+
|
|
87
|
+
for (const raw of rows) {
|
|
88
|
+
const row = normalizeRow(raw);
|
|
89
|
+
if (!row) continue;
|
|
90
|
+
const { audit, ledger, requestKey } = row;
|
|
91
|
+
|
|
92
|
+
const payload = {
|
|
93
|
+
request_key: requestKey,
|
|
94
|
+
task_type: asString(audit.taskType),
|
|
95
|
+
execution_mode: asString(audit.executionMode),
|
|
96
|
+
write_attempted: ledger.writeAttempted === true,
|
|
97
|
+
write_succeeded: ledger.writeSucceeded === true,
|
|
98
|
+
verification_attempted: ledger.verificationAttempted === true,
|
|
99
|
+
verification_succeeded: ledger.verificationSucceeded === true,
|
|
100
|
+
verification_failed: ledger.verificationFailed === true,
|
|
101
|
+
repeat_count: Number.isInteger(audit.repeatCount) ? audit.repeatCount : 0,
|
|
102
|
+
rescue_mode: asString(audit.rescueMode),
|
|
103
|
+
receipt_refs: receiptRefs(ledger),
|
|
104
|
+
telemetry_complete: isPlainObject(row.ledger),
|
|
105
|
+
};
|
|
106
|
+
const exec = emitRecord(ctx, {
|
|
107
|
+
semantic_name: executionSemantic(audit, ledger),
|
|
108
|
+
wall_time_utc: asIsoTime(audit.ts) ?? asIsoTime(ledger.updatedAt),
|
|
109
|
+
payload,
|
|
110
|
+
});
|
|
111
|
+
if (exec) records.push(exec);
|
|
112
|
+
|
|
113
|
+
const model = isPlainObject(ledger.model) ? ledger.model : null;
|
|
114
|
+
if (model) {
|
|
115
|
+
const resource = modelResource(model);
|
|
116
|
+
const failed = model.failed === true || asString(model.status) === 'failed';
|
|
117
|
+
const modelRecord = emitRecord(ctx, {
|
|
118
|
+
semantic_name: failed ? 'model.failed' : 'model.completed',
|
|
119
|
+
wall_time_utc: asIsoTime(audit.ts) ?? asIsoTime(ledger.updatedAt),
|
|
120
|
+
payload: {
|
|
121
|
+
request_key: requestKey,
|
|
122
|
+
duration_ms:
|
|
123
|
+
typeof model.durationMs === 'number' && Number.isFinite(model.durationMs)
|
|
124
|
+
? model.durationMs
|
|
125
|
+
: null,
|
|
126
|
+
resource,
|
|
127
|
+
telemetry_complete: resource.source !== 'UNKNOWN',
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
if (modelRecord) records.push(modelRecord);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return records;
|
|
135
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* digest.js (TASK-008, SPEC §5 DF-FR09/DF-FR12, §8) — bounded AI-readable digest.
|
|
3
|
+
*
|
|
4
|
+
* renderTraceDigest(summary, evidence): string
|
|
5
|
+
*
|
|
6
|
+
* Renders one TraceSummary (+ optional evidence records) as deterministic
|
|
7
|
+
* markdown for an AI reader. Section order mirrors the evaluator's reading
|
|
8
|
+
* sequence (SPEC §12): summary → anomalies → digest → evidence.
|
|
9
|
+
*
|
|
10
|
+
* Contract:
|
|
11
|
+
* - Hard cap: output never exceeds DIGEST_MAX_BYTES (8KB). Overflow is
|
|
12
|
+
* truncated deterministically with an explicit marker — never silent.
|
|
13
|
+
* - Unknowns stay explicit: null metrics render as `unknown`, never 0.
|
|
14
|
+
* - Drill-down: the evidence section names real record_ids so a reader
|
|
15
|
+
* can jump from digest back to retained facts.
|
|
16
|
+
* - Deterministic: no clock, no randomness, stable ordering — the same
|
|
17
|
+
* summary always renders byte-identical output.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const DIGEST_MAX_BYTES = 8 * 1024;
|
|
21
|
+
|
|
22
|
+
const MAX_SPAN_ROWS = 40;
|
|
23
|
+
const MAX_EVIDENCE_REFS = 24;
|
|
24
|
+
|
|
25
|
+
function isPlainObject(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fmtMs(value) {
|
|
30
|
+
return typeof value === 'number' && Number.isFinite(value) ? `${value}ms` : 'unknown';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtNum(value) {
|
|
34
|
+
return typeof value === 'number' && Number.isFinite(value) ? String(value) : 'unknown';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fmtRate(value) {
|
|
38
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
39
|
+
? `${(value * 100).toFixed(1)}%`
|
|
40
|
+
: 'unknown';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fmtPctile(stats) {
|
|
44
|
+
if (!isPlainObject(stats)) return 'p50=unknown p95=unknown p99=unknown';
|
|
45
|
+
return `n=${stats.count} p50=${fmtMs(stats.p50)} p95=${fmtMs(stats.p95)} p99=${fmtMs(stats.p99)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeEvidence(evidence) {
|
|
49
|
+
if (Array.isArray(evidence)) return { records: evidence.filter(isPlainObject), coverage: null };
|
|
50
|
+
if (isPlainObject(evidence)) {
|
|
51
|
+
return {
|
|
52
|
+
records: Array.isArray(evidence.records) ? evidence.records.filter(isPlainObject) : [],
|
|
53
|
+
coverage: isPlainObject(evidence.coverage) ? evidence.coverage : null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { records: [], coverage: null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Record IDs worth drilling into: drops, failures, redactions, high-importance. */
|
|
60
|
+
function evidenceRefs(records) {
|
|
61
|
+
const refs = [];
|
|
62
|
+
for (const record of records) {
|
|
63
|
+
const name = record.semantic_name || '';
|
|
64
|
+
const anomalous =
|
|
65
|
+
name.startsWith('telemetry.') ||
|
|
66
|
+
name.endsWith('.failed') ||
|
|
67
|
+
name.endsWith('.blocked') ||
|
|
68
|
+
record.importance === 'high' ||
|
|
69
|
+
record.importance === 'critical';
|
|
70
|
+
if (anomalous && typeof record.record_id === 'string') {
|
|
71
|
+
refs.push(`${record.record_id} (${name})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return refs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function anomalyLines(summary) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
if (summary.telemetry_complete === false) {
|
|
80
|
+
lines.push('- telemetry incomplete: drops, gaps, or host-blind fields present');
|
|
81
|
+
}
|
|
82
|
+
if (summary.drops && summary.drops.dropped_count > 0) {
|
|
83
|
+
const reasons = Object.keys(summary.drops.by_reason || {}).sort();
|
|
84
|
+
lines.push(`- dropped records: ${summary.drops.dropped_count} (reasons: ${reasons.join(', ') || 'UNSPECIFIED'})`);
|
|
85
|
+
}
|
|
86
|
+
if (summary.drops && summary.drops.redacted > 0) {
|
|
87
|
+
lines.push(`- redacted records: ${summary.drops.redacted}`);
|
|
88
|
+
}
|
|
89
|
+
if (summary.retries && summary.retries.retries > 0) {
|
|
90
|
+
lines.push(`- retries: ${summary.retries.retries} (model attempts: ${summary.retries.model_attempts})`);
|
|
91
|
+
}
|
|
92
|
+
if (summary.retries && summary.retries.failed_spans > 0) {
|
|
93
|
+
lines.push(`- failed spans: ${summary.retries.failed_spans}`);
|
|
94
|
+
}
|
|
95
|
+
if (summary.resource && summary.resource.unknown_count > 0) {
|
|
96
|
+
lines.push(`- host-blind resource usage: ${summary.resource.unknown_count} record(s) UNKNOWN`);
|
|
97
|
+
}
|
|
98
|
+
if (summary.coverage) {
|
|
99
|
+
if (summary.coverage.open_spans > 0) lines.push(`- open spans (no end record): ${summary.coverage.open_spans}`);
|
|
100
|
+
if (summary.coverage.orphan_spans > 0) lines.push(`- orphan spans (missing parent): ${summary.coverage.orphan_spans}`);
|
|
101
|
+
if (summary.coverage.duplicate_ends > 0) lines.push(`- duplicate span ends: ${summary.coverage.duplicate_ends}`);
|
|
102
|
+
}
|
|
103
|
+
if (lines.length === 0) lines.push('- none');
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function spanRow(span) {
|
|
108
|
+
const parent = span.parent_span_id ? ` parent=${span.parent_span_id}` : '';
|
|
109
|
+
const attempt = typeof span.attempt_index === 'number' ? ` attempt=${span.attempt_index}` : '';
|
|
110
|
+
const op = span.operation ? ` op=${span.operation}` : '';
|
|
111
|
+
return `- ${span.span_id} ${span.family}.${span.status} dur=${fmtMs(span.duration_ms)}${attempt}${op}${parent}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renderTraceDigest(summary, evidence) {
|
|
115
|
+
const s = isPlainObject(summary) ? summary : {};
|
|
116
|
+
const { records, coverage } = normalizeEvidence(evidence);
|
|
117
|
+
|
|
118
|
+
const head = [
|
|
119
|
+
'# trace digest',
|
|
120
|
+
`metric_version: ${s.metric_version || 'unknown'}`,
|
|
121
|
+
`trace_id: ${s.trace_id || 'unknown'}`,
|
|
122
|
+
'',
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const summaryLines = [
|
|
126
|
+
'## summary',
|
|
127
|
+
`telemetry_complete: ${s.telemetry_complete === true ? 'true' : 'false'}`,
|
|
128
|
+
`critical_path_ms: ${fmtNum(s.critical_path_ms)}`,
|
|
129
|
+
`durations.overall: ${fmtPctile(s.durations && s.durations.overall)}`,
|
|
130
|
+
`durations.model: ${fmtPctile(s.durations && s.durations.model)}`,
|
|
131
|
+
`durations.tool: ${fmtPctile(s.durations && s.durations.tool)}`,
|
|
132
|
+
`retries: ${s.retries ? s.retries.retries : 'unknown'} (model_attempts=${s.retries ? s.retries.model_attempts : 'unknown'}, failed_spans=${s.retries ? s.retries.failed_spans : 'unknown'})`,
|
|
133
|
+
`drops: ${s.drops ? s.drops.dropped_count : 'unknown'} (events=${s.drops ? s.drops.events : 'unknown'})`,
|
|
134
|
+
`cache: hit_rate=${fmtRate(s.cache && s.cache.hit_rate)} (hits=${s.cache ? s.cache.hits : 'unknown'}, misses=${s.cache ? s.cache.misses : 'unknown'})`,
|
|
135
|
+
`resource: total_value=${fmtNum(s.resource && s.resource.total_value)} (numeric=${s.resource ? s.resource.numeric_count : 'unknown'}, unknown=${s.resource ? s.resource.unknown_count : 'unknown'})`,
|
|
136
|
+
`coverage: records=${s.coverage ? s.coverage.records : 'unknown'} spans=${s.coverage ? s.coverage.spans : 'unknown'}`,
|
|
137
|
+
'',
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const anomalySection = ['## anomalies', ...anomalyLines(s), ''];
|
|
141
|
+
|
|
142
|
+
const spans = Array.isArray(s.spans) ? s.spans : [];
|
|
143
|
+
const shownSpans = spans.slice(0, MAX_SPAN_ROWS);
|
|
144
|
+
const digestLines = ['## digest', `spans: ${spans.length} total, ${shownSpans.length} shown`];
|
|
145
|
+
for (const span of shownSpans) digestLines.push(spanRow(span));
|
|
146
|
+
if (spans.length > shownSpans.length) {
|
|
147
|
+
digestLines.push(`- … truncated: ${spans.length - shownSpans.length} span(s) omitted (digest cap)`);
|
|
148
|
+
}
|
|
149
|
+
digestLines.push('');
|
|
150
|
+
|
|
151
|
+
const refs = evidenceRefs(records);
|
|
152
|
+
const shownRefs = refs.slice(0, MAX_EVIDENCE_REFS);
|
|
153
|
+
const evidenceLines = ['## evidence'];
|
|
154
|
+
if (coverage) {
|
|
155
|
+
evidenceLines.push(
|
|
156
|
+
`store: records_read=${fmtNum(coverage.records_read)} traces=${fmtNum(coverage.traces)} corrupt_lines=${fmtNum(coverage.corrupt_lines)} partial_tail_bytes=${fmtNum(coverage.partial_tail_bytes)} quarantined=${fmtNum(coverage.quarantined_segments)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
evidenceLines.push(`records_available: ${records.length}`);
|
|
160
|
+
if (shownRefs.length > 0) {
|
|
161
|
+
evidenceLines.push('anomalous record refs:');
|
|
162
|
+
for (const ref of shownRefs) evidenceLines.push(`- ${ref}`);
|
|
163
|
+
if (refs.length > shownRefs.length) {
|
|
164
|
+
evidenceLines.push(`- … truncated: ${refs.length - shownRefs.length} ref(s) omitted (digest cap)`);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
evidenceLines.push('anomalous record refs: none');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let out = [...head, ...summaryLines, ...anomalySection, ...digestLines, ...evidenceLines].join('\n');
|
|
171
|
+
|
|
172
|
+
// Hard cap: deterministic tail truncation with an explicit marker.
|
|
173
|
+
if (Buffer.byteLength(out, 'utf8') > DIGEST_MAX_BYTES) {
|
|
174
|
+
const marker = '\n[truncated: digest exceeded 8KB cap]\n';
|
|
175
|
+
const budget = DIGEST_MAX_BYTES - Buffer.byteLength(marker, 'utf8');
|
|
176
|
+
let lo = 0;
|
|
177
|
+
let hi = out.length;
|
|
178
|
+
while (lo < hi) {
|
|
179
|
+
const mid = (lo + hi + 1) >> 1;
|
|
180
|
+
if (Buffer.byteLength(out.slice(0, mid), 'utf8') <= budget) lo = mid;
|
|
181
|
+
else hi = mid - 1;
|
|
182
|
+
}
|
|
183
|
+
out = out.slice(0, lo) + marker;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fingerprints.js (TASK-012, SPEC §5 DF-FR09 / §8) — structural fingerprints.
|
|
3
|
+
*
|
|
4
|
+
* computeFingerprint(summary): { hash, kind, count, metric_version }
|
|
5
|
+
*
|
|
6
|
+
* Pure function over ONE TraceSummary. No IO, no clock, no randomness — the
|
|
7
|
+
* same summary always produces the same fingerprint, and two traces with the
|
|
8
|
+
* same STRUCTURE (same span families/operations/statuses/attempt pattern and
|
|
9
|
+
* the same signal counts) share a fingerprint even when every ID differs.
|
|
10
|
+
*
|
|
11
|
+
* What the hash covers — and what it deliberately excludes:
|
|
12
|
+
* - IN: ordered span descriptors (family, operation, status, attempt_index,
|
|
13
|
+
* parent link expressed as a positional index / 'root' / 'orphan') and
|
|
14
|
+
* the signal counts (retries, failed spans, cache hits/misses, drop
|
|
15
|
+
* events, telemetry completeness).
|
|
16
|
+
* - OUT: record/span/trace IDs, timestamps, durations, resource values.
|
|
17
|
+
* A fingerprint is a shape, not an identity — durations belong to the
|
|
18
|
+
* opportunity layer, never to the grouping key.
|
|
19
|
+
*
|
|
20
|
+
* `kind` names the dominant failure/inefficiency pattern, precedence:
|
|
21
|
+
* dropped > failure > retry > cache-miss > clean.
|
|
22
|
+
* `count` is the number of pattern occurrences inside the trace (retries,
|
|
23
|
+
* failed spans, dropped records, cache misses; 1 for a clean trace).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
const FINGERPRINT_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
function isPlainObject(value) {
|
|
31
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function num(value) {
|
|
35
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Positional parent link: index of the parent inside the summary's own
|
|
40
|
+
* deterministic span order, 'root' when no parent is declared, 'orphan'
|
|
41
|
+
* when the declared parent is absent from the summary.
|
|
42
|
+
*/
|
|
43
|
+
function spanDescriptors(spans) {
|
|
44
|
+
const indexById = new Map();
|
|
45
|
+
spans.forEach((span, index) => {
|
|
46
|
+
if (typeof span.span_id === 'string') indexById.set(span.span_id, index);
|
|
47
|
+
});
|
|
48
|
+
return spans.map((span) => {
|
|
49
|
+
let parent = 'root';
|
|
50
|
+
if (typeof span.parent_span_id === 'string' && span.parent_span_id.length > 0) {
|
|
51
|
+
parent = indexById.has(span.parent_span_id) ? indexById.get(span.parent_span_id) : 'orphan';
|
|
52
|
+
}
|
|
53
|
+
// Model-span operation is the model/version axis — a COHORT key, not a
|
|
54
|
+
// pattern key. Normalizing it keeps mixed-model traces in one cluster so
|
|
55
|
+
// the opportunity layer can flag the confound instead of silently
|
|
56
|
+
// splitting (Simpson's-paradox guard, SPEC §12).
|
|
57
|
+
const operation =
|
|
58
|
+
span.family === 'model' ? '<model>' : typeof span.operation === 'string' ? span.operation : null;
|
|
59
|
+
return [
|
|
60
|
+
typeof span.family === 'string' ? span.family : 'unknown',
|
|
61
|
+
operation,
|
|
62
|
+
typeof span.status === 'string' ? span.status : 'unknown',
|
|
63
|
+
Number.isInteger(span.attempt_index) ? span.attempt_index : null,
|
|
64
|
+
parent,
|
|
65
|
+
];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function signalsOf(summary) {
|
|
70
|
+
const retries = isPlainObject(summary.retries) ? summary.retries : {};
|
|
71
|
+
const cache = isPlainObject(summary.cache) ? summary.cache : {};
|
|
72
|
+
const drops = isPlainObject(summary.drops) ? summary.drops : {};
|
|
73
|
+
return {
|
|
74
|
+
retries: num(retries.retries),
|
|
75
|
+
failed_spans: num(retries.failed_spans),
|
|
76
|
+
cache_hits: num(cache.hits),
|
|
77
|
+
cache_misses: num(cache.misses),
|
|
78
|
+
drop_events: num(drops.events),
|
|
79
|
+
dropped_count: num(drops.dropped_count),
|
|
80
|
+
telemetry_complete: summary.telemetry_complete === true ? 1 : 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function kindOf(signals) {
|
|
85
|
+
if (signals.drop_events > 0) return 'dropped';
|
|
86
|
+
if (signals.failed_spans > 0) return 'failure';
|
|
87
|
+
if (signals.retries > 0) return 'retry';
|
|
88
|
+
if (signals.cache_misses > 0) return 'cache-miss';
|
|
89
|
+
return 'clean';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function countOf(kind, signals) {
|
|
93
|
+
switch (kind) {
|
|
94
|
+
case 'dropped':
|
|
95
|
+
return signals.dropped_count;
|
|
96
|
+
case 'failure':
|
|
97
|
+
return signals.failed_spans;
|
|
98
|
+
case 'retry':
|
|
99
|
+
return signals.retries;
|
|
100
|
+
case 'cache-miss':
|
|
101
|
+
return signals.cache_misses;
|
|
102
|
+
default:
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function computeFingerprint(summary) {
|
|
108
|
+
if (!isPlainObject(summary) || !Array.isArray(summary.spans)) {
|
|
109
|
+
throw new TypeError('computeFingerprint: summary must be a TraceSummary object with a spans array');
|
|
110
|
+
}
|
|
111
|
+
const spans = summary.spans.filter(isPlainObject);
|
|
112
|
+
const signals = signalsOf(summary);
|
|
113
|
+
const kind = kindOf(signals);
|
|
114
|
+
const canonical = JSON.stringify({
|
|
115
|
+
v: FINGERPRINT_VERSION,
|
|
116
|
+
kind,
|
|
117
|
+
spans: spanDescriptors(spans),
|
|
118
|
+
signals,
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
hash: crypto.createHash('sha256').update(canonical).digest('hex').slice(0, 16),
|
|
122
|
+
kind,
|
|
123
|
+
count: countOf(kind, signals),
|
|
124
|
+
metric_version: typeof summary.metric_version === 'string' ? summary.metric_version : 'unknown',
|
|
125
|
+
};
|
|
126
|
+
}
|