/** * KHAELOR * File: prototypes/tui-spike/shared/metrics.ts * Description: In-process spike instrumentation — input-echo latency, memory sampling, ANSI op counting. * Every reported number is measured; nothing is estimated or fabricated (Absolute Rule #4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import fs from "node:fs"; import path from "node:path"; import { countMatches, stripAnsi, visibleWidth } from "./ansi"; interface PendingEcho { t: bigint; // hrtime at stdin 'data' arrival needle: string; // plain text the echoing frame must contain (e.g. "❯ " + composer) } export class SpikeMetrics { readonly t0 = Date.now(); latNs: number[] = []; latTimeline: { t: number; ms: number }[] = []; // per-sample latency with wall time offset jamLog: { t: number; needle: string; frameComposer: string | null }[] = []; unresolvedDropped = 0; private pending: PendingEcho[] = []; writes = 0; bytes = 0; eraseLine = 0; // CSI 2K eraseDown = 0; // CSI 0J / CSI J eraseScreen = 0; // CSI 2J (full-screen clear — must stay 0) cursorUp = 0; // CSI n A / CSI n F syncFrames = 0; // DEC 2026 begin markers framesFull = 0; // full live-region repaints (candidate A only) framesPartial = 0; // damage-tracked repaints (candidate A only) linesRepainted = 0; // individual live rows rewritten (candidate A only) settledBlocks = 0; turns = 0; resizes = 0; overflowLines = 0; // emitted visible lines wider than the terminal (resize-integrity proxy) mem: { t: number; rss: number; heap: number }[] = []; private memTimer: NodeJS.Timeout | null = null; constructor(public candidate: string) {} startMem(everyMs = 1000): void { this.memTimer = setInterval(() => { const m = process.memoryUsage(); this.mem.push({ t: Date.now() - this.t0, rss: m.rss, heap: m.heapUsed }); }, everyMs); this.memTimer.unref(); } /** Call from the raw stdin 'data' handler with the arrival hrtime and the expected echo text. */ expectEcho(t: bigint, needle: string): void { this.pending.push({ t, needle }); } /** Called by the patched stdout for every write. */ onWrite(chunk: string, cols: number): void { const now = process.hrtime.bigint(); this.writes++; this.bytes += Buffer.byteLength(chunk); this.eraseLine += countMatches(chunk, /\x1b\[2K/g); this.eraseDown += countMatches(chunk, /\x1b\[0?J/g); this.eraseScreen += countMatches(chunk, /\x1b\[[23]J/g); this.cursorUp += countMatches(chunk, /\x1b\[\d*[AF]/g); this.syncFrames += countMatches(chunk, /\x1b\[\?2026h/g); // Typed characters are append-only in the harness, so each pending needle is a // prefix of the next; resolving in FIFO order is exact. while (this.pending.length > 0 && chunk.includes(this.pending[0].needle)) { const p = this.pending.shift()!; const ns = Number(now - p.t); this.latNs.push(ns); this.latTimeline.push({ t: Date.now() - this.t0, ms: Math.round((ns / 1e6) * 1000) / 1000 }); } while (this.pending.length > 0 && now - this.pending[0].t > 2_000_000_000n) { const p = this.pending.shift()!; this.unresolvedDropped++; if (this.jamLog.length < 20) { const m = chunk.match(/❯ [^\n\x1b]*/); this.jamLog.push({ t: Date.now() - this.t0, needle: p.needle, frameComposer: m === null ? null : m[0], }); } } for (const line of stripAnsi(chunk).split(/\r\n|\r|\n/)) { if (visibleWidth(line) > cols) this.overflowLines++; } } report(): Record { const lat = this.latNs.map((n) => n / 1e6).sort((a, b) => a - b); const pct = (p: number) => lat.length === 0 ? null : lat[Math.min(lat.length - 1, Math.floor(p * lat.length))]; const durationMs = Date.now() - this.t0; // RSS slope over the last two thirds of the run (least squares), MB/min. const tail = this.mem.slice(Math.floor(this.mem.length / 3)); let slopeMBperMin: number | null = null; if (tail.length >= 5) { const n = tail.length; const mt = tail.reduce((s, x) => s + x.t, 0) / n; const mr = tail.reduce((s, x) => s + x.rss, 0) / n; let num = 0; let den = 0; for (const x of tail) { num += (x.t - mt) * (x.rss - mr); den += (x.t - mt) ** 2; } slopeMBperMin = den === 0 ? null : ((num / den) * 60_000) / 1e6; } const mb = (b: number) => Math.round((b / 1e6) * 10) / 10; return { candidate: this.candidate, durationMs, inputLatencyMs: { samples: lat.length, p50: pct(0.5), p95: pct(0.95), max: lat.length ? lat[lat.length - 1] : null, unresolvedDropped: this.unresolvedDropped, }, io: { writes: this.writes, bytes: this.bytes, writesPerSec: Math.round((this.writes / durationMs) * 1000 * 10) / 10, }, ansiOps: { eraseLine: this.eraseLine, eraseDown: this.eraseDown, eraseScreenFullClears: this.eraseScreen, cursorUp: this.cursorUp, dec2026SyncFrames: this.syncFrames, }, paint: { framesFull: this.framesFull, framesPartial: this.framesPartial, linesRepainted: this.linesRepainted, settledBlocks: this.settledBlocks, turns: this.turns, resizes: this.resizes, overflowLines: this.overflowLines, }, latencyTimeline: this.latTimeline, jamLog: this.jamLog, memory: { timeline: this.mem.map((x) => ({ t: x.t, rssMB: mb(x.rss), heapMB: mb(x.heap) })), samples: this.mem.length, firstRssMB: this.mem.length ? mb(this.mem[0].rss) : null, lastRssMB: this.mem.length ? mb(this.mem[this.mem.length - 1].rss) : null, maxRssMB: this.mem.length ? mb(Math.max(...this.mem.map((x) => x.rss))) : null, lastHeapMB: this.mem.length ? mb(this.mem[this.mem.length - 1].heap) : null, rssSlopeMBperMin: slopeMBperMin === null ? null : Math.round(slopeMBperMin * 100) / 100, }, }; } save(file?: string): string { const target = file && file.length > 0 ? file : path.join(process.cwd(), "out", `metrics-${this.candidate}-${Date.now()}.json`); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, JSON.stringify(this.report(), null, 2) + "\n"); return target; } } /** Monkey-patch a write stream so every write is accounted. Returns an unpatch function. */ export function patchStdout( stream: NodeJS.WriteStream, metrics: SpikeMetrics, getCols: () => number, ): () => void { const orig = stream.write.bind(stream); (stream as unknown as { write: unknown }).write = ( chunk: string | Uint8Array, ...rest: unknown[] ) => { try { const s = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); metrics.onWrite(s, getCols()); } catch { /* accounting must never break rendering */ } return (orig as (c: unknown, ...r: unknown[]) => boolean)(chunk, ...rest); }; return () => { (stream as unknown as { write: unknown }).write = orig; }; }