返回 CodeWhale
pet-telemetry.ts
根目录 / pet / src / core / pet-telemetry.ts
1 import { CATEGORIES, clamp, errorOnsetOf, type Category, type WhaleEvent } from './model.js';
2 import { IntervalIndex } from './signal.js';
3 import { PET_MAX_SECONDS, validatePetState, type PetState } from './pet-sim.js';
4
5 /** One projection for imports, demos and recorded live snapshots. Times are ms.
6 * These are aesthetic encodings of measured events, never model confidence. */
7 export const PET_BIN_MS = 400;
8 export interface PetBucket extends PetState {
9 version: 1;
10 sequence: number;
11 simTimeMs: number;
12 durationMs: number;
13 onsets: number[];
14 activeMs: number[];
15 errors: number;
16 agentIds: string[];
17 waiting: boolean;
18 }
19
20 export function validatePetBucket(value: unknown): asserts value is PetBucket {
21 validatePetState(value);
22 const b = value as PetBucket;
23 if (!b || typeof b !== 'object' || b.version !== 1 || !Number.isSafeInteger(b.sequence) || b.sequence < 0 || b.sequence > PET_MAX_SECONDS * 2.5
24 || b.simTimeMs !== b.sequence * PET_BIN_MS || b.durationMs !== PET_BIN_MS
25 || !CATEGORIES.includes(b.channel as Category) || typeof b.waiting !== 'boolean'
26 || !Array.isArray(b.agentIds) || b.agentIds.length > 250_000 || b.agentIds.some(id => typeof id !== 'string' || !id || id.length > 4096)
27 || !Number.isSafeInteger(b.errors) || b.errors < 0 || b.errors > 250_000
28 || !Array.isArray(b.onsets) || b.onsets.length !== 13 || b.onsets.some(n => !Number.isSafeInteger(n) || n < 0 || n > 250_000)
29 || !Array.isArray(b.activeMs) || b.activeMs.length !== 13 || b.activeMs.some(n => !Number.isFinite(n) || n < 0 || n > 100_000_000))
30 throw new Error('Invalid version 1 pet bucket.');
31 }
32
33 export function decodePetJSONL(text: string): PetBucket[] {
34 if (text.length > 64 * 1024 * 1024) throw new Error('Pet tape exceeds 64 MiB.');
35 const rows = text.split(/\r?\n/).filter(line => line.trim()).map(line => JSON.parse(line) as unknown);
36 if (rows.length > 216_000) throw new Error('Pet tape exceeds 24 hours.');
37 return rows.map((row, i) => { validatePetBucket(row); if (row.sequence !== i) throw new Error('Non-contiguous pet tape.'); return row; });
38 }
39
40 /** A live file must advance before its contents count as a new observation.
41 * Existing bytes, duplicate samples and a restarted sequence establish a
42 * baseline; they never replay an old onset or human request. Drivers supply a
43 * bounded tail and reset this cursor after suspension or a new attachment. */
44 export class PetLiveTape {
45 private sequence: number | undefined;
46 reset(): void { this.sequence = undefined; }
47 readTail(text: string): PetBucket | undefined {
48 if (!text) { this.reset(); return; }
49 if (text.length > 262_144) { this.reset(); throw new Error('Live pet input exceeds its tail limit.'); }
50 if (!text.endsWith('\n')) return;
51 const line = text.trimEnd().split('\n').at(-1);
52 if (!line) return;
53 let packet: unknown;
54 try { packet = JSON.parse(line); validatePetBucket(packet); }
55 catch (error) { this.reset(); throw error; }
56 const previous = this.sequence; this.sequence = packet.sequence;
57 if (previous === undefined || packet.sequence <= previous) return;
58 return packet;
59 }
60 }
61
62 const order = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
63 const keyOf = (e: WhaleEvent) => JSON.stringify([e.traceId, e.id]);
64 const isContainer = (e: WhaleEvent) => e.attributes['whalesong.container'] === true
65 || e.attributes['codewhale.container'] === true;
66
67 /** Compile a single trace. Unknown-duration spans provide onsets, not occupancy.
68 * Updates of the same trace/id replace earlier snapshots rather than double count.
69 * An endpoint onset gets its own bucket; intervals use [start, end). */
70 export function compilePetTelemetry(input: readonly WhaleEvent[], durationMs = 0, firstSequence = 0, originMs = 0): PetBucket[] {
71 if (input.length > 250_000) throw new Error('Pet input exceeds 250000 events.');
72 if (!Number.isFinite(durationMs) || durationMs < 0) throw new Error('Invalid pet duration.');
73 if (!Number.isSafeInteger(firstSequence) || firstSequence < 0) throw new Error('Invalid first pet bucket.');
74 if (!Number.isFinite(originMs)) throw new Error('Invalid pet clock origin.');
75 durationMs = Math.max(0, durationMs - originMs);
76 const unique = new Map<string, WhaleEvent>();
77 const traces = new Set<string>();
78 for (const e of input) {
79 if (e.schemaVersion !== 1 || !e.id || !e.traceId || !CATEGORIES.includes(e.category)
80 || !Number.isFinite(e.startTime) || !Number.isFinite(e.endTime)
81 || e.startTime < 0 || e.endTime < e.startTime || !e.attributes)
82 throw new Error('Invalid event-v1 pet input. Import through importTrace first.');
83 traces.add(e.traceId); unique.set(keyOf(e), e);
84 }
85 if (traces.size > 1) throw new Error('Select one trace for the pet.');
86 const parents = new Set([...unique.values()].filter(e => e.parentId).map(e => e.parentId!));
87 const events = [...unique.values()].filter(e => !isContainer(e)
88 && !(e.category === 'orchestration' && parents.has(e.id)))
89 .map(e => ({ ...e, startTime: e.startTime - originMs, endTime: (e.openEnded ? e.startTime : e.endTime) - originMs,
90 attributes: e.attributes['whalesong.error_onset_ms'] === undefined ? e.attributes
91 : { ...e.attributes, 'whalesong.error_onset_ms': errorOnsetOf(e) - originMs } }))
92 .sort((a, b) => a.startTime - b.startTime || order(a.id, b.id));
93 let lastOnset = 0;
94 for (const e of events) { durationMs = Math.max(durationMs, e.endTime); lastOnset = Math.max(lastOnset, e.startTime); }
95 const failures = events.filter(e => e.category === 'error' || e.status === 'error').map(errorOnsetOf).sort((a, b) => a - b);
96 if (failures.length) lastOnset = Math.max(lastOnset, failures[failures.length - 1]);
97 const count = Math.max(1, Math.ceil(durationMs / PET_BIN_MS), Math.floor(lastOnset / PET_BIN_MS) + 1);
98 if (count - firstSequence > 216_000 || count > PET_MAX_SECONDS * 2.5) throw new Error('Pet replay exceeds 24 hours; select a shorter trace.');
99 const index = new IntervalIndex(events), result: PetBucket[] = [];
100 const recent: WhaleEvent[] = [], names = new Map<string, number>();
101 let next = 0, expired = 0, nextFailure = 0;
102 for (let sequence = firstSequence; sequence < count; sequence++) {
103 const start = sequence * PET_BIN_MS, end = start + PET_BIN_MS;
104 // A trailing window only: appending future events cannot rewrite earlier bins.
105 while (next < events.length && events[next].startTime < end) {
106 const e = events[next++];
107 // A liveness pulse continues an operation; it is not a repeated tool call.
108 if (e.attributes['whalesong.continuation'] === true) continue;
109 recent.push(e); names.set(e.name, (names.get(e.name) ?? 0) + 1);
110 }
111 while (expired < recent.length && recent[expired].startTime < end - 12_000) {
112 const name = recent[expired++].name, n = names.get(name)! - 1;
113 if (n) names.set(name, n); else names.delete(name);
114 }
115 const onsets = CATEGORIES.map(() => 0), activeMs = CATEGORIES.map(() => 0);
116 const agents = new Set<string>();
117 while (nextFailure < failures.length && failures[nextFailure] < start) nextFailure++;
118 let errors = 0, waiting = false, human = false;
119 while (nextFailure < failures.length && failures[nextFailure] < end) { errors++; nextFailure++; }
120 for (const e of index.query(start, end)) {
121 if (e.startTime >= end) continue;
122 const onset = e.startTime >= start, c = CATEGORIES.indexOf(e.category);
123 const overlap = Math.max(0, Math.min(end, e.endTime) - Math.max(start, e.startTime));
124 if (!onset && !overlap) continue;
125 activeMs[c] += overlap;
126 if (onset) onsets[c]++;
127 if (e.agentId && !['unknown', 'unattributed'].includes(e.agentId)) agents.add(e.agentId);
128 if (e.category === 'human') {
129 human = true; waiting ||= e.status === 'pending' || e.status === 'running' || e.attributes['whalesong.waiting'] === true;
130 }
131 }
132 const total = activeMs.reduce((a, b) => a + b, 0), hits = onsets.reduce((a, b) => a + b, 0);
133 const observed = total > 0 || hits > 0 || errors > 0;
134 let dominant = CATEGORIES.indexOf('other');
135 for (let c = 0; c < CATEGORIES.length; c++) {
136 if (activeMs[c] > activeMs[dominant]
137 || activeMs[c] === activeMs[dominant] && onsets[c] > onsets[dominant]) dominant = c;
138 }
139 let repeated = 0;
140 for (const n of names.values()) if (n >= 4) repeated += n;
141 const repeatDensity = repeated / Math.max(1, recent.length - expired);
142 const channel: Category = errors ? 'error' : human ? 'human' : agents.size >= 3 ? 'agent' : CATEGORIES[dominant];
143 result.push({ version: 1, sequence, simTimeMs: start, durationMs: PET_BIN_MS,
144 activity: observed ? clamp(.28 + .38 * total / PET_BIN_MS + .08 * hits, 0, 1) : .12,
145 coherence: observed ? clamp(.92 - repeatDensity * .58 - Math.min(.55, errors * .18), .08, 1) : .25,
146 attention: waiting ? .8 : human ? .65 : errors ? .45 : 0,
147 channel, observed: observed ? 1 : 0, roamX: 0, roamY: 0, flip: 1, lit: 1,
148 onsets, activeMs, errors, agentIds: [...agents].sort(order), waiting });
149 }
150 return result;
151 }
152
153 /** Flat state remains compatible with native readers; metadata preserves audio
154 * onsets and peer identities. No prompts, tool arguments, or event names escape. */
155 export function encodePetJSONL(buckets: readonly PetBucket[]): string {
156 return buckets.map(b => JSON.stringify(b)).join('\n') + (buckets.length ? '\n' : '');
157 }
158
159 /** Legacy visual conformance interchange. Use JSONL to preserve audio metadata. */
160 export function encodePetTSV(buckets: readonly PetBucket[]): string {
161 return 'dt\tactivity\tcoherence\tattention\tchannel\tobserved\troamX\troamY\tflip\tlit\n'
162 + buckets.map(b => [b.durationMs / 1000, b.activity, b.coherence, b.attention, b.channel,
163 b.observed, b.roamX, b.roamY, b.flip, b.lit].join('\t')).join('\n') + '\n';
164 }
165
165 lines TYPESCRIPT