| 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 |