diff --git a/autoresearch.sh b/autoresearch.sh new file mode 100755 index 0000000..e37a075 --- /dev/null +++ b/autoresearch.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$repo_root" + +if [[ "$(node -p 'process.versions.node.split(".")[0]')" != "24" ]]; then + echo "autoresearch requires Node 24; found $(node --version)" >&2 + exit 1 +fi +for command_name in pnpm tee timeout; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "missing required command: $command_name" >&2 + exit 1 + fi +done +if [[ ! -d node_modules ]]; then + echo "dependencies are missing; run pnpm install --frozen-lockfile before autoresearch" >&2 + exit 1 +fi + +entry_count="${T4_PERF_ENTRY_COUNT:-10000}" +event_count="${T4_PERF_EVENT_COUNT:-100000}" +repetitions="${T4_PERF_REPETITIONS:-7}" +warmups="${T4_PERF_WARMUPS:-1}" +timeout_seconds="${T4_AUTORESEARCH_TIMEOUT_SECONDS:-120}" +run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" +output_dir="test-results/perf/autoresearch/$run_id" +mkdir -p "$output_dir" +log_path="$output_dir/run.log" + +runner=() +cpu_affinity="unbound" +if command -v taskset >/dev/null 2>&1; then + requested_cpu="${T4_AUTORESEARCH_CPU:-}" + if [[ -z "$requested_cpu" ]]; then + allowed_affinity="$(taskset -pc "$$" 2>/dev/null || true)" + allowed_affinity="${allowed_affinity##*: }" + first_affinity_range="${allowed_affinity%%,*}" + requested_cpu="${first_affinity_range%%-*}" + fi + if [[ -n "$requested_cpu" ]] && taskset -c "$requested_cpu" true 2>/dev/null; then + cpu_affinity="$requested_cpu" + runner=(taskset -c "$cpu_affinity") + elif [[ -n "${T4_AUTORESEARCH_CPU:-}" ]]; then + echo "T4_AUTORESEARCH_CPU is outside this process's allowed CPU set" >&2 + exit 1 + fi +fi + +export CI=1 +export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--expose-gc" +export T4_PERF_ENTRY_COUNT="$entry_count" +export T4_PERF_EVENT_COUNT="$event_count" +export T4_PERF_REPETITIONS="$repetitions" +export T4_PERF_WARMUPS="$warmups" +export T4_PERF_MACHINE_LABEL="${T4_PERF_MACHINE_LABEL:-linux-vps-x64}" +export T4_PERF_OUTPUT_DIR="$output_dir" +export T4_AUTORESEARCH_TIMEOUT_SECONDS_RESOLVED="$timeout_seconds" +export T4_AUTORESEARCH_CPU_AFFINITY_RESOLVED="$cpu_affinity" + +{ + "${runner[@]}" timeout "$timeout_seconds" pnpm exec vp test run packages/client/test/projection.test.ts + "${runner[@]}" timeout "$timeout_seconds" pnpm exec vp test run scripts/perf/core.test.ts + node scripts/perf/autoresearch-report.mjs "$output_dir/latest-core.json" + printf 'ASI timeout_seconds=%s\n' "$timeout_seconds" + printf 'ASI cpu_affinity=%s\n' "$cpu_affinity" + printf 'ASI log=%s\n' "$log_path" +} 2>&1 | tee "$log_path" diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index fd30525..95462d9 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -29,6 +29,14 @@ settle before replacing the warm copy. The tail paint sample is taken after the that copy and two more browser animation frames have elapsed. These numbers show where a slowdown occurs; they are not a claim about a physical display's pixel response time. +Session-click phases have two boundaries. Metrics named `browser.session-dom-click-to-*` start at +the actual DOM click received by the application and are the primary product-performance signal. +The older `browser.session-click-to-*` metrics start immediately before Playwright asks Chromium to +click, so they also include Playwright's actionability checks, pointer movement, and input delivery. +The separate `browser.playwright-session-click-command-to-dom-click` metric makes that +automation-only time visible. Do not attribute it to application rendering or use it alone to judge +a product optimization. + `ui.mount-bounded-10k` stops after the original mount assertions, before the paint-only wait and phase-file write. `ui.playwright-scenario-instrumented` records the full instrumented test duration under a new name so paint instrumentation cannot silently change an existing comparison boundary. diff --git a/e2e/remote-app.spec.ts b/e2e/remote-app.spec.ts index c1c1b7e..b57c4aa 100644 --- a/e2e/remote-app.spec.ts +++ b/e2e/remote-app.spec.ts @@ -545,6 +545,11 @@ test("@soak mounts the bounded tail of a 10k history on a phone viewport", async }; requestAnimationFrame(inspect); }); + await rail.locator('[data-session-row="host-history/session-history"]').evaluate((element) => { + element.addEventListener("click", () => { + Object.assign(window, { __t4SessionDomClickAt: performance.now() }); + }, { capture: true, once: true }); + }); const sessionClickStartedAt = await page.evaluate(() => performance.now()); await rail.locator('[data-session-row="host-history/session-history"]').click(); @@ -587,6 +592,10 @@ test("@soak mounts the bounded tail of a 10k history on a phone viewport", async ) { throw new Error("browser paint observer returned incomplete phases"); } + const sessionDomClickAt = await page.evaluate(() => ( + window as typeof window & { __t4SessionDomClickAt?: number } + ).__t4SessionDomClickAt); + if (sessionDomClickAt === undefined) throw new Error("session DOM click timestamp was not captured"); const phaseOutput = process.env.T4_PERF_PHASE_OUTPUT; if (phaseOutput !== undefined) { await writeFile( @@ -595,6 +604,11 @@ test("@soak mounts the bounded tail of a 10k history on a phone viewport", async mountDuration, navigationDomContentLoaded: navigationTiming.domContentLoaded, connectedAfterDomContentLoaded: connectedAt - navigationTiming.domContentLoaded, + sessionClickCommandToDomClick: sessionDomClickAt - sessionClickStartedAt, + sessionDomClickToTranscriptVisible: phases.transcriptVisibleAt - sessionDomClickAt, + sessionDomClickToTailAligned: phases.tailAlignedAt - sessionDomClickAt, + sessionDomClickToRealListVisible: phases.realListVisibleAt - sessionDomClickAt, + sessionDomClickToTailPainted: phases.tailPaintedAt - sessionDomClickAt, sessionClickToTranscriptVisible: phases.transcriptVisibleAt - sessionClickStartedAt, sessionClickToTailAligned: phases.tailAlignedAt - sessionClickStartedAt, tailAlignedToRealListVisible: phases.realListVisibleAt - phases.tailAlignedAt, diff --git a/packages/client/src/projection.ts b/packages/client/src/projection.ts index 31d40aa..2f26463 100644 --- a/packages/client/src/projection.ts +++ b/packages/client/src/projection.ts @@ -591,6 +591,15 @@ function touch( options: Required, ): ProjectionSnapshot { const existing = snapshot.sessions.get(sessionKey); + if ( + existing !== undefined && + snapshot.lru.at(-1) === sessionKey && + snapshot.lru.length <= options.maxWarmSessions && + snapshot.activeSessionKey !== undefined && + snapshot.lru.includes(snapshot.activeSessionKey) + ) { + return snapshot; + } const lru = [...snapshot.lru.filter((item) => item !== sessionKey), sessionKey]; let sessions = snapshot.sessions; if (existing === undefined) @@ -619,15 +628,19 @@ function withSession( sessionKey: string, update: (session: SessionProjection) => SessionProjection, options: Required, + arrivalOrdinal?: number, ): ProjectionSnapshot { const warmed = touch(snapshot, sessionKey, options); const current = warmed.sessions.get(sessionKey)!; const updated = update(current); - if (updated === current) return warmed; - return Object.freeze({ - ...warmed, - sessions: mapWith(warmed.sessions, sessionKey, Object.freeze(updated)), - }); + if (updated === current) { + if (arrivalOrdinal === undefined || warmed.arrivalOrdinal === arrivalOrdinal) return warmed; + return Object.freeze({ ...warmed, arrivalOrdinal }); + } + const sessions = mapWith(warmed.sessions, sessionKey, Object.freeze(updated)); + return arrivalOrdinal === undefined + ? Object.freeze({ ...warmed, sessions }) + : Object.freeze({ ...warmed, sessions, arrivalOrdinal }); } function updateRoot( snapshot: ProjectionSnapshot, @@ -1103,10 +1116,9 @@ function applyProjectionInput( }); }, config, + frame.type === "event" ? eventArrivalOrdinal : undefined, ); - return frame.type === "event" - ? Object.freeze({ ...next, arrivalOrdinal: eventArrivalOrdinal }) - : next; + return next; } case "gap": { const sessionKey = key(String(frame.hostId), String(frame.sessionId)); diff --git a/packages/client/src/transcript-retention.ts b/packages/client/src/transcript-retention.ts index 2f8fdfa..875e572 100644 --- a/packages/client/src/transcript-retention.ts +++ b/packages/client/src/transcript-retention.ts @@ -46,25 +46,29 @@ const SECRET_KEY = /token|secret|password|credential|authorization|cookie|privat /** Exact byte totals for immutable values created by this module. */ const retainedJsonByteCache = new WeakMap(); - -const IMPORTANT_KEYS = [ - "type", - "images", - "role", - "text", - "reasoning", - "tool", - "title", - "args", - "result", - "details", - "customType", - "customDetails", - "output", - "stdout", - "stderr", - "content", -] as const; +const UTF8_ENCODER = new TextEncoder(); + +const IMPORTANT_KEY_PRIORITY: Readonly> = Object.freeze( + Object.assign(Object.create(null) as Record, { + type: 0, + images: 1, + role: 2, + text: 3, + reasoning: 4, + tool: 5, + title: 6, + args: 7, + result: 8, + details: 9, + customType: 10, + customDetails: 11, + output: 12, + stdout: 13, + stderr: 14, + content: 15, + }), +); +const IMPORTANT_KEY_COUNT = Math.max(...Object.values(IMPORTANT_KEY_PRIORITY)) + 1; interface SanitizedNode { readonly value: unknown; @@ -97,14 +101,53 @@ export type RetainedTranscriptEvent = Extract< { kind: "snapshot" | "entry" | "event" | "agent.transcript" | "gap" } >; +function jsonStringBytes(value: string): number { + let bytes = 2; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + code === 0x22 || + code === 0x5c || + code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ) { + bytes += 2; + } else if (code < 0x20) { + bytes += 6; + } else if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else { + bytes += 6; + } + } else { + bytes += code >= 0xdc00 && code <= 0xdfff ? 6 : 3; + } + } + return bytes; +} + export function retainedJsonBytes(value: unknown): number { - if (value !== null && (typeof value === "object" || typeof value === "function")) { + if (typeof value === "string") return jsonStringBytes(value); + if (typeof value === "number") return Number.isFinite(value) ? String(value).length : 4; + if (typeof value === "boolean") return value ? 4 : 5; + if (value === null) return 4; + if (typeof value === "object" || typeof value === "function") { const cached = retainedJsonByteCache.get(value); if (cached !== undefined) return cached; } const serialized = JSON.stringify(value); if (serialized === undefined) return 0; - return new TextEncoder().encode(serialized).byteLength; + return UTF8_ENCODER.encode(serialized).byteLength; } function rememberRetainedJsonBytes(value: T, bytes: number): T { @@ -131,9 +174,8 @@ function boundedInteger(value: number | undefined, fallback: number, ceiling: nu */ export function retainedText(value: string, maxJsonBytes: number): string { const budget = Math.max(2, Math.floor(maxJsonBytes)); - if (retainedJsonBytes(value) <= budget && retainedJsonBytes(value) <= MAX_RETAINED_VALUE_STRING_BYTES + 2) { - return value; - } + const valueBytes = retainedJsonBytes(value); + if (valueBytes <= budget && valueBytes <= MAX_RETAINED_VALUE_STRING_BYTES + 2) return value; const available = Math.max(0, Math.min(value.length, MAX_RETAINED_VALUE_STRING_BYTES)); let low = 0; @@ -156,19 +198,26 @@ export function retainedText(value: string, maxJsonBytes: number): string { } function orderedEntries(value: Record): Array<[string, unknown]> { - const priority = new Map(IMPORTANT_KEYS.map((key, index) => [key, index])); - return Object.entries(value).sort(([left], [right]) => { - const leftPriority = priority.get(left) ?? IMPORTANT_KEYS.length; - const rightPriority = priority.get(right) ?? IMPORTANT_KEYS.length; - return leftPriority - rightPriority; - }); + const entries = Object.entries(value); + for (let index = 1; index < entries.length; index += 1) { + const previous = IMPORTANT_KEY_PRIORITY[entries[index - 1]![0]] ?? IMPORTANT_KEY_COUNT; + const current = IMPORTANT_KEY_PRIORITY[entries[index]![0]] ?? IMPORTANT_KEY_COUNT; + if (previous <= current) continue; + entries.sort(([left], [right]) => { + const leftPriority = IMPORTANT_KEY_PRIORITY[left] ?? IMPORTANT_KEY_COUNT; + const rightPriority = IMPORTANT_KEY_PRIORITY[right] ?? IMPORTANT_KEY_COUNT; + return leftPriority - rightPriority; + }); + break; + } + return entries; } function sanitizeNode( value: unknown, budget: number, depth: number, - ancestors: ReadonlySet, + ancestors: Set, imageBlock = false, ): SanitizedNode | undefined { if (budget < 1 || value === undefined) return undefined; @@ -190,19 +239,19 @@ function sanitizeNode( return undefined; } - const nextAncestors = new Set(ancestors); - nextAncestors.add(value); + ancestors.add(value); if (Array.isArray(value)) { let bytes = 2; const output: unknown[] = []; for (const item of value.slice(0, MAX_RETAINED_VALUE_ARRAY_ITEMS)) { const separator = output.length === 0 ? 0 : 1; - const child = sanitizeNode(item, budget - bytes - separator, depth + 1, nextAncestors); + const child = sanitizeNode(item, budget - bytes - separator, depth + 1, ancestors); if (child === undefined) continue; output.push(child.value); bytes += separator + child.bytes; if (bytes >= budget) break; } + ancestors.delete(value); return bytes <= budget ? { value: Object.freeze(output), bytes } : undefined; } @@ -231,7 +280,7 @@ function sanitizeNode( item, budget - bytes - fixedBytes, depth + 1, - nextAncestors, + ancestors, sourceIsImageBlock, ); if (child === undefined) continue; @@ -240,6 +289,7 @@ function sanitizeNode( bytes += fixedBytes + child.bytes; if (bytes >= budget) break; } + ancestors.delete(value); return bytes <= budget ? { value: Object.freeze(output), bytes } : undefined; } @@ -378,7 +428,8 @@ export function appendRetainedValue( retainedCount -= 1; } - const candidates = [...values.slice(firstPriorIndex), value]; + const candidates = values.slice(firstPriorIndex) as T[]; + candidates.push(value); bytes += retainedArrayItemBytes(value) + (retainedCount === 0 ? 0 : 1); retainedCount += 1; @@ -390,7 +441,9 @@ export function appendRetainedValue( firstRetainedIndex += 1; } - const retained = Object.freeze(candidates.slice(firstRetainedIndex)); + const retained = Object.freeze( + firstRetainedIndex === 0 ? candidates : candidates.slice(firstRetainedIndex), + ); return rememberRetainedJsonBytes(retained, bytes); } diff --git a/packages/client/test/transcript-retention.test.ts b/packages/client/test/transcript-retention.test.ts index 22966da..e33f19d 100644 --- a/packages/client/test/transcript-retention.test.ts +++ b/packages/client/test/transcript-retention.test.ts @@ -40,6 +40,55 @@ function toolResultEntry(index: number): DurableEntry { } describe("retained transcript budgets", () => { + it("matches JSON UTF-8 byte lengths for primitive and fallback values", () => { + const encoder = new TextEncoder(); + const values: readonly unknown[] = [ + "", + "\"\\/\b\t\n\f\r\u0000\u001f", + "Aé€😀", + "\ud800", + "\udc00", + 0, + -0, + 1.5, + 1e21, + 1e-7, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + true, + false, + null, + { nested: ["Aé€😀", 1e21, null] }, + ["value", undefined], + ]; + + for (const value of values) { + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new Error("fixture must serialize"); + expect(retainedJsonBytes(value)).toBe(encoder.encode(serialized).byteLength); + } + + for (const value of [undefined, () => "value", Symbol("value")]) { + expect(retainedJsonBytes(value)).toBe(0); + } + }); + + it("prioritizes retained fields over prototype-named input fields", () => { + const expected = { type: "tool.result", result: "ok" }; + const retained = sanitizeRetainedRecord( + { + constructor: "x".repeat(100), + type: expected.type, + result: expected.result, + }, + retainedJsonBytes(expected), + ); + + expect(retained).toEqual(expected); + expect(retainedJsonBytes(retained)).toBe(retainedJsonBytes(expected)); + }); + it("keeps the newest contiguous suffix under count, entry, and cumulative byte caps", () => { const retained = retainDurableEntries( Array.from({ length: 200 }, (_, index) => toolResultEntry(index)), diff --git a/scripts/perf/autoresearch-report.mjs b/scripts/perf/autoresearch-report.mjs new file mode 100644 index 0000000..56ef3a1 --- /dev/null +++ b/scripts/perf/autoresearch-report.mjs @@ -0,0 +1,103 @@ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const reportPath = process.argv[2]; +if (!reportPath) throw new Error("usage: node scripts/perf/autoresearch-report.mjs "); + +const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const reportFile = resolve(reportPath); +const reportContents = readFileSync(reportFile); +const report = JSON.parse(reportContents.toString("utf8")); +if (report.kind !== "core") throw new Error(`expected a core report, received ${report.kind}`); + +const metrics = new Map(report.metrics.map((metric) => [metric.name, metric])); +const required = [ + "projection.snapshot", + "projection.events", + "projection.event-ns-per-event", + "projection.events-heap-growth", +]; +for (const name of required) { + if (!metrics.has(name)) throw new Error(`core report is missing ${name}`); +} + +const sourcePaths = [ + "packages/client/src/projection.ts", + "packages/client/src/transcript-retention.ts", +]; +const sourceHash = createHash("sha256"); +for (const sourcePath of sourcePaths) { + sourceHash.update(sourcePath); + sourceHash.update("\0"); + sourceHash.update(readFileSync(resolve(repoRoot, sourcePath))); + sourceHash.update("\0"); +} +const sourceTreeHash = sourceHash.digest("hex"); + +const event = metrics.get("projection.event-ns-per-event"); +const eventDuration = metrics.get("projection.events"); +const snapshot = metrics.get("projection.snapshot"); +const heap = metrics.get("projection.events-heap-growth"); +const artifact = relative(repoRoot, reportFile); +const buildMode = "vite-plus-test-transform"; +const workload = `history-${report.scenario.entryCount}-events-${report.scenario.eventCount}-v1`; +const timeoutSeconds = Number.parseInt( + process.env.T4_AUTORESEARCH_TIMEOUT_SECONDS_RESOLVED ?? "", + 10, +); +if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1) { + throw new Error("missing resolved autoresearch timeout"); +} +const cpuAffinity = process.env.T4_AUTORESEARCH_CPU_AFFINITY_RESOLVED; +if (!cpuAffinity) throw new Error("missing resolved autoresearch CPU affinity"); + +const evidenceFile = resolve(dirname(reportFile), "evidence.json"); +const evidenceArtifact = relative(repoRoot, evidenceFile); +writeFileSync( + evidenceFile, + `${JSON.stringify( + { + schemaVersion: 1, + source: { + treeHash: sourceTreeHash, + commit: report.machine.commit, + dirty: report.machine.dirty, + }, + execution: { + buildMode, + workload, + repetitions: report.scenario.repetitions, + warmups: report.scenario.warmups, + timeoutSeconds, + cpuAffinity, + }, + artifact: { + path: artifact, + sha256: createHash("sha256").update(reportContents).digest("hex"), + }, + }, + null, + 2, + )}\n`, +); + +const lines = [ + `METRIC projection_event_ns_per_event=${event.median}`, + `METRIC projection_event_p95_ns_per_event=${event.p95}`, + `METRIC projection_events_ms=${eventDuration.median}`, + `METRIC projection_snapshot_ms=${snapshot.median}`, + `METRIC projection_event_heap_growth_bytes=${heap.median}`, + `ASI source_tree_hash=${sourceTreeHash}`, + `ASI source_commit=${report.machine.commit}`, + `ASI source_dirty=${report.machine.dirty}`, + `ASI build_mode=${buildMode}`, + `ASI workload=${workload}`, + `ASI repetitions=${report.scenario.repetitions}`, + `ASI warmups=${report.scenario.warmups}`, + `ASI event_samples_ns_per_event=${JSON.stringify(event.samples)}`, + `ASI artifact=${artifact}`, + `ASI evidence=${evidenceArtifact}`, +]; +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/scripts/perf/core.test.ts b/scripts/perf/core.test.ts index ffc7b16..25ab7ce 100644 --- a/scripts/perf/core.test.ts +++ b/scripts/perf/core.test.ts @@ -7,6 +7,7 @@ import { positiveInteger, sample, summarize, writeReport } from "./report.mjs"; const ENTRY_COUNT = positiveInteger(process.env.T4_PERF_ENTRY_COUNT, 10_000, "entry count"); const EVENT_COUNT = positiveInteger(process.env.T4_PERF_EVENT_COUNT, 100_000, "event count"); const REPETITIONS = positiveInteger(process.env.T4_PERF_REPETITIONS, 5, "repetitions"); +const WARMUPS = positiveInteger(process.env.T4_PERF_WARMUPS, 1, "warmups"); const HOST_ID = "host-perf"; const SESSION_ID = "session-perf"; const VERSION = "omp-app/1"; @@ -79,7 +80,7 @@ test( throw new Error("projection snapshot did not retain the expected entries"); } }, - { repetitions: REPETITIONS }, + { repetitions: REPETITIONS, warmups: WARMUPS }, ); const rowProjection = transcriptProjection(); @@ -91,11 +92,11 @@ test( throw new Error("row derivation returned fewer rows than expected"); } }, - { repetitions: REPETITIONS }, + { repetitions: REPETITIONS, warmups: WARMUPS }, ); const eventSamples = []; - for (let repetition = 0; repetition < REPETITIONS; repetition += 1) { + for (let repetition = -WARMUPS; repetition < REPETITIONS; repetition += 1) { globalThis.gc?.(); let state = applyPublicFrame(createProjectionSnapshot(), snapshotFrame() as never); const heapBefore = process.memoryUsage().heapUsed; @@ -113,14 +114,32 @@ test( } as never, ); } - eventSamples.push({ - elapsedMs: performance.now() - startedAt, - heapGrowthBytes: Math.max(0, process.memoryUsage().heapUsed - heapBefore), - }); + const elapsedMs = performance.now() - startedAt; + if (repetition >= 0) { + eventSamples.push({ + elapsedMs, + heapGrowthBytes: Math.max(0, process.memoryUsage().heapUsed - heapBefore), + }); + } const session = state.sessions.values().next().value; const expectedRetainedEvents = Math.min(EVENT_COUNT, 512); - if (session?.events.length !== expectedRetainedEvents || session.entries.length !== ENTRY_COUNT) { - throw new Error("event throughput benchmark violated bounded retention"); + if ( + session?.events.length !== expectedRetainedEvents || + session.entries.length !== ENTRY_COUNT || + String(session.entries[0]?.id) !== "entry-0" || + String(session.entries.at(-1)?.id) !== `entry-${ENTRY_COUNT - 1}` || + session.cursor?.epoch !== "perf-epoch" || + session.cursor.seq !== EVENT_COUNT + 1 || + session.historyTruncated === true || + state.arrivalOrdinal !== EVENT_COUNT + ) { + throw new Error("event throughput benchmark violated snapshot, cursor, history, or retention semantics"); + } + for (let offset = 0; offset < expectedRetainedEvents; offset += 1) { + const retained = session.events[offset]?.event as { readonly index?: unknown } | undefined; + if (retained?.index !== EVENT_COUNT - expectedRetainedEvents + offset) { + throw new Error("event throughput benchmark violated retained event ordering"); + } } } @@ -129,6 +148,11 @@ test( direction: "lower", ...summarize(eventSamples.map((value) => value.elapsedMs)), }; + const eventPerItemMetric = { + name: "projection.event-ns-per-event", + direction: "lower", + ...summarize(eventSamples.map((value) => value.elapsedMs * 1_000_000 / EVENT_COUNT), "ns/event"), + }; const heapMetric = { name: "projection.events-heap-growth", direction: "lower", @@ -138,9 +162,18 @@ test( ), }; - writeReport("core", [snapshotMetric, rowsMetric, eventMetric, heapMetric], { - scenario: { entryCount: ENTRY_COUNT, eventCount: EVENT_COUNT, repetitions: REPETITIONS }, - }); + writeReport( + "core", + [snapshotMetric, rowsMetric, eventMetric, eventPerItemMetric, heapMetric], + { + scenario: { + entryCount: ENTRY_COUNT, + eventCount: EVENT_COUNT, + repetitions: REPETITIONS, + warmups: WARMUPS, + }, + }, + ); }, 120_000, ); diff --git a/scripts/perf/ui.mjs b/scripts/perf/ui.mjs index 8ec0011..e961936 100644 --- a/scripts/perf/ui.mjs +++ b/scripts/perf/ui.mjs @@ -13,6 +13,11 @@ const phaseSamples = { mountDuration: [], navigationDomContentLoaded: [], connectedAfterDomContentLoaded: [], + sessionClickCommandToDomClick: [], + sessionDomClickToTranscriptVisible: [], + sessionDomClickToTailAligned: [], + sessionDomClickToRealListVisible: [], + sessionDomClickToTailPainted: [], sessionClickToTranscriptVisible: [], sessionClickToTailAligned: [], tailAlignedToRealListVisible: [], @@ -119,6 +124,31 @@ writeReport( direction: "lower", ...summarize(phaseSamples.connectedAfterDomContentLoaded), }, + { + name: "browser.playwright-session-click-command-to-dom-click", + direction: "lower", + ...summarize(phaseSamples.sessionClickCommandToDomClick), + }, + { + name: "browser.session-dom-click-to-transcript-visible", + direction: "lower", + ...summarize(phaseSamples.sessionDomClickToTranscriptVisible), + }, + { + name: "browser.session-dom-click-to-tail-aligned", + direction: "lower", + ...summarize(phaseSamples.sessionDomClickToTailAligned), + }, + { + name: "browser.session-dom-click-to-real-list-visible", + direction: "lower", + ...summarize(phaseSamples.sessionDomClickToRealListVisible), + }, + { + name: "browser.session-dom-click-to-tail-painted", + direction: "lower", + ...summarize(phaseSamples.sessionDomClickToTailPainted), + }, { name: "browser.session-click-to-transcript-visible", direction: "lower", @@ -150,7 +180,7 @@ writeReport( fixture: "history-10k-v1", viewport: { width: 390, height: 844 }, repetitions, - note: "Browser phases use the renderer performance clock. Tail painted is sampled after the real list is visible and two animation frames have elapsed.", + note: "Browser phases use the renderer performance clock. session-dom-click metrics start at the real DOM click; session-click metrics include Playwright action delivery. Tail painted is sampled after the real list is visible and two animation frames have elapsed.", }, }, );