/** UI times use performance.now(); native spans use their own command clock. */ export interface NativeSpan { name: string; start_ms: number; duration_ms: number; parent: number | null; counters: Record; } export interface NativeMetrics { total_ms: number; result_count: number; trace: NativeSpan[] | null; dropped_spans: number; } export interface MeasuredResponse { results: T[]; metrics: NativeMetrics; } export type SearchLane = "lexical" | "hybrid"; export interface AttemptStart { lane: SearchLane; startedAt: number; submittedAt: number; submission: number; } interface Attempt { lane: SearchLane; submission: number; attempt: number; queued_ms: number; retry_wait_ms: number; invoke_ms: number; native: NativeMetrics | null; transport_estimate_ms: number | null; failed: boolean; ended_at: number; } interface InputTimingSummary { debounce_ms: number; first_results_ms: number | null; latest_displayed_ms: number | null; hybrid_final_results_ms: number | null; } export class SearchMeasurement { private originalInput: Readonly | null = null; debug: boolean; inputAt: number; scheduledAt: number; context: Record; submissions = new Map(); latestSubmission = new Map(); submissionSequence = new Map(); laneAttempts = new Map(); attempts: Attempt[] = []; droppedAttempts = 0; droppedDisplays = 0; firstDisplayMs: number | null = null; displays: { lane: SearchLane; elapsed_ms: number; dom_update_ms: number; count: number; }[] = []; constructor( inputAt: number, scheduledAt: number, debug: boolean, context: Record = {}, ) { this.inputAt = inputAt; this.scheduledAt = scheduledAt; this.debug = debug; this.context = { ...context }; } /** Filesystem updates are independent requests, not elapsed user idle time. */ freshForRefresh(now: number): SearchMeasurement { const refreshed = new SearchMeasurement(now, now, this.debug, this.context); // Preserve one fixed-size numeric snapshot, never an ancestor trace/history. // Pending responses on the old request cannot rewrite this snapshot. refreshed.originalInput = Object.freeze({ ...(this.originalInput ?? { debounce_ms: Math.max(0, this.scheduledAt - this.inputAt), first_results_ms: this.firstDisplayMs, latest_displayed_ms: this.displays.at(-1)?.elapsed_ms ?? null, hybrid_final_results_ms: [...this.displays] .reverse() .find((display) => display.lane === "hybrid")?.elapsed_ms ?? null, }), }); return refreshed; } submitted(lane: SearchLane, at: number) { if (!this.submissions.has(lane)) this.submissions.set(lane, at); this.latestSubmission.set(lane, at); this.submissionSequence.set( lane, (this.submissionSequence.get(lane) ?? 0) + 1, ); } begin(lane: SearchLane, startedAt: number): AttemptStart { return { lane, startedAt, submittedAt: this.latestSubmission.get(lane) ?? startedAt, submission: this.submissionSequence.get(lane) ?? 0, }; } completed( token: AttemptStart, end: number, native: NativeMetrics | null, failed: boolean, ) { const { lane, startedAt: start, submittedAt, submission } = token; const prior = this.attempts.filter((attempt) => attempt.lane === lane); const previous = prior.at(-1); const retry = previous?.failed && previous.submission === submission; const attemptNumber = (this.laneAttempts.get(lane) ?? 0) + 1; this.laneAttempts.set(lane, attemptNumber); const elapsed = Math.max(0, end - start); this.attempts.push({ lane, submission, attempt: attemptNumber, queued_ms: retry ? 0 : Math.max(0, start - submittedAt), retry_wait_ms: retry ? Math.max(0, start - previous.ended_at) : 0, invoke_ms: elapsed, native, transport_estimate_ms: native ? Math.max(0, elapsed - native.total_ms) : null, failed, ended_at: end, }); if (this.attempts.length > (this.debug ? 32 : 4)) { this.attempts.shift(); this.droppedAttempts++; } } displayed(lane: SearchLane, start: number, end: number, count: number) { if (this.firstDisplayMs === null) this.firstDisplayMs = Math.max(0, end - this.inputAt); this.displays.push({ lane, elapsed_ms: Math.max(0, end - this.inputAt), dom_update_ms: Math.max(0, end - start), count, }); if (this.displays.length > (this.debug ? 16 : 2)) { this.displays.shift(); this.droppedDisplays++; } } latestNative(lane: SearchLane) { return ( this.attempts .filter((attempt) => attempt.lane === lane && !attempt.failed) .at(-1)?.native ?? null ); } /** Deliberately exclude query text, filenames, paths, snippets and errors. */ export() { return { version: 1, trigger: this.originalInput ? "filesystem_refresh" : "user_search", original_input: this.originalInput ? { ...this.originalInput } : null, context: this.context, clock_note: "UI monotonic elapsed times; native command-relative spans. Nested spans and parallel lanes must not be summed.", debounce_ms: Math.max(0, this.scheduledAt - this.inputAt), lane_schedule_ms: Object.fromEntries( [...this.submissions].map(([lane, at]) => [ lane, Math.max(0, at - this.scheduledAt), ]), ), attempts: this.attempts.map(({ ended_at: _end, ...attempt }) => attempt), displays: this.displays, first_results_ms: this.firstDisplayMs, latest_displayed_ms: this.displays.at(-1)?.elapsed_ms ?? null, hybrid_final_results_ms: [...this.displays] .reverse() .find((display) => display.lane === "hybrid")?.elapsed_ms ?? null, dropped_attempts: this.droppedAttempts, dropped_displays: this.droppedDisplays, }; } } export function measuredFailure( error: unknown, ): { message: string; metrics: NativeMetrics } | null { if (!error || typeof error !== "object") return null; const value = error as { message?: unknown; metrics?: NativeMetrics }; return typeof value.message === "string" && value.metrics && Number.isFinite(value.metrics.total_ms) ? { message: value.message, metrics: value.metrics } : null; } /** Malformed/cyclic parent chains cannot trap the debug renderer. */ export function spanDepth(spans: NativeSpan[], index: number) { let depth = 0; const visited = new Set([index]); let parent = spans[index]?.parent; while ( parent != null && spans[parent] && !visited.has(parent) && depth < 16 ) { visited.add(parent); depth++; parent = spans[parent].parent; } return depth; } export function briefMs(value: number) { return value < 1 ? "<1" : value < 10 ? value.toFixed(1) : String(Math.round(value)); }