diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69feb12..1f01242 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -241,6 +241,8 @@ jobs: # own behavioral tests and exact guide receipts; this job needs no GPU. - name: Lint the WASM target and generate the browser application run: just web-verify + - name: Check the optional WASM measurement adapter + run: just benchmark-web-check - name: Save verified WASM dependencies and build output if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.dependencies.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ff5703c..c9935d6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,6 +9,7 @@ "/docs/preview/vendor/**", "/vendor/**", "/examples/**", - "/fixtures/gltf/*/**" + "/fixtures/gltf/*/**", + "/fixtures/benchmarks/*/source/**" ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb5e7e0..78c33ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ repository's pre-push hook. The optional CI container supplies Node. Run `just fmt` before checking your changes with `just verify`. - [Development](docs/development.md): setup, commands, and document/import formats. +- [Testing](TESTING.md): verification entry points, performance tooling, external suites, and fixture sources. - [Architecture](docs/architecture/architecture.md): responsibilities and state boundaries. - [Proposals](rfcs/README.md): concrete future ideas, rationale, and unresolved decisions; short work items stay in [TODO.md](TODO.md). - [Documentation pipeline](docs/architecture/documentation-pipeline.md): executable guides, input replay, callouts, and animation authoring. diff --git a/Cargo.toml b/Cargo.toml index 3371eff..d6ac518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" rust-version = "1.95" publish = false +[features] +# Contributor-only instrumentation; normal builds contain no clocks or recorder. +viewport-measure = [] + [lib] crate-type = ["rlib", "cdylib"] # This private application library owns the existing single test inventory. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..7fc826b --- /dev/null +++ b/TESTING.md @@ -0,0 +1,129 @@ +# Testing N3 + +This is the contributor index for verification, benchmark tooling, reference +suites, and test assets. Setup and environment details live in +[development](docs/development.md); change and review rules live in +[AGENTS.md](AGENTS.md). The [justfile](justfile) defines the executable commands. + +## Choose the evidence you need + +| Task | Entry point | What it establishes | +| ---------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Verify an implementation change | `just verify` | Native formatting, Clippy, tooling regressions, and the complete Rust suite including exact guide replay | +| Run Rust behavior tests | `just test` | The native Rust suite, including guide checks; accepts Cargo test arguments for focused runs | +| Check generated user documentation | `just docs check` | Read-only replay and exact artifact verification on the native renderer | +| Check the browser build | `just web-verify` | Node wrapper regressions, WASM Clippy, and static-site generation; does not launch a browser | +| Check development tooling | `just tools-test` | Python tooling tests and Node wrapper tests | +| Check real Metal capture | `just test-metal` | Focused capture regressions on a Mac with an available GPU | +| Reproduce CI environments | `just ci`, `just ci-macos` | Explicit Ubuntu Docker checks, or macOS bundle/signing and native integration checks, respectively | +| Measure the viewport | `just measure-viewport --help` | Discover the opt-in native/browser build-run-measure workflow and its workload options | +| Repeat and compare measurements | `just benchmark-viewport --help` | Run a controlled suite, aggregate raw reports, and compare a baseline with a candidate | +| Verify benchmark correctness | `just benchmark-test` | Recorder, evidence admission, statistics, lifecycle, browser policy, and renderer parity contracts | + +`just verify` remains the native development gate. Browser installation and +performance measurements are separate, optional workflows. See +[verification and hooks](docs/development.md#verification-and-git-hooks) for +the full environment and CI boundaries. + +`just benchmark-web-check` compiles and lints the optional WASM measurement +adapter; the WASM CI lane includes this check alongside the ordinary browser +build. It does not install or launch a browser. + +Compilation, replay, device interaction, and performance measurements answer +different questions. Report which evidence was collected. In particular, a +successful WASM build does not prove browser input behavior, and a short CPU +submission time does not measure GPU completion or display latency. + +## Browser runtime and performance + +The [browser development guide](docs/development.md#browser-build) owns runtime +interaction checks, embedding, and host limitations. Native and web share one +feature UI and user guide. + +The [viewport measurement guide](docs/architecture/viewport-measurement.md) owns +setup, commands, report fields, workload definitions, fixture selection, and +comparison methodology. Its `editor`, `viewport`, and `renderer` modes measure the +complete app, editor viewport without egui, and scene renderer without editor +projection/feedback, respectively. They preserve the scene dimensions and record +execution counters to verify exclusions; **Hide UI** is not an isolation mode. +Its Playwright runner launches a fresh isolated Chromium +profile, supports headless and headed execution, and saves browser/GPU diagnostics +with raw samples. This currently automates the measurement loop, not every editor +interaction. The [Playwright browser documentation](https://playwright.dev/docs/browsers) +explains browser installation and execution modes; N3's tooling owns its version +pin and local cache paths. + +Use the [repeated benchmark workflow](docs/architecture/viewport-measurement.md#repeated-benchmark-workflow) +for performance investigations. It runs fresh application/browser instances in +seeded shuffled rounds and writes raw reports, logs, and CLI/JSON summaries. The +suite defaults to a visible isolated browser because headless mode can change +frame pacing. It summarizes independent runs without treating every frame as an +independent repetition. Results are descriptive evidence, not a statistical +significance claim or a performance CI gate. + +The implementation lives in [`tools/benchmark/`](tools/benchmark/README.md), with +its own tests and wire-contract fixtures. These correctness tests run in ordinary +verification and CI; timing collection stays opt-in. + +Benchmark internal workloads: authored topology, scene rendering, object count, +or deformation. Keep format decoding correctness and loading performance distinct +from steady-state rendering. The +[authoring and interchange contract](docs/architecture/authoring-and-interchange.md) +explains why editable meshes and immutable scene assets need different coverage +even when an optimization uses shared code. Each platform retains its own +[capabilities and performance policy](docs/architecture/platform-boundaries.md). + +## External reference suites and tools + +These are resources to select from for a concrete investigation. Listing an +upstream suite here does not mean N3 runs or passes it in CI. Keep selected cases +and tool versions reproducible, and record their purpose alongside the result. + +| Resource | Use in N3 | +| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| [Khronos glTF Asset Generator](https://github.com/KhronosGroup/glTF-Asset-Generator) | Targeted importer cases covering accessors, buffers, materials, primitives, animation, and other glTF semantics | +| [Khronos glTF Validator](https://github.com/KhronosGroup/glTF-Validator) | Validate source assets independently of N3; valid input does not establish correct rendered output | +| [WebGPU Conformance Test Suite](https://github.com/gpuweb/cts) | Investigate behavior defined by the graphics API; the suite can run inside WPT or standalone, and does not provide an editor performance score | +| [wgpu testing guide](https://github.com/gfx-rs/wgpu/blob/trunk/docs/testing.md) | Find upstream validation, GPU, shader, and benchmark coverage when reducing a problem to wgpu or naga | + +Use the [executable documentation pipeline](docs/architecture/documentation-pipeline.md) +for N3's feature behavior and rendered guide evidence. Upstream conformance tests +complement that application-specific contract. + +## Benchmark methodology + +These primary references explain the practices used by N3's benchmark tooling. +They are useful beyond any one renderer optimization; they are not dependencies +of the suite or evidence that N3 implements each framework's statistical model. + +| Resource | Practice to apply | +| ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| [pyperf runner](https://pyperf.readthedocs.io/en/stable/runner.html) and [analysis](https://pyperf.readthedocs.io/en/stable/analyze.html) | Fresh worker processes, explicit warm-up, repeated measurements, raw samples and metadata, and visible noise rather than a single score | +| [Google Benchmark user guide](https://google.github.io/benchmark/user_guide.html) | Random interleaving to reduce ordering bias, repeated-run aggregates, and machine-readable results | +| [Criterion analysis](https://bheisler.github.io/criterion.rs/book/analysis.html) | Separate warm-up, measurement, and comparison; inspect outliers and distinguish small changes from noise | + +N3's harness measures the real application's retained frame window. Whole-command +timing includes startup/import/shutdown, and a function microbenchmark has a +different execution model. Use those tools for a matching question rather than +substituting them for end-to-end frame measurements. + +## Fixture sources and provenance + +| Source | Role | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| [N3 geometry fixtures](fixtures/README.md) | Canonical small geometry and import cases, with expected topology and source attribution | +| [N3 glTF fixtures](fixtures/gltf/README.md) | The existing pinned Khronos subset, license records, checksums, and validator evidence | +| [Chess Set benchmark fixture](fixtures/benchmarks/chess-set/README.md) | Pinned CC0 Poly Haven scene, reproducible GLB, and source/derivative validation for native/browser measurements | +| [Khronos glTF Sample Assets](https://github.com/KhronosGroup/glTF-Sample-Assets) | Curated representative and feature-focused models; licensing is per asset, not uniformly CC0 | +| [Poly Haven](https://polyhaven.com/) and its [asset license](https://polyhaven.com/license) | CC0 models, textures, and environment maps when the existing corpus lacks a needed workload | + +Reuse existing fixtures first. Keep canonical assets and attribution in +`fixtures/`; keep disposable experiments and review output in ignored local +storage. Preserve upstream files and record exact revisions, bytes, and checksums. +Derived fixtures need a reproducible recipe and their own provenance. The +[benchmark fixture policy](docs/architecture/viewport-measurement.md#workloads-and-fixture-selection) +defines the initial workload plan and suggested size budgets. + +When adopting another suite or tool, add its authoritative link here and explain +which layer it tests. Put detailed instructions beside the owning workflow; +keep execution receipts and temporary results out of this index. diff --git a/TODO.md b/TODO.md index 50dcdba..42df30a 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,7 @@ and document the implemented behavior in the executable user guide. | Native accessibility | Wire egui's AccessKit adapter through the native event loop, add polite live announcements for toasts, and verify with VoiceOver. Keyboard focus and widget labels alone do not provide screen-reader announcements. | | OS font support | Fonts as document materials; discovery/loading boundary suitable for a later web implementation. Separate from bundled interface fonts. | | Web platform follow-up | Build on the [WASM browser baseline](docs/development.md#browser-build): browser/device coverage, touch/IME, external asset resource workflows, persistence/recovery, and multiple embedded workspaces. | -| Native/browser viewport profiling | [Issue #2](https://github.com/gridaco/n3/issues/2): reproduce large-mesh orbit stutter with comparable builds, real browser runs, and stage-level timings. Distinguish shared costs, web-host mistakes, and measured platform constraints before optimizing; follow the WASM baseline delivery. | +| Native/browser viewport optimization | [Issue #2](https://github.com/gridaco/n3/issues/2): use the [measurement harness](docs/architecture/viewport-measurement.md) to attribute large-mesh orbit costs and validate focused improvements. Physical-input latency, GPU/presentation timing, and additional workload families remain follow-ups. Preserve each host's performance policy; optimization is deferred. | | Linux capture test concurrency | Diagnose allocator aborts and segmentation faults in parallel lavapipe tests, reproduced on the native baseline. The [opt-in CI runner](docs/development.md#verification-and-git-hooks) uses one test thread while retaining every test and exact capture; establish the cause before restoring parallel execution. | | Exact macOS 27 guide frame | Replace the approximate static window chrome with measurements from a native macOS 27 N3 capture or Apple's design kit. Evaluate the translucent traffic-light material, highlights, and shadow only if exact mirroring is worth the rendering complexity. | diff --git a/docs/architecture/frame-meter.md b/docs/architecture/frame-meter.md new file mode 100644 index 0000000..cd10af5 --- /dev/null +++ b/docs/architecture/frame-meter.md @@ -0,0 +1,76 @@ +# Application frame meter + +The shared **View → Developer → Show FPS Meter** action enables a transient, +off-by-default observation of host frame cadence. Native and browser use the +same arithmetic and overlay. The native host retains its presentation policy; +the browser retains its event-loop scheduling. This is a small live diagnostic, +separate from the repeatable [viewport measurement harness](viewport-measurement.md). + +## Measurement contract + +Each host records one monotonic timestamp after a successful application frame +has been submitted and its surface presentation requested. Failed acquisition, +skipped rendering, and egui layout retries do not count as completed frames. The +first timestamp establishes an anchor; subsequent timestamps define intervals. +For N intervals lasting T seconds, the sample is N/T FPS and 1000T/N ms per frame. +It is not an average of instantaneous reciprocal frame times. + +A sample publishes on a successful frame once its observation span reaches at +least 500 ms. Actual elapsed time is the denominator, including scheduling gaps, +surface-acquisition waits, idle time, and stalls. There is no assumed refresh +rate, fabricated sample at startup, clamping of long intervals, or catch-up timer. +Toggle-off discards observations so re-enabling starts a fresh measurement. + +These timestamps establish application submission cadence. They do not establish +GPU completion, display refresh, compositor presentation, dropped display frames, +or input-to-display latency. Host/browser throttling can reduce the observed +cadence; a low idle rate is not evidence of slow scene rendering. The visible +label is **App FPS · last sample**, with a waiting state before the first sample. + +## Execution and UI boundaries + +The meter never requests a redraw or installs a timer. An idle window retains +its last painted sample until ordinary work causes another frame. The host +records after painting, so a newly published sample becomes visible on a later +ordinary frame. Shared measurement state accepts elapsed monotonic timestamps; +it does not own native or browser clock APIs. + +Collection uses bounded state and constant work per observed frame. It introduces +no GPU query, readback, fence, wait, per-frame allocation, or sorting. +Enabling any overlay has some CPU and draw cost; this contract promises a small, +bounded observation path, not mathematically zero overhead. Disabling the meter +avoids collection, per-frame clock reads, and its overlay. Display text is +formatted only when its sample changes. + +The overlay deliberately uses a plain black rectangle and monospace white/gray +text in either theme. It is anchored to the bottom-right corner of the application +window, independent of the viewport rectangle, panels, and other overlays. +Its paint-only geometry has no hit target or focus owner. +Hiding the UI hides the overlay while leaving collection enabled. +The toggle is not a document edit or persistent preference and has no shortcut. +Use the ordinary semantic action and menu presenter, not a second UI-specific +toggle path. + +## Verification boundaries + +Pure recorder tests use explicit timestamps to verify the denominator, initial +anchor, publication interval, variable cadence, long gaps, and toggle resets. +Host wiring must count only successful surface presentation submissions and must +not introduce extra wakeups. UI tests cover paint-only input behavior and normal +menu dispatch. + +For an overhead comparison, use repeated runs of the same build and scene with +the real toggle off/on, record that state, and keep frame dimensions and workload +identical. Include a stationary forced-rendering case as well as orbit: expensive +projection can mask a small overlay cost. Compare run-level cadence and CPU +distributions, retaining tails and background-load limitations. A browser clock +read, a rendered overlay, and an extra scheduling loop are different costs; +the meter adds only the first two. The measurement harness records actual meter +state so unlike workloads cannot silently become repeats of one case. + +The [executable user guide](../guide/fps-meter.md) exercises the actual menu, +waiting overlay, Hide UI, and unchanged document, selection, settings, and undo +history. Its replay clock is deliberately not passed off as a host presentation +clock: the screenshot shows a waiting meter, never invented numeric performance. +Runtime native/browser observation is required to establish actual host cadence; +the guide is UI evidence, not a performance result. diff --git a/docs/architecture/platform-boundaries.md b/docs/architecture/platform-boundaries.md index 39b6495..4af160d 100644 --- a/docs/architecture/platform-boundaries.md +++ b/docs/architecture/platform-boundaries.md @@ -82,6 +82,11 @@ Compilation and architectural similarity establish neither frame-time parity nor the best achievable performance. Report the host, workload, and measured effect alongside any remaining limits. +The [application frame meter](frame-meter.md) is a passive submission-cadence +readout. It consumes host timestamps without adding wakeups or GPU waits; the +[viewport measurement harness](viewport-measurement.md) owns repeatable workloads +and comparisons. + ## One product experience Keep one feature UI, semantic action catalog, and user guide. Each feature owns diff --git a/docs/architecture/viewport-measurement.md b/docs/architecture/viewport-measurement.md new file mode 100644 index 0000000..fefaa8d --- /dev/null +++ b/docs/architecture/viewport-measurement.md @@ -0,0 +1,413 @@ +# Viewport measurement + +This contributor tool starts the investigation in [issue #2](https://github.com/gridaco/n3/issues/2). +It builds the real native or WASM application with the optional `viewport-measure` +feature, runs a bounded workload through the production rendering path, and saves +raw frame samples and summaries. Ordinary builds do not enable the feature. +Measurement output and compiled browser artifacts belong in ignored local storage. + +The [benchmark package](../../tools/benchmark/README.md) documents code ownership, +test layers, and producer/consumer contract checks. `just benchmark-test` verifies +the measurement tooling without collecting a performance baseline or installing +a browser; ordinary `just verify` and CI include those correctness checks. + +The single-run tool supplies raw evidence; the repeated benchmark tool controls +run order and produces machine-readable and plain-text comparisons. It does not +establish that browser overhead is +unavoidable, identify all causes of a slowdown, or approve a renderer rewrite. +Follow the [platform performance contract](platform-boundaries.md): retain each +host's execution policy and use evidence to distinguish a browser integration +mistake from a shared rendering cost. + +## Build, run, measure + +Run from the repository root. The Python tool uses the repository's Cargo cache +and respects `CARGO_HOME` and `CARGO_TARGET_DIR`. The optional browser target and +matching glue generator are installed through `just web-setup`. Install the separate +automated browser once with `python3 -m tools.benchmark.measure setup-browser` +(or `just measure-browser-setup`). This pins Playwright 1.63.0 in `.cache/viewport-tools/` +and its full Chromium browser in `.cache/viewport-browsers/`. It installs no global +browser and is not part of the native verifier. + +```sh +python3 -m tools.benchmark.measure --host native --profile release \ + --mode editor \ + --input fixtures/benchmarks/chess-set/chess-set-1k.glb \ + --output .cache/measure/native-editor-release-orbit-01.json + +python3 -m tools.benchmark.measure --host web --profile release \ + --mode editor \ + --input fixtures/benchmarks/chess-set/chess-set-1k.glb \ + --output .cache/measure/web-editor-release-orbit-01.json +``` + +The native command launches a separate editor process, measures, writes its +report, and exits. The browser command defaults to isolated automated headless +Chromium: it opens the harness, waits for the imported editor, presses **Run**, +saves the report, and closes its own browser/context. It uses a fresh temporary +profile, never the user's Chrome profile or an attached browser session. The +command succeeds only after a complete report and successful runner shutdown. +Timeout or interruption requests cleanup of the owned runner and browser. + +Use `--browser headed` for the same automated loop with a visible isolated window. +Use `--browser manual` only for an explicitly chosen manual session: open the +printed localhost URL, press **Run**, and keep the page visible and focused. +`--open` opens the default browser only with `--browser manual`; it is rejected +for automated runs. Runtime timeout is controlled by `--timeout` (default 600 +seconds; compilation time is excluded). Every invocation needs a fresh output +path. Existing reports are never overwritten. + +Automation uses the `chromium` channel's full browser for both headed and new +headless mode; setup omits the separate headless shell with `--no-shell`. +[Playwright's browser documentation](https://playwright.dev/docs/browsers#chromium-new-headless-mode) +explains that distinction and the browser versions required by each Playwright +release. Device scale defaults to 2; `--device-scale-factor` changes the automated +context's scale while `--width`/`--height` still request physical pixels. Manual +sessions use the actual browser/device scale. + +The runner sets Chromium's process device scale as well as the context's scale. +In the initial macOS headless check, context emulation alone reported DPR 2 but +`ResizeObserver.devicePixelContentBoxSize` still reported DPR 1. winit uses that +physical-size observation. The process flag makes the observations agree; it is +recorded in launch metadata, and a canvas-size mismatch still fails the run. + +Headless rendering is a distinct test environment, not proof of native display +behavior or ordinary headed browser performance. Record browser mode/version, +launch configuration, and the actual GPU adapter/backend before comparing runs. +The runner does not silently switch to software rendering flags when GPU setup +fails. [Chrome's headless GPU testing guidance](https://developer.chrome.com/blog/supercharge-web-ai-testing) +illustrates why GPU availability must be checked rather than inferred from a +successful browser launch. Platform-specific GPU setup needs a recorded experiment; +do not copy Linux GPU flags into every host or equate a software adapter with a +hardware result. + +The local server binds only `127.0.0.1`. A per-run token gates configuration, +selected input bytes, generated WASM/glue, and report submission. Only the file +explicitly selected with `--input` is exposed; a report cannot choose its output +path. Host/origin checks and a body-size limit constrain report submission. The +model stays local and is not copied into the repository or generated site. Treat +the printed URL as a temporary local capability and do not share it. + +Each invocation compiles with `--locked --features viewport-measure`. Choose +`--profile dev` for a development-build comparison or `--profile release` for an +optimized-build comparison. Both hosts use that same named Cargo profile; the +ordinary browser `web` profile is not used here. The current `dev` profile uses +application optimization level 1 and dependency level 2; release uses Cargo's +release settings unless explicitly overridden. Reports record the command, +profile, Rust compiler, build flags, profile overrides, UTC timestamp, revision, +dirty state, and a fingerprint of tracked and nonignored untracked source files. +WASM builds use `build/measure-web//`, separate from `build/web/`. + +Available workload controls: + +| Option | Default | Meaning | +| ----------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------- | +| `--frames` | `180` | Retained frames after warm-up, maximum 10,000 | +| `--warmup` | `60` | Discarded frames before retained samples, maximum 10,000 | +| `--workload` | `orbit` | `orbit` advances the shared camera operation by a fixed angle per rendered frame; `stationary` holds the camera still | +| `--mode` | `editor` | Complete `editor`, UI-free `viewport`, or `renderer` without editor projection and feedback; see mode contracts below | +| `--selected` | off | Select the first document object in `editor` or `viewport`; rejected in `renderer` mode | +| `--width`, `--height` | `1280`, `800` | Requested physical dimensions of the complete editor surface, not CSS pixels or the inner 3D viewport | +| `--no-stage-timing` | off | Disable optional CPU stage and projection timing; execution counters remain enabled to verify mode exclusions | +| `--browser` | `headless` | Automated `headless` or `headed` Chromium; `manual` explicitly opts out of automation | +| `--device-scale-factor` | `2` | Automated browser scale, finite and greater than zero up to 4; manual browser scale is observed instead | + +Both hosts reset to isolated default preferences and fixed solid shading, edges, +grid, light theme, and camera state. They do not modify the user's preferences. +Fresh automated runs start with the [FPS meter](frame-meter.md) disabled. Reports +record its actual initial enabled state and each retained frame's state; a change +invalidates the capture. Meter-on and meter-off captures are different UI +workloads and must not be pooled as repeats. Older reports without this field +have unknown meter state and cannot be mixed with observed-state reports. +The same harness accepts authored mesh inputs such as OBJ and self-contained GLB +scene assets. Linked asset source resolution and other document dependencies need +separate workload definition; the input hash identifies only the selected file, +not an asset dependency graph. Prefer self-contained fixtures for cross-host runs. + +## Repeated benchmark workflow + +Use `just benchmark-viewport` for an optimization investigation. It wraps the +existing measurement path, builds once per requested host, and runs each repeat +in a fresh native process or isolated browser. Cases run sequentially; their order +is shuffled within each round with a recorded seed. This reduces systematic +ordering bias without running competing workloads simultaneously. +The defaults are five repeats, seed `0`, and the `release` Cargo profile. +`--profile`, `--seed`, and `--no-stage-timing` make those choices explicit when +needed; the workload controls above retain their single-run meanings. + +```sh +just benchmark-viewport run \ + --input fixtures/benchmarks/chess-set/chess-set-1k.glb \ + --hosts native web --modes editor viewport renderer \ + --repeats 5 --frames 180 --warmup 60 --browser headed \ + --output .cache/benchmark/chess-baseline +``` + +The suite defaults to headed Chromium. Headless and visible Chromium have shown +different frame pacing locally, including with little measured CPU work. Treat +browser mode as part of the environment rather than assuming that two executions +of the same build are interchangeable. The single-run command retains its +headless default for focused automated checks. + +The output directory preserves raw JSON reports, runner logs, a run manifest, +and a frozen copy of each built native/browser artifact, alongside +`summary.json` (`n3.viewport-suite.v1`), and `summary.txt`. Keep these together: the summary is a lens over +the measurements, not a replacement for the samples or their provenance. A source +fingerprint identifies a revision of the local code but does not preserve that +code; retain its patch separately when necessary. Output directories must be +fresh so another investigation cannot overwrite its evidence. An output directory +inside the repository must be ignored; external temporary directories are also +supported. + +The suite checks source identity before and after each session and hashes its +owned artifacts so another build cannot replace the code used by later repeats. +Raw suite reports carry execution-order metadata; their manifest records successful +completion and the report hash. Offline aggregation checks that receipt too, so a +JSON file left by a failed shutdown cannot become accepted evidence later. Keep +suite reports with their manifest when moving or archiving the output. + +The manifest retains failed and partial attempts. The suite does not silently +retry them or include failed runner processes in its aggregate. Contamination, +runner failure, or an incompatible comparison produces a nonzero exit status; +inspect the retained evidence before deciding whether to rerun. + +Regenerate an aggregate from existing raw reports, or compare two completed +aggregates after a focused change: + +```sh +just benchmark-viewport report .cache/measure/chess-*.json \ + --output .cache/benchmark/chess-imported + +just benchmark-viewport compare \ + .cache/benchmark/chess-baseline/summary.json \ + .cache/benchmark/chess-candidate/summary.json \ + --output .cache/benchmark/chess-comparison +``` + +Inspect rejected or contaminated reports before using a comparison. The tool +checks report completeness, the recorded mode contract, validity flags, and compatibility of +the measured workload and environment. Different modes and hosts remain separate +cases. A source change is intentional for a baseline/candidate comparison; +changes in input, dimensions, shading, camera path, instrumentation, or browser +environment can make its timing delta ambiguous. +The comparison command pairs the same host and mode across baseline and +candidate. Native/web or editor/renderer results can be read alongside each +other, but are not treated as interchangeable repeats of one case. + +Frames within an orbit are correlated observations, not independent repetitions. +The aggregate summarizes each run first, then reports the median and range across +repeated runs, with median absolute deviation (MAD) describing their spread. +Within-run p95 and maximum frame times retain tail and interruption +evidence. It does not pool all frames into a larger apparent sample size, claim +statistical significance, or enforce a performance pass/fail threshold. Five +repeats are a starting point, not a guarantee that a small difference is real. +Slow samples remain in the data. A numerical outlier is not, by itself, grounds +for discarding a run. A zero-duration stage can reflect clock quantization; +percentage changes from a zero baseline are reported as undefined. + +Use the report to choose the next instrument. CPU frame time, frame-start cadence, +and named stages answer different questions; execution counters confirm which +work happened. A repeated stage cost suggests a focused profile, while a long +frame-start interval with little CPU work suggests scheduling or presentation +investigation. Neither alone measures GPU execution or input-to-display latency. + +The [methodology references](../../TESTING.md#benchmark-methodology) cover process +isolation, warm-up, repeated runs, randomized ordering, and retaining raw data. +N3 keeps a small orchestration/report layer around its real application: timing +the entire command would include startup and loading, while an isolated function +loop would not exercise the production host's frame scheduling. These references +guide the method; N3 does not claim their statistical analyses or use them as +interchangeable runtime harnesses. + +## Measurement modes + +The modes isolate executed work, rather than changing the visibility of controls: + +| Mode | Included work | Excluded work | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `editor` (default) | Complete production editor frame: egui, editor projection and selection preparation, 3D feedback, scene rendering, and final composition | Nothing from the normal measured frame path | +| `viewport` | Editor projection and selection preparation, 3D feedback, production scene renderer, and direct scene composition | egui frame/layout, widget and overlay painting, tessellation, texture updates, and egui composition | +| `renderer` | Scene/resource synchronization, camera updates, production scene renderer, and direct scene composition | egui execution, editor projection/picking preparation, and editor interaction feedback | + +The normal **Hide UI** action is not a benchmark mode. It omits controls but still +runs egui, viewport interaction, projection, overlay painting, tessellation, and +composition. It also enlarges the scene area, changing the rendering workload. + +All modes first load the input and establish the full editor layout before the +measured window. They capture that layout's scene rectangle and retain its physical +size, aspect ratio, device scale, and camera path. UI-free modes render into that +same rectangle with blank outer margins; they do not expand to fill the surface. +This is intentional: removing panels must not simultaneously increase the pixel +workload when comparing modes. It isolates execution differences under matched +scene dimensions, rather than representing a full-window renderer application. +A full-window no-UI workload would be useful for that separate question, but +would need its own label and dimensions; it is not currently an option. +The preparation frame is not a retained sample. The UI-free paths use the same +production renderer and caches, with direct scene composition replacing egui's +texture composition. They retain the host's surface and frame scheduling policy. +Renderer mode preserves base edges, grid, shading, lighting, and camera settings; +the omitted feedback is editor highlights, edit selection, and transform gizmos. + +Choose `--mode viewport` or `--mode renderer` in the commands above and use a fresh +output name identifying the mode. Repeat every mode under the same conditions. +`--selected` is meaningful for `editor` and `viewport`; renderer mode rejects it +because selection feedback is outside that mode's contract. + +Per-frame counters record actual entry into egui passes, tessellation, texture +updates, egui composition, editor preparation, scene rendering, and direct scene +composition, plus editor feedback updates. `validity.mode_contract_satisfied` +checks those counts against the requested mode even with `--no-stage-timing`. +A mode-contract failure causes the +tool to fail; a mode label or rounded zero duration is not proof of exclusion. +Other validity flags, such as lost focus, remain in the saved report so contaminated +runs can be inspected and excluded from comparison. + +These are related workloads, not an additive accounting equation. Subtracting +renderer time from editor time does not precisely measure "UI cost": composition, +feedback, scheduling, and CPU/GPU overlap can change. Use the named stages and +execution counters to locate work before tracing or optimizing it. + +## What the report establishes + +The versioned `n3.viewport-measure.v2` JSON includes the retained samples in +original order and median, nearest-rank p95/p99, minimum, and maximum summaries. +It records scene counts, selection, renderer/adapter metadata supplied by the +host, mesh revision, dimensions, CPU stages, and narrowly scoped cache/upload +counters. The selected model's filename, byte length, and SHA-256 identify the +input without recording its absolute path. The source fingerprint distinguishes local changes but cannot reconstruct them; +preserve the reviewed patch alongside local evidence when needed. + +`cpu_frame_ms` is elapsed host CPU time around the measured frame path. +`frame_start_interval_ms` is the interval between consecutive observed frame +starts. Surface acquisition, queue submission, and presentation API durations are +CPU observations; they do not measure when the GPU finishes or the display shows +a frame. A long API call can include waiting. A short call does not prove the GPU +is fast. `editor_projection` names the projection/occlusion work independently of +its entry point. In `editor` mode this nested duration is included within UI +timing; in `viewport` mode editor preparation owns it. Do not add it again to +either enclosing stage or total frame time. Skipped stage durations are `null`, +not zero. Disabling stage timing makes the CPU stage map and projection duration +`null`; raw execution and projection/cache counters remain present. Upload byte +counts cover the instrumented base mesh buffers, not all GPU traffic. Schema v1 +reports used the misleading `projection_within_ui` name and lack mode contracts; +keep them as historical evidence instead of combining their fields with v2 data. + +The fixed camera path exercises shared navigation and real rendering without +synthetic device delivery. It measures rendering throughput under that workload, +not OS/DOM event latency, physical trackpad responsiveness, or orbit input +coalescing. `stationary` deliberately requests frames: it is **not an idle +wakeup or power benchmark**. Both hosts retain their normal surface and scheduling +policies while the measurement asks for another frame. + +Browser metadata includes user agent, device pixel ratio, CSS/physical canvas +sizes, viewport size, visibility, and the automation mode/configuration. JavaScript polls the result every 250 ms; +this is recorded as harness overhead. Browser loading records selected-byte +fetch/read duration separately from the synchronous `load_file` call. Neither +number establishes time to first fully rendered frame; native loading duration +is currently unavailable. Loading precedes the measured warm-up and frame sample +window. + +Browser clock precision depends on browser policy and isolation. The initial local +browser smoke showed durations quantized around 0.1 ms. A zero stage duration can +mean the work fell below timer resolution; it does not establish that the stage +was free. Keep this uncertainty and instrumentation overhead visible when +comparing small native and browser stages. + +Actual display refresh, presentation timestamps, and GPU timestamps are recorded +as unavailable rather than inferred from a nominal refresh rate or CPU frame +intervals. The first slice does not provide async GPU timestamp/readback support, +idle-event-loop sampling, full allocation profiling, or input latency traces. +Those are follow-up instruments, not reasons to add per-frame blocking GPU waits. + +## Interpreting and extending evidence + +### Workloads and fixture selection + +Organize steady-state performance cases by internal representation and work: +authored topology, static material rendering, many placed objects, and animated +deformation. Do not multiply every performance case by every supported file +extension. Format adapters still need their own correctness tests. Parsing, +decoding, resource resolution, and initial upload need separate loading benchmarks +when those costs are under investigation. + +The [authoring boundary](authoring-and-interchange.md) matters here. OBJ loads into +editable authored topology; glTF/GLB loads into immutable `SceneAsset` resources +placed in the same document. Both participate in the common projection/occlusion +cache, but their geometry preparation, rendering, and editing capabilities are +different. A GLB-only suite would miss authored vertex-edit and polygon-edge work. +Optimizations to shared code should transfer when the evaluated workload is +equivalent; similar silhouettes or triangle counts alone do not establish that. +Keep format dispatch in `asset_io`, with performance policy below that boundary. + +The maintained corpus starts with the following fixtures and remaining workloads: + +| Workload | Fixture direction | Current coverage | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Dense editable topology | A reproducibly generated authored document, initially around 25,000 vertices, with a larger optional case | The current orbit harness supports this representation; a public dense fixture still needs to be added | +| Static imported geometry | Poly Haven [Chess Set](../../fixtures/benchmarks/chess-set/README.md), 6,962,072-byte GLB, 49,150 vertices, 76,920 triangles, 33 mesh nodes | Pinned CC0 1K source and reproducible GLB; use the existing Solid orbit/stationary harness | +| Static textured scene | Existing Khronos [Water Bottle](../../fixtures/gltf/README.md), 8,966,700 bytes, 2,549 vertices, 4,510 triangles, four embedded images | Already pinned with CC0 model data; an explicit Material Preview workload is still needed | +| Many objects/draws | Deterministic placements of existing geometry, with shared-resource versus unique-resource cases stated explicitly | Future workload; triangle count alone cannot stand in for object/draw count | +| Skinning and morphs | Existing Simple Skin and Animated Morph Cube for correctness, then bounded replication or one larger licensed asset for scale | Animation starts paused in the current harness; timed playback is a future workload | + +In particular, the current session fixes Solid shading. Textures may be loaded and +resident, but those frames do **not** benchmark Material Preview/PBR shading. +Current upload counters cover base mesh buffers, not imported texture traffic. +Before using textured or animated fixtures for performance conclusions, add an +explicit shared workload setting, appropriate counters, and matching host reports. + +Suggested acquisition budgets for the initial maintained corpus are at most +10 MiB per external asset and 25 MiB total, with 1K/2K textures and a recorded +decoded memory footprint. These are selection guidelines, not implemented import +limits. Increase computational load with explicit geometry/instance counts; +reserve larger downloads and 4K/8K texture stress for optional cases. Record source +revision or immutable download identity, license, checksums, authored and evaluated +counts, materials, image dimensions, and required extensions. Preserve upstream +files; any derived fixture needs its own reproducible recipe and provenance. + +The [testing index](../../TESTING.md#external-reference-suites-and-tools) keeps +authoritative links to importer validators, graphics conformance suites, and +[fixture sources](../../TESTING.md#fixture-sources-and-provenance). Choose a bounded +subset for the missing workload. Conformance and source validity complement the +editor's performance evidence; they do not supply its camera, editing, or +frame-time benchmark. + +### Comparing runs + +First inspect validity flags and raw samples. Exclude runs with frame errors, +focus losses, visibility changes, viewport/DPR changes, or unexpected geometry +revision changes. The browser records contamination detected between retained +frames as well. A successfully saved report can still be unsuitable for comparison. +Verify the actual surface and inner viewport dimensions from the report; matching +requested dimensions alone is insufficient, particularly across different display +scales. Keep the canvas visible and avoid resizing or interacting during a run. + +Collect multiple warmed repetitions on the same machine, power state, display, +input hash, profile, physical viewport dimensions, quality settings, selected +state, mode, and workload. For a deliberate mode comparison, change only the mode +and verify the reported scene rectangle and camera matrices match. Compare +distributions and raw frame pacing, not just a single +average or FPS estimate. Run paired instrumented/uninstrumented repetitions before +attributing a small difference to a renderer change. Keep adapter/backend and +browser versions explicit; the same `wgpu` code does not prove identical work, +synchronization, limits, or presentation behavior across hosts. + +Use stage/counter evidence to choose a focused next trace. For example, repeated +projection work under camera-only orbit can direct investigation toward shared CPU +work; it does not by itself explain every host difference. If evidence isolates a +browser timing, scheduling, focus, or synchronization mistake, fix that adapter +and repeat the same workload. If a cost is shared, treat the optimization as +separate renderer work and preserve native capabilities. Classify a browser limit +as unavoidable only after a controlled experiment establishes that constraint. + +A Three.js comparison also needs the same imported geometry, physical pixels, +material/shading, edges, selection feedback, camera path, and presentation +conditions. An editor and a minimal scene viewer do different work. A perceived +difference is a useful report, but an unmatched comparison cannot identify its +cause or acceptable magnitude. + +Tooling regressions run with `just tools-test`. +They cover host/profile builds, artifact preservation, input metadata, isolated +browser setup, runner failure/timeout/cleanup, report completion, and the actual +localhost server's token/path/origin/output boundaries. Runtime native and +browser measurements remain necessary: a parser test or WASM compilation is not +measurement evidence. diff --git a/docs/baselines/linux-vulkan-lavapipe.json b/docs/baselines/linux-vulkan-lavapipe.json index b9132e3..d3aa0d6 100644 --- a/docs/baselines/linux-vulkan-lavapipe.json +++ b/docs/baselines/linux-vulkan-lavapipe.json @@ -2,7 +2,7 @@ "version": 1, "renderer": "linux-vulkan-lavapipe", "canonical_renderer": "macos-metal", - "canonical_guide_sha256": "cc7355f856d22adcee6f3d3eaa7e6bbac6c4e54a05efeebeeefb758f9fdcbc4a", + "canonical_guide_sha256": "053811ae10792db8cc797e488ec726fd446326d561f0960d88882009c0dac2a7", "artifacts": [ { "path": "2d-ruler.md", @@ -16,8 +16,8 @@ }, { "path": "README.md", - "bytes": 1295, - "sha256": "66a7a25fba95bf9e7a2fb5fc258d3195b386e996e6093dbc121ed0563ebf0431" + "bytes": 1323, + "sha256": "58dde32c89fe920ed3e25a30f91036540c6e03df0f400a944e3bddc2e6afafd3" }, { "path": "animation.md", @@ -154,6 +154,16 @@ "bytes": 405572, "sha256": "baebffa9f6f34b9f28633b1de9d50090656e7006f10adb456cee2f8d357dab74" }, + { + "path": "assets/fps-meter-menu.webp", + "bytes": 327900, + "sha256": "0f694d4b58f6c8cc95f41ee7678b550f1a41b1b180a03fa7b49af5234182a6cd" + }, + { + "path": "assets/fps-meter-waiting.webp", + "bytes": 344208, + "sha256": "0850b56610ddf5c6129b77f52a4b078e74a003ea1edc7045b133aedbcfca18be" + }, { "path": "assets/gizmo-aligned.webp", "bytes": 35028, @@ -291,8 +301,8 @@ }, { "path": "assets/navigation-view-menu.webp", - "bytes": 456936, - "sha256": "9ee30f1fd03e6ea9058f95d9b007ebe1389d4915dc910f6aa247d9246fb1cd9b" + "bytes": 453928, + "sha256": "10558658ba47498216c3110faf876ec331412ff33f295884a8daaeaeaaeafa7b" }, { "path": "assets/navigation.webp", @@ -526,8 +536,8 @@ }, { "path": "assets/xray-menu.webp", - "bytes": 685912, - "sha256": "8ce67807876f834cf8625456c348e60f878eb90b2df95de4d067a96608e98a0a" + "bytes": 684000, + "sha256": "aebc992be270dbe528ab07470f7378ddfcd9d8ed83dacaffcdddad5bf7bd211f" }, { "path": "assets/xray-selection-retained.webp", @@ -559,6 +569,11 @@ "bytes": 8956, "sha256": "972f80e3573c3034a23addfd18d61a4027a142bfad63d7fa3eee70afdcb382b6" }, + { + "path": "fps-meter.md", + "bytes": 1707, + "sha256": "89f4eba21a34a6b6ca65f54536232d4832c15d0e9b15067a08912ead2cbdacb3" + }, { "path": "gizmo.md", "bytes": 5979, @@ -586,8 +601,8 @@ }, { "path": "manifest.txt", - "bytes": 414413, - "sha256": "3a32c409bb90f84ca365f7e9d001b4e8462d3aae0d11d56c0663f59e22cbf97a" + "bytes": 415567, + "sha256": "615fd4951bce995ccad3cfe011bf2341da7e6f86486fe666be7ee1c3c5bf0a65" }, { "path": "navigation.md", diff --git a/docs/development.md b/docs/development.md index 325bcad..d2c10a8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,8 @@ The application is a single Rust package named `n3`, with private implementation modules. Read [AGENTS.md](../AGENTS.md) for development principles and iteration -guidance. Start at [the architecture map](architecture/architecture.md) and +guidance. [Testing](../TESTING.md) indexes verification workflows, reference suites, +and benchmark resources. Start at [the architecture map](architecture/architecture.md) and [the milestone review](architecture/milestone-review.md). The old viewer-only spike has been removed; the root package is the maintained source tree. @@ -84,6 +85,11 @@ user guide; host capabilities omit or disable unsupported controls. Read [platform boundaries and performance](architecture/platform-boundaries.md) before extending the port or changing host ownership. +For opt-in native/browser CPU-stage and frame-pacing measurements, use +[`just measure-viewport`](architecture/viewport-measurement.md). It builds the +production hosts with contributor instrumentation and records raw samples; +ordinary builds do not include the recorder or measurement exports. + ```sh just web-setup just web-check diff --git a/docs/guide/README.md b/docs/guide/README.md index 7a9e5bd..9bd6a17 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -10,6 +10,7 @@ Guide to N3, the minimal mesh editor. Import OBJ geometry, insert parametric sha - [Snapping movement](snapping.md) - [Workspace layout and property editing](workspace.md) - [Terminal](terminal.md) +- [FPS meter](fps-meter.md) - [Toasts and shortcut hints](toasts.md) - [Length units](length-units.md) - [2D](2d.md) diff --git a/docs/guide/assets/fps-meter-menu.webp b/docs/guide/assets/fps-meter-menu.webp new file mode 100644 index 0000000..e68b679 Binary files /dev/null and b/docs/guide/assets/fps-meter-menu.webp differ diff --git a/docs/guide/assets/fps-meter-waiting.webp b/docs/guide/assets/fps-meter-waiting.webp new file mode 100644 index 0000000..da8d408 Binary files /dev/null and b/docs/guide/assets/fps-meter-waiting.webp differ diff --git a/docs/guide/assets/navigation-view-menu.webp b/docs/guide/assets/navigation-view-menu.webp index 204cf3f..622de27 100644 Binary files a/docs/guide/assets/navigation-view-menu.webp and b/docs/guide/assets/navigation-view-menu.webp differ diff --git a/docs/guide/assets/xray-menu.webp b/docs/guide/assets/xray-menu.webp index 7ce2006..157a5fd 100644 Binary files a/docs/guide/assets/xray-menu.webp and b/docs/guide/assets/xray-menu.webp differ diff --git a/docs/guide/fps-meter.md b/docs/guide/fps-meter.md new file mode 100644 index 0000000..81c8556 --- /dev/null +++ b/docs/guide/fps-meter.md @@ -0,0 +1,33 @@ + + +# FPS meter + +Open N3 → View → Developer → Show FPS Meter to show a small meter at the bottom-right corner +of the application window. It stays anchored to the window as the viewport and +panels change. +Choose the same item again to turn it off. The meter starts off each time you +open N3 and does not change your document, selection, settings, or undo history. + +![fps meter menu](assets/fps-meter-menu.webp) + +**App FPS** measures the rate at which N3 submits completed application frames +for presentation. The frame interval is the average elapsed time between those +submissions. It includes time spent waiting between frames, including idle time +and stalls. It does not measure GPU execution time, frames actually displayed +by your screen, or the delay between your input and its visible result. + +The meter waits for enough frames to form its first sample: + +![fps meter waiting](assets/fps-meter-waiting.webp) + +While frames are arriving, the sample updates after at least half a second. +The display is labeled **last sample**: an idle editor keeps its last value +until another frame arrives. Enabling the meter does not keep an otherwise idle +editor drawing just to refresh the number. Orbit or animate the scene when you +want to observe its active frame rate; a low rate while doing nothing is normal. + +The meter is a passive overlay. It does not take pointer or keyboard input. +Command + \ hides it with the rest of the UI; repeating the shortcut +restores it if it is still enabled. Hiding the UI does not disable measurement. + +[Back to guide](README.md). diff --git a/docs/guide/manifest.txt b/docs/guide/manifest.txt index ae53fa1..89f85e8 100644 --- a/docs/guide/manifest.txt +++ b/docs/guide/manifest.txt @@ -466,6 +466,29 @@ input: clean image: assets/terminal-resized.webp (362074 bytes) input: clean +feature: fps-meter +control: app.menu = N3 +control: view = N3 → View +control: view.developer = N3 → View → Developer +control: view.fps-meter.toggle = N3 → View → Developer → Show FPS Meter +control: viewport = Viewport +control: insert = Insert +control: insert.cube = Insert → Cube +shortcut-replay: history.undo = Command+Z +shortcut-replay: ui.toggle = Command+\ +assert: The FPS meter starts disabled in a fresh workspace +assert: Show FPS Meter is available in N3, View, Developer +assert: The menu enables the meter at the window's bottom-right corner, outside the viewport bounds and waiting for host frame samples +assert: Tutorial clock advances do not invent a measured application FPS +assert: Hide UI hides the meter without disabling its measurement +assert: Showing the UI restores the enabled FPS meter at the same window position +assert: The same menu disables the meter without changing document, selection, settings, or camera +assert: Meter toggles add no history: one Undo still removes the inserted cube +image: assets/fps-meter-menu.webp (340736 bytes) +input: clean +image: assets/fps-meter-waiting.webp (352016 bytes) +input: clean + feature: toasts control: app.menu = N3 control: preferences.open = N3 → Preferences @@ -2506,7 +2529,7 @@ image: assets/navigation-frame-key.webp (470520 bytes) input: tutorial @ 4.861s: focused=true; pointer=960.0,524.5; cursor=Default; held_mouse=[]; held_keys=[Shift+1]; released_mouse=none; released_keys=none; gesture=none; trail=0 image: assets/navigation-mouse-orbit.webp (489428 bytes) input: tutorial @ 0.681s: focused=true; pointer=331.0,994.0; cursor=Default; held_mouse=[Right drag]; held_keys=[]; released_mouse=none; released_keys=Escape/120ms; gesture=Pinch 1.16×/120ms; trail=2 -image: assets/navigation-view-menu.webp (466214 bytes) +image: assets/navigation-view-menu.webp (463412 bytes) input: tutorial @ 5.461s: focused=true; pointer=343.0,203.0; cursor=Default; held_mouse=[]; held_keys=[]; released_mouse=none; released_keys=none; gesture=none; trail=0 image: assets/navigation.webp (54712 bytes) input: clean @@ -3117,7 +3140,7 @@ frame @ 10.941s + 50ms: focused=true; pointer=1091.5,776.0; cursor=Default; held frame @ 10.991s + 50ms: focused=true; pointer=1091.5,776.0; cursor=Default; held_mouse=[]; held_keys=[]; released_mouse=none; released_keys=none; gesture=none; trail=0 frame @ 11.041s + 50ms: focused=true; pointer=1091.5,776.0; cursor=Default; held_mouse=[]; held_keys=[]; released_mouse=none; released_keys=none; gesture=none; trail=0 frame @ 11.091s + 50ms: focused=true; pointer=1091.5,776.0; cursor=Default; held_mouse=[]; held_keys=[]; released_mouse=none; released_keys=none; gesture=none; trail=0 -image: assets/xray-menu.webp (692304 bytes) +image: assets/xray-menu.webp (690564 bytes) input: tutorial @ 11.991s: focused=true; pointer=119.0,83.0; cursor=Default; held_mouse=[]; held_keys=[]; released_mouse=Left released@119.0,83.0/350ms; released_keys=none; gesture=none; trail=1 image: assets/xray-selection-retained.webp (574034 bytes) input: tutorial @ 12.591s: focused=true; pointer=343.0,341.0; cursor=Default; held_mouse=[]; held_keys=[]; released_mouse=none; released_keys=none; gesture=none; trail=0 diff --git a/docs/templates/fps-meter.md.in b/docs/templates/fps-meter.md.in new file mode 100644 index 0000000..1d12bff --- /dev/null +++ b/docs/templates/fps-meter.md.in @@ -0,0 +1,31 @@ +# FPS meter + +Open {{control:view.fps-meter.toggle}} to show a small meter at the bottom-right corner +of the application window. It stays anchored to the window as the viewport and +panels change. +Choose the same item again to turn it off. The meter starts off each time you +open N3 and does not change your document, selection, settings, or undo history. + +{{image:fps-meter-menu}} + +**App FPS** measures the rate at which N3 submits completed application frames +for presentation. The frame interval is the average elapsed time between those +submissions. It includes time spent waiting between frames, including idle time +and stalls. It does not measure GPU execution time, frames actually displayed +by your screen, or the delay between your input and its visible result. + +The meter waits for enough frames to form its first sample: + +{{image:fps-meter-waiting}} + +While frames are arriving, the sample updates after at least half a second. +The display is labeled **last sample**: an idle editor keeps its last value +until another frame arrives. Enabling the meter does not keep an otherwise idle +editor drawing just to refresh the number. Orbit or animate the scene when you +want to observe its active frame rate; a low rate while doing nothing is normal. + +The meter is a passive overlay. It does not take pointer or keyboard input. +{{shortcut:ui.toggle}} hides it with the rest of the UI; repeating the shortcut +restores it if it is still enabled. Hiding the UI does not disable measurement. + +[Back to guide](README.md). diff --git a/fixtures/README.md b/fixtures/README.md index 034adcb..fd73ec8 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -73,3 +73,10 @@ just run fixtures/obj/suzanne.obj [glTF fixtures](gltf/README.md) cover embedded PBR textures, external-resource skinning, and morph animation through the read-only scene viewer. Their upstream revision, licenses, and exact bytes are recorded separately from the OBJ fixtures. + +## Benchmark scenes + +[Chess Set](benchmarks/chess-set/README.md) is a bounded CC0 Poly Haven scene for +native/browser viewport measurements. It includes the unchanged 1K source bundle, +a reproducibly packed GLB, and source/derivative checksums. Benchmark workload and +validation limits are documented alongside the fixture. diff --git a/fixtures/benchmarks/chess-set/LICENSE.md b/fixtures/benchmarks/chess-set/LICENSE.md new file mode 100644 index 0000000..ac9dfb1 --- /dev/null +++ b/fixtures/benchmarks/chess-set/LICENSE.md @@ -0,0 +1,12 @@ +# Chess Set asset license + +Chess Set by **Riley Queen**, distributed by [Poly Haven](https://polyhaven.com/a/chess_set). +The model and texture files are provided under **CC0-1.0** according to +[Poly Haven's asset license](https://polyhaven.com/license). +See the [CC0 1.0 legal code](https://creativecommons.org/publicdomain/zero/1.0/legalcode.en). + +This applies to the preserved files in `source/` and the byte-preserving +`chess-set-1k.glb` derivative. They are not relicensed under N3's source-code +license. The packing script and N3-authored documentation use the repository's +source-code license. Website renders, logos, and other website content are not +included. diff --git a/fixtures/benchmarks/chess-set/README.md b/fixtures/benchmarks/chess-set/README.md new file mode 100644 index 0000000..08b62b8 --- /dev/null +++ b/fixtures/benchmarks/chess-set/README.md @@ -0,0 +1,99 @@ +# Chess Set benchmark fixture + +[Chess Set by Riley Queen](https://polyhaven.com/a/chess_set), distributed by +Poly Haven under [CC0-1.0](LICENSE.md), is N3's first external benchmark scene. +The recognizable board and pieces make missing geometry and placement mistakes +easy to spot. The bounded 1K export supplies overlapping geometry and multiple +materials without a large texture download. + +Use [`chess-set-1k.glb`](chess-set-1k.glb) for both native and browser runs. +It is a self-contained derivative of the unchanged upstream +[`source/chess_set_1k.gltf`](source/chess_set_1k.gltf) and its referenced resources. +The browser's single-file input does not resolve sibling files from the source +glTF bundle. + +| Property | Value | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| Source bundle | 6,983,829 bytes across 11 files (6.66 MiB) | +| Packed GLB | 6,962,072 bytes (6.64 MiB) | +| Scene | One scene; 33 mesh nodes: board and 32 pieces | +| Geometry | 49,150 vertices across primitives; 76,920 triangles; 37 primitives | +| Materials | Three metallic/roughness materials, all double-sided | +| Images | Nine 1024 × 1024 JPEGs: base color, normal, and packed ARM for each material | +| Decoded image storage | 37,748,736 bytes (36 MiB) of base-level RGBA8; excludes mipmaps, retained encoded bytes, and other CPU/GPU allocations | +| Extensions, animation, skins, morphs | None | + +These are imported scene counts, not authored editable topology. N3 places this +as one asset instance with 33 internal mesh nodes. Repeating instances is a +separate workload and must state whether resources are shared. The original ARM +images have no `occlusionTexture` references; this fixture preserves that choice. + +## Integrity and reproduction + +[`provenance.json`](provenance.json) pins source URLs, sizes, upstream MD5 values, +and SHA-256 digests, plus the derived GLB's digest. Downloaded October 2, 2026. +The upstream URLs are mutable, so the recorded hashes identify the exact snapshot. +The geometry download URL contains `8k`; it is the shared geometry buffer used by +the selected 1K export, not an 8K texture download. + +Keep `source/` byte-identical to upstream. [`pack.py`](pack.py) uses only Python's +standard library and first verifies every source digest. It embeds the existing +geometry and JPEG bytes, adds aligned image buffer views, and serializes a GLB. +It does not optimize, merge, reorder, resize, recompress, or change scene semantics. +The source bundle and derivative together occupy about 13.3 MiB. + +From the repository root: + +```sh +# Read-only: verify source hashes and exact GLB reproducibility. +python3 fixtures/benchmarks/chess-set/pack.py + +# Explicitly regenerate the same pinned derivative. +python3 fixtures/benchmarks/chess-set/pack.py --write +``` + +Changing the asset or recipe requires a deliberate provenance update; the recipe +fails if its output differs from the recorded derivative. Source resources are +excluded from repository formatting. + +## Validation and workload + +On October 2, 2026, the official Khronos `gltf-validator` **2.0.0-dev.3.10**, running +on Node.js **24.14.0**, checked both the external glTF and packed GLB with all +resources loaded, no issue suppression, and untruncated reports. Both returned +**zero errors, 37 warnings, two information messages, and zero hints**: + +- Each primitive omits tangents while using a normal map. The 37 + `MESH_PRIMITIVE_GENERATED_TANGENT_SPACE` warnings mean consumers must generate + tangent space; visual differences across implementations remain possible. +- Two `UNUSED_OBJECT` messages identify unused secondary UV attributes. + +These upstream properties are preserved, not repaired during packing. Full +acquisition and validator receipts live in ignored `.cache/review/chess-set/`. +The importer regression compares the external-file and embedded-byte paths, +including evaluated geometry, materials, and decoded images. + +```sh +just measure-viewport --host native --profile release \ + --mode editor \ + --input fixtures/benchmarks/chess-set/chess-set-1k.glb \ + --output .cache/measure/chess-native-editor-orbit-01.json + +just measure-viewport --host web --profile release \ + --mode editor \ + --input fixtures/benchmarks/chess-set/chess-set-1k.glb \ + --output .cache/measure/chess-web-editor-orbit-01.json +``` + +Repeat with `--mode viewport` and `--mode renderer`, using fresh mode-specific +output names, to distinguish editor and UI work from the production scene renderer. +Every mode preserves the full editor layout's scene rectangle; renderer mode +excludes editor projection and feedback. Only editor and viewport modes accept +`--selected`. + +The current harness measures **Solid** orbit or stationary frames. Importing and +retaining textures does not establish Material Preview/PBR performance. An +explicit Material Preview workload remains separate work. Likewise this asset +does not cover dense authored mesh editing. See the +[measurement guide](../../../docs/architecture/viewport-measurement.md) for +browser setup, report validity, repeatability, and remaining instrumentation limits. diff --git a/fixtures/benchmarks/chess-set/chess-set-1k.glb b/fixtures/benchmarks/chess-set/chess-set-1k.glb new file mode 100644 index 0000000..78e54e3 Binary files /dev/null and b/fixtures/benchmarks/chess-set/chess-set-1k.glb differ diff --git a/fixtures/benchmarks/chess-set/pack.py b/fixtures/benchmarks/chess-set/pack.py new file mode 100644 index 0000000..313917b --- /dev/null +++ b/fixtures/benchmarks/chess-set/pack.py @@ -0,0 +1,76 @@ +"""Reproduce the pinned Chess Set GLB using Python's standard library only.""" + +import argparse +import hashlib +import json +from pathlib import Path +import struct + + +ROOT = Path(__file__).resolve().parent + + +def pack(): + provenance = json.loads((ROOT / "provenance.json").read_text()) + resources = {} + for record in provenance["files"]: + path = ROOT / record["path"] + path.resolve().relative_to((ROOT / "source").resolve()) + content = path.read_bytes() + if (len(content) != record["bytes"] + or hashlib.sha256(content).hexdigest() != record["sha256"]): + raise ValueError(f"Source integrity mismatch: {record['path']}") + resources[path.relative_to(ROOT / "source").as_posix()] = content + + document = json.loads(resources["chess_set_1k.gltf"]) + # This recipe deliberately covers this pinned export, not arbitrary glTF. + if len(document["buffers"]) != 1: + raise ValueError("Expected one upstream geometry buffer") + buffer = document["buffers"][0] + binary = bytearray(resources[buffer.pop("uri")]) + if len(binary) != buffer["byteLength"]: + raise ValueError("Geometry buffer length mismatch") + if any(view["buffer"] != 0 for view in document["bufferViews"]): + raise ValueError("Unexpected geometry buffer reference") + + for image in document["images"]: + if image.get("mimeType") != "image/jpeg" or "bufferView" in image: + raise ValueError("Expected external JPEG images") + content = resources[image.pop("uri")] + binary.extend(b"\0" * (-len(binary) % 4)) + image["bufferView"] = len(document["bufferViews"]) + document["bufferViews"].append({ + "buffer": 0, "byteOffset": len(binary), "byteLength": len(content), + }) + binary.extend(content) + + buffer["byteLength"] = len(binary) + binary.extend(b"\0" * (-len(binary) % 4)) + payload = json.dumps(document, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + payload += b" " * (-len(payload) % 4) + result = (struct.pack("(), + 49_150 + ); + assert_eq!( + frame + .draws + .iter() + .map(|draw| draw.indices.len() / 3) + .sum::(), + 76_920 + ); + assert!( + frame + .draws + .iter() + .all(|draw| draw.topology == Topology::Triangles) + ); + } + let source_frame = source.evaluate(source.default_scene, None).unwrap(); + let embedded_frame = embedded.evaluate(embedded.default_scene, None).unwrap(); + assert_eq!(source_frame.bounds, embedded_frame.bounds); + assert_eq!( + frame_fingerprints(&source_frame), + frame_fingerprints(&embedded_frame) + ); + + for (original, packed) in source.materials.iter().zip(&embedded.materials) { + assert_eq!(original.name, packed.name); + assert_eq!(original.base_color, packed.base_color); + assert_eq!(original.metallic, packed.metallic); + assert_eq!(original.roughness, packed.roughness); + assert_eq!(original.emissive, packed.emissive); + assert_eq!(original.normal_scale, packed.normal_scale); + assert_eq!(original.occlusion_strength, packed.occlusion_strength); + assert_eq!(original.alpha_mode, packed.alpha_mode); + assert_eq!(original.alpha_cutoff, packed.alpha_cutoff); + assert_eq!(original.double_sided, packed.double_sided); + assert_eq!(original.unlit, packed.unlit); + let slots = |material: &Material| { + [ + material.base_color_texture, + material.metallic_roughness_texture, + material.normal_texture, + material.occlusion_texture, + material.emissive_texture, + ] + .map(|slot| slot.map(|info| (info.texture, info.tex_coord, info.transform))) + }; + assert_eq!(slots(original), slots(packed)); + } + for (original, packed) in source.textures.iter().zip(&embedded.textures) { + assert_eq!(original.image, packed.image); + assert_eq!(original.sampler.wrap_s, packed.sampler.wrap_s); + assert_eq!(original.sampler.wrap_t, packed.sampler.wrap_t); + assert_eq!(original.sampler.mag, packed.sampler.mag); + assert_eq!(original.sampler.min, packed.sampler.min); + assert_eq!(original.sampler.mipmap, packed.sampler.mipmap); + } + for (original, packed) in source.images.iter().zip(&embedded.images) { + assert_eq!(original.name, packed.name); + assert_eq!(original.width, packed.width); + assert_eq!(original.height, packed.height); + assert_eq!(original.rgba8.len(), packed.rgba8.len()); + assert_eq!( + Sha256::digest(&original.rgba8), + Sha256::digest(&packed.rgba8) + ); + } +} diff --git a/src/documentation/features.rs b/src/documentation/features.rs index 1de5bef..3a216fb 100644 --- a/src/documentation/features.rs +++ b/src/documentation/features.rs @@ -44,6 +44,12 @@ pub(super) const FEATURES: &[Feature] = &[ template: include_str!("../../docs/templates/terminal.md.in"), scenario: terminal::run, }, + Feature { + slug: "fps-meter", + title: "FPS meter", + template: include_str!("../../docs/templates/fps-meter.md.in"), + scenario: fps_meter::run, + }, Feature { slug: "toasts", title: "Toasts and shortcut hints", diff --git a/src/documentation/mod.rs b/src/documentation/mod.rs index 189d539..1c16d9e 100644 --- a/src/documentation/mod.rs +++ b/src/documentation/mod.rs @@ -42,6 +42,8 @@ mod edit_selection; mod edit_toolbar_tests; #[path = "scenarios/editing.rs"] mod editing; +#[path = "scenarios/fps_meter.rs"] +mod fps_meter; #[path = "scenarios/gizmo.rs"] mod gizmo; #[path = "scenarios/hand_tool.rs"] diff --git a/src/documentation/scenarios/fps_meter.rs b/src/documentation/scenarios/fps_meter.rs new file mode 100644 index 0000000..2635c41 --- /dev/null +++ b/src/documentation/scenarios/fps_meter.rs @@ -0,0 +1,96 @@ +//! Show the actual developer control and passive overlay. Guide replay has an +//! explicit scenario clock, not a host surface-presentation cadence, so it must +//! never manufacture a numeric FPS result for a screenshot. +use super::{HEIGHT, Result, Session, WIDTH}; +use crate::controls::Control; +use std::time::Duration; + +pub fn run(s: &mut Session<'_>) -> Result<()> { + s.require( + !s.state.fps_meter.enabled() && s.trace.get(Control::FpsMeter).is_err(), + "The FPS meter starts disabled in a fresh workspace", + )?; + s.click_path(&[Control::InsertMenu, Control::InsertCube])?; + let document = s.state.editor.document.clone(); + let selection = s.state.editor.selected_objects.clone(); + let revision = s.state.editor.revision; + let settings = s.state.user_settings(); + let camera = s.state.camera.clone(); + + s.click_path(&[Control::N3Menu, Control::ViewMenu, Control::DeveloperMenu])?; + s.witness(Control::ShowFpsMeter)?; + s.require( + s.trace.get(Control::ShowFpsMeter)?.parents + == [Control::N3Menu, Control::ViewMenu, Control::DeveloperMenu] + && s.trace.get(Control::ShowFpsMeter)?.enabled, + "Show FPS Meter is available in N3, View, Developer", + )?; + s.wait(Duration::from_millis(250))?; + s.capture_image("fps-meter-menu")?; + s.click(Control::ShowFpsMeter)?; + s.hover(Control::Viewport)?; + let meter = s.trace.get(Control::FpsMeter)?.rect; + let window = + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(WIDTH as f32, HEIGHT as f32)); + s.require( + s.state.fps_meter.enabled() + && s.state.fps_meter.sample().is_none() + && s.trace + .get(Control::FpsMeter)? + .label + .contains("Waiting for frames") + && s.trace + .get(Control::FpsMeter)? + .label + .contains("last sample") + && window.contains_rect(meter) + && meter.right_bottom() == window.right_bottom() + && !s.state.viewport.contains_rect(meter), + "The menu enables the meter at the window's bottom-right corner, outside the viewport bounds and waiting for host frame samples", + )?; + // Advancing tutorial time renders the UI without pretending that replay + // submissions are real native/browser surface presentation observations. + s.wait(Duration::from_secs(1))?; + s.require( + s.state.fps_meter.sample().is_none(), + "Tutorial clock advances do not invent a measured application FPS", + )?; + s.capture_image("fps-meter-waiting")?; + + s.shortcut("ui.toggle")?; + s.require( + !s.state.show_ui && s.state.fps_meter.enabled() && s.trace.get(Control::FpsMeter).is_err(), + "Hide UI hides the meter without disabling its measurement", + )?; + s.shortcut("ui.toggle")?; + s.require( + s.state.show_ui + && s.state.fps_meter.enabled() + && s.trace.get(Control::FpsMeter)?.rect == meter, + "Showing the UI restores the enabled FPS meter at the same window position", + )?; + s.click_path(&[ + Control::N3Menu, + Control::ViewMenu, + Control::DeveloperMenu, + Control::ShowFpsMeter, + ])?; + s.require( + !s.state.fps_meter.enabled() + && s.state.fps_meter.sample().is_none() + && s.trace.get(Control::FpsMeter).is_err() + && s.state.editor.document == document + && s.state.editor.selected_objects == selection + && s.state.editor.revision == revision + && s.state.user_settings() == settings + && s.state.camera.eye() == camera.eye() + && s.state.camera.orientation() == camera.orientation(), + "The same menu disables the meter without changing document, selection, settings, or camera", + )?; + s.undo()?; + s.require( + s.state.editor.document.objects.is_empty(), + "Meter toggles add no history: one Undo still removes the inserted cube", + )?; + Ok(()) +} diff --git a/src/editor/measurement.rs b/src/editor/measurement.rs new file mode 100644 index 0000000..cb14bc4 --- /dev/null +++ b/src/editor/measurement.rs @@ -0,0 +1,13 @@ +//! Reuse production viewport preparation without constructing an egui frame. +use super::{Camera, Editor, Projection, Rect}; + +impl Editor { + pub(crate) fn prepare_viewport( + &mut self, + viewport: Rect, + camera: &Camera, + z_up: bool, + ) -> Result<(), String> { + self.prepare(&Projection::new(viewport, camera, z_up)?) + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index baf604e..deb9aac 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -4,6 +4,8 @@ mod derived_edit; pub(crate) mod edit_history; mod geometry_edit; mod make_face; +#[cfg(feature = "viewport-measure")] +mod measurement; pub(crate) mod numeric_transform; pub(crate) mod snapping; mod transform_gizmo; @@ -2196,6 +2198,7 @@ impl Editor { } fn prepare(&mut self, projection: &Projection) -> Result<(), String> { + crate::measurement::editor_prepared(); if self.cache.is_none() { self.cache = Some(GeometryCache::with_assets( &self.document, @@ -3523,11 +3526,13 @@ impl GeometryCache { position, })); } + let bvh = Bvh::build(triangles); + crate::measurement::geometry_rebuilt(); Ok(Self { vertices, selectable_vertices, loose_edges, - bvh: Bvh::build(triangles), + bvh, projection: None, projected: Vec::new(), }) @@ -3577,6 +3582,7 @@ impl GeometryCache { if self.projection.as_ref() == Some(projection) { return; } + let probe = crate::measurement::projection_started(); self.projected.clear(); for vertex in &self.vertices { let Some(screen) = projection.screen(vertex.position) else { @@ -3602,6 +3608,7 @@ impl GeometryCache { }); } self.projection = Some(projection.clone()); + probe.finish(self.vertices.len()); } } diff --git a/src/frame_meter.rs b/src/frame_meter.rs new file mode 100644 index 0000000..70081df --- /dev/null +++ b/src/frame_meter.rs @@ -0,0 +1,197 @@ +//! Passive application frame cadence, independent of UI passes and host clocks. +//! +//! Hosts report one timestamp after handing a rendered surface to `present`. +//! This is not a GPU completion or display timestamp. No timer, redraw request, +//! allocation, or GPU synchronization is needed to collect a sample. +use std::time::Duration; + +const SAMPLE_PERIOD: Duration = Duration::from_millis(500); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Sample { + intervals: u64, + elapsed: Duration, +} + +impl Sample { + pub(crate) fn fps(self) -> f64 { + self.intervals as f64 / self.elapsed.as_secs_f64() + } + + pub(crate) fn mean_interval_ms(self) -> f64 { + self.elapsed.as_secs_f64() * 1000.0 / self.intervals as f64 + } + + pub(crate) fn elapsed(self) -> Duration { + self.elapsed + } +} + +#[derive(Default)] +pub(crate) struct FrameMeter { + enabled: bool, + first: Option, + last: Option, + intervals: u64, + sample: Option, +} + +impl FrameMeter { + pub(crate) fn enabled(&self) -> bool { + self.enabled + } + + pub(crate) fn toggle(&mut self) { + *self = Self { + enabled: !self.enabled, + ..Self::default() + }; + } + + /// The last completed sample stays visible while the app is idle. Publishing + /// never schedules a frame; the next ordinary redraw displays the result. + pub(crate) fn sample(&self) -> Option { + self.sample + } + + /// `now` is monotonic elapsed host time, taken only when enabled and after a + /// successful submission/presentation handoff. Failed acquisition attempts + /// don't call this, but their delay remains in the next successful interval. + pub(crate) fn record_submission(&mut self, now: Duration) { + if !self.enabled { + return; + } + if self.last.is_some_and(|last| now < last) { + // A broken/reset clock cannot yield a meaningful rate across epochs. + // Equal timestamps are valid on a host with coarse clock precision. + self.first = None; + self.intervals = 0; + self.sample = None; + } + self.last = Some(now); + let Some(first) = self.first else { + self.first = Some(now); + return; + }; + self.intervals += 1; + let elapsed = now - first; + if elapsed >= SAMPLE_PERIOD { + self.sample = Some(Sample { + intervals: self.intervals, + elapsed, + }); + // Share the boundary timestamp, not its interval, with the next + // window. Long gaps count in full; filtering them could hide stalls. + self.first = Some(now); + self.intervals = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(meter: &mut FrameMeter, milliseconds: u64) { + meter.record_submission(Duration::from_millis(milliseconds)); + } + + fn assert_rate(sample: Sample, fps: f64, mean_ms: f64) { + assert!((sample.fps() - fps).abs() < 1e-9); + assert!((sample.mean_interval_ms() - mean_ms).abs() < 1e-9); + } + + #[test] + fn disabled_and_reenabled_meter_never_reuses_a_sample() { + let mut meter = FrameMeter::default(); + at(&mut meter, 0); + at(&mut meter, 1000); + assert_eq!(meter.first, None); + assert_eq!(meter.sample(), None); + meter.toggle(); + at(&mut meter, 1000); + assert_eq!(meter.sample(), None); + at(&mut meter, 1500); + assert_rate(meter.sample().unwrap(), 2.0, 500.0); + meter.toggle(); + assert!(!meter.enabled()); + assert_eq!(meter.sample(), None); + meter.toggle(); + at(&mut meter, 9000); + assert_eq!(meter.sample(), None); + at(&mut meter, 9500); + assert_rate(meter.sample().unwrap(), 2.0, 500.0); + } + + #[test] + fn counts_intervals_not_endpoints_at_60_and_120_fps() { + for fps in [60, 120] { + let mut meter = FrameMeter::default(); + meter.toggle(); + for i in 0..=fps / 2 { + meter.record_submission(Duration::from_secs_f64(i as f64 / fps as f64)); + } + let first = meter.sample().unwrap(); + assert_eq!(first.elapsed(), Duration::from_millis(500)); + assert_rate(first, fps as f64, 1000.0 / fps as f64); + for i in fps / 2 + 1..=fps { + meter.record_submission(Duration::from_secs_f64(i as f64 / fps as f64)); + } + assert_eq!(meter.sample(), Some(first)); + } + } + + #[test] + fn uneven_frames_use_total_elapsed_not_average_instantaneous_fps() { + let mut meter = FrameMeter::default(); + meter.toggle(); + for t in [0, 10, 20, 600] { + at(&mut meter, t); + } + assert_rate(meter.sample().unwrap(), 5.0, 200.0); + assert_eq!( + meter.sample().unwrap().elapsed(), + Duration::from_millis(600) + ); + } + + #[test] + fn idle_stalls_and_failed_attempts_remain_in_elapsed_time() { + let mut meter = FrameMeter::default(); + meter.toggle(); + for t in [0, 100, 200, 300, 400, 500] { + at(&mut meter, t); + } + let previous = meter.sample(); + at(&mut meter, 600); + // No handoff callbacks during idle, occlusion, or failed acquisition. + assert_eq!(meter.sample(), previous); + at(&mut meter, 10500); + assert_rate(meter.sample().unwrap(), 0.2, 5000.0); + assert_eq!(meter.sample().unwrap().elapsed(), Duration::from_secs(10)); + } + + #[test] + fn repeated_clock_values_count_frames_without_dividing_by_zero() { + let mut meter = FrameMeter::default(); + meter.toggle(); + for _ in 0..4 { + at(&mut meter, 0); + } + assert_eq!(meter.sample(), None); + at(&mut meter, 500); + assert_rate(meter.sample().unwrap(), 8.0, 125.0); + } + + #[test] + fn clock_regression_starts_a_new_epoch() { + let mut meter = FrameMeter::default(); + meter.toggle(); + at(&mut meter, 1000); + at(&mut meter, 1500); + at(&mut meter, 0); + assert_eq!(meter.sample(), None); + at(&mut meter, 500); + assert_rate(meter.sample().unwrap(), 2.0, 500.0); + } +} diff --git a/src/input/actions.rs b/src/input/actions.rs index f96a460..7757d6c 100644 --- a/src/input/actions.rs +++ b/src/input/actions.rs @@ -76,6 +76,7 @@ actions! { LocalView => ("view.local", "Local View", Command::ToggleLocalView, Some("Isolate selected objects; activate again to restore the scene")), Xray => ("view.xray", "X-ray", Command::ToggleXray, Some("See and select through surfaces")), Ruler2D => ("view.2d-ruler", "2D ruler", Command::ToggleRuler2D, None), + ToggleFpsMeter => ("view.fps-meter.toggle", "Show FPS Meter", Command::ToggleFpsMeter, Some("Measure completed application frame cadence without requesting extra frames")), Edges => ("view.edges", "Edges", Command::ToggleEdges, Some("Original polygon boundaries")), ViewPerspective => ("view.perspective", "Perspective", Command::View(View::Perspective), None), ViewFront => ("view.front", "Front", Command::View(View::Front), None), @@ -136,6 +137,7 @@ mod tests { | ActionId::AnimationPanel | ActionId::TerminalPanel | ActionId::CloseToolDock + | ActionId::ToggleFpsMeter | ActionId::Edges | ActionId::InsertCube | ActionId::InsertCylinder diff --git a/src/input/shortcuts.rs b/src/input/shortcuts.rs index ee5ebc0..c7281fd 100644 --- a/src/input/shortcuts.rs +++ b/src/input/shortcuts.rs @@ -24,6 +24,7 @@ pub enum Command { Undo, Redo, ToggleUi, + ToggleFpsMeter, ToggleAnimationPanel, ToggleTerminalPanel, CloseToolDock, diff --git a/src/lib.rs b/src/lib.rs index 14c47fa..f723893 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,9 @@ mod asset_io; #[cfg(not(target_arch = "wasm32"))] mod documentation; mod editor; +mod frame_meter; mod input; +mod measurement; mod model; #[cfg(not(target_arch = "wasm32"))] mod native; diff --git a/src/measurement/clock.rs b/src/measurement/clock.rs new file mode 100644 index 0000000..d3daf53 --- /dev/null +++ b/src/measurement/clock.rs @@ -0,0 +1,5 @@ +//! Each target keeps its monotonic clock, including browser Performance timing. +#[cfg(not(target_arch = "wasm32"))] +pub(super) use std::time::Instant; +#[cfg(target_arch = "wasm32")] +pub(super) use web_time::Instant; diff --git a/src/measurement/disabled.rs b/src/measurement/disabled.rs new file mode 100644 index 0000000..0bd928b --- /dev/null +++ b/src/measurement/disabled.rs @@ -0,0 +1,48 @@ +//! Zero-size production probes: no clocks, TLS, allocations, or recorder. +use super::Stage; + +#[derive(Default)] +pub(crate) struct FrameProbe { + _private: (), +} +impl FrameProbe { + #[inline(always)] + pub(crate) fn end(&mut self, _: Stage) {} + #[inline(always)] + pub(crate) fn mesh_upload(&mut self, _: usize) {} + #[inline(always)] + pub(crate) fn resized(&mut self) {} + #[inline(always)] + pub(crate) fn ui_jobs(&mut self, _: usize) {} + #[inline(always)] + pub(crate) fn pixel_scale(&mut self, _: f32) {} + #[inline(always)] + pub(crate) fn egui_pass(&mut self) {} + #[inline(always)] + pub(crate) fn egui_tessellate(&mut self) {} + #[inline(always)] + pub(crate) fn egui_texture_update(&mut self) {} + #[inline(always)] + pub(crate) fn egui_composite(&mut self) {} + #[inline(always)] + pub(crate) fn scene_render(&mut self) {} + #[inline(always)] + pub(crate) fn scene_size(&mut self, _: [u32; 2]) {} + #[inline(always)] + pub(crate) fn surface_size(&mut self, _: [u32; 2]) {} + #[inline(always)] + pub(crate) fn editor_feedback(&mut self) {} +} +pub(crate) struct ProjectionProbe; +impl ProjectionProbe { + #[inline(always)] + pub(crate) fn finish(self, _: usize) {} +} +#[inline(always)] +pub(crate) fn projection_started() -> ProjectionProbe { + ProjectionProbe +} +#[inline(always)] +pub(crate) fn geometry_rebuilt() {} +#[inline(always)] +pub(crate) fn editor_prepared() {} diff --git a/src/measurement/enabled.rs b/src/measurement/enabled.rs new file mode 100644 index 0000000..1e3c443 --- /dev/null +++ b/src/measurement/enabled.rs @@ -0,0 +1,568 @@ +use super::Stage; +use crate::{ + camera::Camera, + settings::{Settings, ThemeMode}, + workspace_ui::WorkspaceUi, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::cell::RefCell; +#[path = "clock.rs"] +mod clock; +use clock::Instant; + +const STAGES: [&str; 14] = [ + "surface_acquire", + "ui", + "commands_and_refresh", + "host_prepare", + "cache_sync", + "tessellation_and_textures", + "feedback", + "scene_encode", + "ui_encode", + "submit_api", + "present_api", + "host_tail", + "editor_prepare", + "scene_composite", +]; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Options { + pub warmup_frames: usize, + pub sample_frames: usize, + pub workload: Workload, + pub mode: ExecutionMode, + pub selected: bool, + pub instrument_stages: bool, +} +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ExecutionMode { + #[default] + Editor, + Viewport, + Renderer, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Workload { + Stationary, + Orbit, +} +impl Default for Options { + fn default() -> Self { + Self { + warmup_frames: 60, + sample_frames: 180, + workload: Workload::Orbit, + mode: ExecutionMode::Editor, + selected: false, + instrument_stages: true, + } + } +} +impl Options { + pub(crate) fn validate(&self) -> Result<(), String> { + if !(1..=10_000).contains(&self.sample_frames) || self.warmup_frames > 10_000 { + return Err( + "Measurement requires 1..=10000 samples and at most 10000 warm-up frames".into(), + ); + } + if self.mode == ExecutionMode::Renderer && self.selected { + return Err("Renderer mode excludes editor selection feedback; omit selected".into()); + } + Ok(()) + } +} + +#[derive(Default, Clone, Serialize)] +struct Counters { + mesh_uploads: usize, + // Only base mesh position/normal/color and edge buffers; not total GPU traffic. + base_mesh_upload_bytes: usize, + viewport_resizes: usize, + ui_jobs: usize, + egui_passes: usize, + egui_tessellations: usize, + egui_texture_updates: usize, + egui_composites: usize, + editor_prepares: usize, + editor_feedback_updates: usize, + scene_renders: usize, + scene_composites: usize, +} +#[derive(Default, Clone, Serialize)] +struct ProjectionStats { + rebuilds: usize, + vertices_tested: usize, + cpu_ms: Option, + geometry_rebuilds: usize, +} +#[derive(Default)] +struct Nested { + active: bool, + instrument: bool, + stats: ProjectionStats, + editor_prepares: usize, +} +thread_local! { static NESTED: RefCell = RefCell::new(Nested::default()); } + +#[derive(Default)] +pub(crate) struct FrameProbe { + data: Option, +} +struct FrameData { + start: Instant, + last: Instant, + stages: [Option; STAGES.len()], + instrument: bool, + counters: Counters, + pixels_per_point: f32, + scene_pixels: Option<[u32; 2]>, + surface_pixels: Option<[u32; 2]>, +} +impl FrameProbe { + fn start(instrument: bool) -> Self { + Self::start_at(instrument, Instant::now()) + } + fn start_at(instrument: bool, now: Instant) -> Self { + NESTED.with(|value| { + *value.borrow_mut() = Nested { + active: true, + instrument, + stats: ProjectionStats::default(), + editor_prepares: 0, + } + }); + Self { + data: Some(FrameData { + start: now, + last: now, + stages: [None; STAGES.len()], + instrument, + counters: Counters::default(), + pixels_per_point: 1.0, + scene_pixels: None, + surface_pixels: None, + }), + } + } + pub(crate) fn end(&mut self, stage: Stage) { + if let Some(data) = &mut self.data + && data.instrument + { + data.end_at(stage, Instant::now()); + } + } + pub(crate) fn mesh_upload(&mut self, bytes: usize) { + if let Some(data) = &mut self.data { + data.counters.mesh_uploads += 1; + data.counters.base_mesh_upload_bytes += bytes; + } + } + pub(crate) fn resized(&mut self) { + if let Some(data) = &mut self.data { + data.counters.viewport_resizes += 1; + } + } + pub(crate) fn ui_jobs(&mut self, count: usize) { + if let Some(data) = &mut self.data { + data.counters.ui_jobs = count; + } + } + pub(crate) fn pixel_scale(&mut self, value: f32) { + if let Some(data) = &mut self.data { + data.pixels_per_point = value; + } + } + pub(crate) fn egui_pass(&mut self) { + if let Some(data) = &mut self.data { + data.counters.egui_passes += 1; + } + } + pub(crate) fn egui_tessellate(&mut self) { + if let Some(data) = &mut self.data { + data.counters.egui_tessellations += 1; + } + } + pub(crate) fn egui_texture_update(&mut self) { + if let Some(data) = &mut self.data { + data.counters.egui_texture_updates += 1; + } + } + pub(crate) fn egui_composite(&mut self) { + if let Some(data) = &mut self.data { + data.counters.egui_composites += 1; + } + } + pub(crate) fn scene_render(&mut self) { + if let Some(data) = &mut self.data { + data.counters.scene_renders += 1; + } + } + pub(crate) fn scene_composite(&mut self) { + if let Some(data) = &mut self.data { + data.counters.scene_composites += 1; + } + } + pub(crate) fn scene_size(&mut self, value: [u32; 2]) { + if let Some(data) = &mut self.data { + data.scene_pixels = Some(value); + } + } + pub(crate) fn surface_size(&mut self, value: [u32; 2]) { + if let Some(data) = &mut self.data { + data.surface_pixels = Some(value); + } + } + pub(crate) fn editor_feedback(&mut self) { + if let Some(data) = &mut self.data { + data.counters.editor_feedback_updates += 1; + } + } +} +impl FrameData { + fn end_at(&mut self, stage: Stage, now: Instant) { + *self.stages[stage as usize].get_or_insert(0.0) += + now.duration_since(self.last).as_secs_f64() * 1000.0; + self.last = now; + } +} +impl Drop for FrameProbe { + fn drop(&mut self) { + if self.data.is_some() { + NESTED.with(|value| value.borrow_mut().active = false); + } + } +} +pub(crate) struct ProjectionProbe { + active: bool, + start: Option, +} +pub(crate) fn projection_started() -> ProjectionProbe { + NESTED.with(|value| { + let value = value.borrow(); + ProjectionProbe { + active: value.active, + start: (value.active && value.instrument).then(Instant::now), + } + }) +} +impl ProjectionProbe { + pub(crate) fn finish(self, vertices: usize) { + if self.active { + NESTED.with(|value| { + let mut value = value.borrow_mut(); + value.stats.rebuilds += 1; + value.stats.vertices_tested += vertices; + if let Some(start) = self.start { + *value.stats.cpu_ms.get_or_insert(0.0) += + start.elapsed().as_secs_f64() * 1000.0; + } + }); + } + } +} +pub(crate) fn editor_prepared() { + NESTED.with(|value| { + let mut value = value.borrow_mut(); + if value.active { + value.editor_prepares += 1; + } + }); +} +pub(crate) fn geometry_rebuilt() { + NESTED.with(|value| { + let mut value = value.borrow_mut(); + if value.active { + value.stats.geometry_rebuilds += 1; + } + }); +} + +#[derive(Serialize)] +struct Sample { + frame: usize, + frame_start_interval_ms: Option, + cpu_frame_ms: f64, + cpu_stage_ms: Option>>, + projection: ProjectionStats, + counters: Counters, + focused: bool, + fps_meter_enabled: bool, + viewport_pixels: Option<[u32; 2]>, + surface_pixels: Option<[u32; 2]>, + viewport_points: [f32; 4], + camera_view_projection: [f32; 16], + pixels_per_point: f32, + mesh_revision: u64, + error: Option, +} +pub(crate) struct Session { + options: Options, + metadata: Value, + frame: usize, + previous_start: Option, + samples: Vec, + finished: bool, + initial_camera: Camera, + viewport: egui::Rect, + viewport_ui_rect: egui::Rect, + initial_fps_meter_enabled: bool, + fps_meter_constant: bool, +} +impl Session { + pub(crate) fn new( + options: Options, + metadata: Value, + state: &mut WorkspaceUi, + ) -> Result { + options.validate()?; + if state.editor.document.objects.is_empty() || state.editor.is_interacting() { + return Err("Measurement needs a loaded document without an active edit".into()); + } + if !state.show_ui || state.show_preferences || state.tool_dock.active.is_some() { + return Err( + "Measurement needs the full editor layout with Preferences and the Tool Dock closed" + .into(), + ); + } + if !state.viewport.is_finite() || !state.viewport.is_positive() || state.is_local_view() { + return Err("Measurement needs a laid-out viewport showing the whole document".into()); + } + if state + .asset_views + .values() + .any(|view| view.playback.playing || view.playback.clip.is_some()) + { + return Err("Measurement requires imported assets in their static rest pose".into()); + } + state.apply_user_settings(&Settings::default()); + state.theme_mode = ThemeMode::Light; + state.shading = crate::render::shading::ShadingMode::Solid; + state.show_edges = true; + state.show_grid = true; + state.show_ui = true; + state.reset_navigation_input(); + state.toasts.clear(); + state.hovered_file = false; + state.editor.leave_edit(); + state.editor.set_tool(crate::editor::Tool::default()); + state.editor.set_xray(false); + state.editor.deselect(); + if options.selected { + state + .editor + .select_object(state.editor.document.objects[0].id)?; + } + state.editor.hovered_object = None; + state.camera = Camera::default(); + state.camera.frame(state.aspect()); + Ok(Self { + samples: Vec::with_capacity(options.sample_frames), + options, + metadata, + frame: 0, + previous_start: None, + finished: false, + initial_camera: state.camera.clone(), + viewport: state.viewport, + viewport_ui_rect: state.viewport_ui_rect, + initial_fps_meter_enabled: state.fps_meter.enabled(), + fps_meter_constant: true, + }) + } + pub(crate) fn active(&self) -> bool { + !self.finished + } + pub(crate) fn mode(&self) -> ExecutionMode { + self.options.mode + } + pub(crate) fn without_ui(&self) -> bool { + self.active() && self.mode() != ExecutionMode::Editor + } + pub(crate) fn prepare_without_ui(&self, state: &mut WorkspaceUi, probe: &mut FrameProbe) { + if self.active() && self.mode() == ExecutionMode::Viewport { + if let Err(error) = + state + .editor + .prepare_viewport(state.viewport, &state.camera, state.z_up) + { + state.error = Some(error); + } + probe.end(Stage::EditorPrepare); + } + } + pub(crate) fn begin_frame(&mut self, state: &mut WorkspaceUi) -> FrameProbe { + if self.finished { + return FrameProbe::default(); + } + if self.without_ui() { + // Keep the same scene area as the baseline editor layout. Skipping + // UI execution must not increase render resolution or change framing. + state.viewport = self.viewport; + state.viewport_ui_rect = self.viewport_ui_rect; + } + // Fixed angular path per rendered frame, through the shared navigation + // operation. This measures rendering throughput, not DOM/OS input latency. + state.editor.hovered_object = None; + state.camera = self.initial_camera.clone(); + if matches!(self.options.workload, Workload::Orbit) { + // Derive from completed frames, so a surface retry cannot advance + // the camera without an accompanying rendered sample. + state.orbit((self.frame + 1) as f32, 0.0); + } + FrameProbe::start(self.options.instrument_stages) + } + pub(crate) fn finish_frame( + &mut self, + probe: FrameProbe, + state: &WorkspaceUi, + focused: bool, + ) -> Option { + self.finish_frame_at(probe, state, focused, Instant::now()) + } + fn finish_frame_at( + &mut self, + mut probe: FrameProbe, + state: &WorkspaceUi, + focused: bool, + finished_at: Instant, + ) -> Option { + let mut data = probe.data.take()?; + let elapsed = finished_at.duration_since(data.start).as_secs_f64() * 1000.0; + let interval = self + .previous_start + .replace(data.start) + .map(|previous| data.start.duration_since(previous).as_secs_f64() * 1000.0); + let projection = NESTED.with(|value| { + let mut value = value.borrow_mut(); + value.active = false; + data.counters.editor_prepares = value.editor_prepares; + value.stats.clone() + }); + let fps_meter_enabled = state.fps_meter.enabled(); + // Include warm-up observations: changing the overlay workload and then + // restoring it must not make a contaminated run look constant. + self.fps_meter_constant &= fps_meter_enabled == self.initial_fps_meter_enabled; + if self.frame >= self.options.warmup_frames { + self.samples.push(Sample { + frame: self.samples.len(), + frame_start_interval_ms: interval, + cpu_frame_ms: elapsed, + cpu_stage_ms: data + .instrument + .then(|| STAGES.into_iter().zip(data.stages).collect()), + projection, + counters: data.counters, + focused, + fps_meter_enabled, + viewport_pixels: data.scene_pixels, + surface_pixels: data.surface_pixels, + viewport_points: rect_points(state.viewport), + camera_view_projection: state + .camera + .view_projection(state.aspect()) + .to_cols_array(), + pixels_per_point: data.pixels_per_point, + mesh_revision: state.mesh_revision, + error: state.error.clone(), + }); + } + self.frame += 1; + if self.samples.len() != self.options.sample_frames { + return None; + } + self.finished = true; + let mut stages = serde_json::Map::new(); + if self.options.instrument_stages { + for name in STAGES { + stages.insert( + name.into(), + distribution(self.samples.iter().filter_map(|sample| { + sample.cpu_stage_ms.as_ref().and_then(|values| values[name]) + })), + ); + } + stages.insert( + "editor_projection".into(), + distribution( + self.samples + .iter() + .filter_map(|sample| sample.projection.cpu_ms), + ), + ); + } + let mesh = state.mesh.as_ref(); + Some(json!({ + "schema": "n3.viewport-measure.v2", "options": self.options, "metadata": self.metadata, + "scope": "Production host rendering with a deterministic semantic camera workload. CPU timings are elapsed API costs, not GPU completion or display latency.", + "limitations": ["stationary is forced rendering, not idle power/wakeup observation", "GPU durations and actual presentation times are unavailable", "input device delivery and latency are excluded", "stage probes add overhead; compare instrument_stages=false runs", "no missed-display-frame inference without refresh/presentation observations"], + "render": {"fps_meter_enabled": self.initial_fps_meter_enabled, "shading": "solid", "edges": true, "grid": true, "theme": "light", "selected_objects": state.editor.selected_objects.len(), "hovered_object": state.editor.hovered_object, "pixels_per_point": self.samples.last().map(|sample| sample.pixels_per_point), "baseline_viewport_points": rect_points(self.viewport), "baseline_viewport_ui_points": rect_points(self.viewport_ui_rect), "initial_camera_view_projection": self.initial_camera.view_projection(self.viewport.width() / self.viewport.height()).to_cols_array()}, + "model": {"objects": state.editor.document.objects.len(), "vertices": mesh.map(|mesh| mesh.vertex_count), "faces": mesh.map(|mesh| mesh.face_count), "triangles": mesh.map(|mesh| mesh.triangle_count), "linked_assets": state.asset_views.len()}, + "validity": {"fps_meter_constant": self.fps_meter_constant, "all_frames_focused": self.samples.iter().all(|sample| sample.focused), "no_frame_errors": self.samples.iter().all(|sample| sample.error.is_none()), "constant_viewport": self.samples.iter().all(|sample| sample.viewport_pixels.is_some_and(|size| size[0] > 0 && size[1] > 0) && sample.viewport_pixels == self.samples[0].viewport_pixels && sample.viewport_points == self.samples[0].viewport_points && sample.pixels_per_point == self.samples[0].pixels_per_point), "constant_surface": self.samples.iter().all(|sample| sample.surface_pixels.is_some_and(|size| size[0] > 0 && size[1] > 0) && sample.surface_pixels == self.samples[0].surface_pixels), "viewport_matches_baseline": self.samples.iter().all(|sample| sample.viewport_points == rect_points(self.viewport)), "unchanged_mesh_revision": self.samples.iter().all(|sample| sample.mesh_revision == self.samples[0].mesh_revision), "mode_contract_satisfied": self.samples.iter().all(|sample| sample.mode_contract_satisfied(self.mode()))}, + "summary": {"cpu_frame_ms": distribution(self.samples.iter().map(|sample| sample.cpu_frame_ms)), "frame_start_interval_ms": distribution(self.samples.iter().filter_map(|sample| sample.frame_start_interval_ms)), "cpu_stage_ms": stages}, + "samples": self.samples, + })) + } +} +impl Sample { + fn mode_contract_satisfied(&self, mode: ExecutionMode) -> bool { + let counts = &self.counters; + if counts.scene_renders != 1 + || !self + .viewport_pixels + .is_some_and(|size| size[0] > 0 && size[1] > 0) + { + return false; + } + if mode == ExecutionMode::Editor { + return counts.egui_passes > 0 + && counts.egui_tessellations == 1 + && counts.egui_composites == 1 + && counts.editor_prepares > 0 + && counts.editor_feedback_updates == 1 + && counts.scene_composites == 0; + } + let without_egui = counts.egui_passes == 0 + && counts.egui_tessellations == 0 + && counts.egui_texture_updates == 0 + && counts.egui_composites == 0 + && counts.ui_jobs == 0; + without_egui + && counts.scene_composites == 1 + && match mode { + ExecutionMode::Editor => unreachable!(), + ExecutionMode::Viewport => { + counts.editor_prepares > 0 && counts.editor_feedback_updates == 1 + } + ExecutionMode::Renderer => { + counts.editor_prepares == 0 + && counts.editor_feedback_updates == 0 + && self.projection.rebuilds == 0 + && self.projection.vertices_tested == 0 + && self.projection.geometry_rebuilds == 0 + } + } + } +} +fn rect_points(rect: egui::Rect) -> [f32; 4] { + [rect.min.x, rect.min.y, rect.max.x, rect.max.y] +} +fn distribution(values: impl Iterator) -> Value { + let mut values: Vec<_> = values.collect(); + values.sort_by(f64::total_cmp); + if values.is_empty() { + return Value::Null; + } + let n = values.len(); + let median = (values[(n - 1) / 2] + values[n / 2]) / 2.0; + json!({"count": n, "median": median, "p95": values[(n * 95).div_ceil(100) - 1], "p99": values[(n * 99).div_ceil(100) - 1], "min": values[0], "max": values[n - 1]}) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/src/measurement/mod.rs b/src/measurement/mod.rs new file mode 100644 index 0000000..fd97778 --- /dev/null +++ b/src/measurement/mod.rs @@ -0,0 +1,29 @@ +//! Opt-in viewport diagnostics. Normal builds erase probes at this module boundary. +#[cfg(feature = "viewport-measure")] +mod enabled; +#[cfg(feature = "viewport-measure")] +pub(crate) use enabled::*; +#[cfg(not(feature = "viewport-measure"))] +mod disabled; +#[cfg(not(feature = "viewport-measure"))] +pub(crate) use disabled::*; + +#[derive(Clone, Copy)] +pub(crate) enum Stage { + Acquire, + Ui, + Commands, + HostPrepare, + CacheSync, + Tessellate, + Feedback, + SceneEncode, + UiEncode, + Submit, + Present, + HostTail, + #[cfg(feature = "viewport-measure")] + EditorPrepare, + #[cfg(feature = "viewport-measure")] + SceneComposite, +} diff --git a/src/measurement/tests.rs b/src/measurement/tests.rs new file mode 100644 index 0000000..9f8bdc4 --- /dev/null +++ b/src/measurement/tests.rs @@ -0,0 +1,749 @@ +use super::*; +use std::time::Duration; + +fn instant_after(origin: Instant, millis: u64) -> Instant { + origin + Duration::from_millis(millis) +} + +fn renderer_probe_at(start: Instant, instrument: bool) -> FrameProbe { + let mut probe = FrameProbe::start_at(instrument, start); + probe.scene_render(); + probe.scene_composite(); + probe.scene_size([900, 600]); + probe.surface_size([1280, 800]); + probe +} + +fn renderer_session(state: &mut WorkspaceUi, warmup: usize, samples: usize) -> Session { + Session::new( + Options { + mode: ExecutionMode::Renderer, + warmup_frames: warmup, + sample_frames: samples, + ..Options::default() + }, + json!({}), + state, + ) + .unwrap() +} + +fn fixture() -> WorkspaceUi { + let mut state = WorkspaceUi::new(egui::TextureId::Managed(0)); + state + .editor + .insert(crate::document::PrimitiveKind::Cube) + .unwrap(); + state.viewport = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 600.0)); + state.viewport_ui_rect = state.viewport.shrink(10.0); + state +} +#[test] +fn nearest_rank_percentiles_and_even_median_preserve_raw_order_independently() { + let values = distribution([4.0, 1.0, 3.0, 2.0].into_iter()); + assert_eq!(values["median"], 2.5); + assert_eq!(values["p95"], 4.0); + assert_eq!(distribution(std::iter::empty()), Value::Null); +} + +#[test] +fn summary_percentiles_use_nearest_rank_without_interpolating_tail_samples() { + assert_eq!( + distribution([42.0].into_iter()), + json!({"count": 1, "median": 42.0, "p95": 42.0, "p99": 42.0, "min": 42.0, "max": 42.0}) + ); + assert_eq!( + distribution((1..=101).rev().map(f64::from)), + json!({"count": 101, "median": 51.0, "p95": 96.0, "p99": 100.0, "min": 1.0, "max": 101.0}) + ); +} + +#[test] +fn warmup_is_excluded_and_completion_retains_exactly_the_requested_samples() { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 2, 3); + let origin = Instant::now(); + let mut report = None; + for frame in 0..5 { + let start = instant_after(origin, frame * 100); + let probe = renderer_probe_at(start, true); + // Large warm-up costs must not leak into retained frame distributions. + let duration = if frame < 2 { 90 } else { frame + 1 }; + report = session.finish_frame_at(probe, &state, true, instant_after(start, duration)); + assert_eq!(report.is_some(), frame == 4); + assert_eq!(session.active(), frame != 4); + } + let report = report.unwrap(); + assert!( + report["validity"] + .as_object() + .unwrap() + .values() + .all(|value| value == true) + ); + let samples = report["samples"].as_array().unwrap(); + assert_eq!(samples.len(), 3); + for (index, sample) in samples.iter().enumerate() { + assert_eq!(sample["frame"], index); + assert_eq!(sample["cpu_frame_ms"], (index + 3) as f64); + // The first retained frame's cadence includes its warm-up predecessor. + assert_eq!(sample["frame_start_interval_ms"], 100.0); + } + assert_eq!(report["summary"]["cpu_frame_ms"]["median"], 4.0); + assert_eq!(report["summary"]["frame_start_interval_ms"]["count"], 3); + let completed_camera = state.camera.view_projection(state.aspect()); + let inactive = session.begin_frame(&mut state); + assert!(inactive.data.is_none()); + assert_eq!( + state.camera.view_projection(state.aspect()), + completed_camera + ); + assert!(session.finish_frame(inactive, &state, true).is_none()); + assert_eq!(session.samples.len(), 3); +} + +#[test] +fn zero_warmup_has_no_invented_first_interval_or_cadence_summary() { + let state = &mut fixture(); + let mut session = renderer_session(state, 0, 1); + let start = Instant::now(); + let report = session + .finish_frame_at( + renderer_probe_at(start, true), + state, + true, + instant_after(start, 5), + ) + .unwrap(); + assert_eq!(report["samples"][0]["cpu_frame_ms"], 5.0); + assert!(report["samples"][0]["frame_start_interval_ms"].is_null()); + assert!(report["summary"]["frame_start_interval_ms"].is_null()); +} + +#[test] +fn fps_meter_baseline_and_samples_observe_the_initial_state_without_changing_it() { + for enabled in [false, true] { + let mut state = fixture(); + if enabled { + state.fps_meter.toggle(); + } + let mut session = renderer_session(&mut state, 1, 2); + assert_eq!(state.fps_meter.enabled(), enabled); + let origin = Instant::now(); + let mut report = None; + for frame in 0..3 { + let start = instant_after(origin, frame * 100); + report = session.finish_frame_at( + renderer_probe_at(start, true), + &state, + true, + instant_after(start, 5), + ); + } + let report = report.unwrap(); + assert_eq!(report["render"]["fps_meter_enabled"], enabled); + assert_eq!(report["validity"]["fps_meter_constant"], true); + for sample in report["samples"].as_array().unwrap() { + assert_eq!(sample["fps_meter_enabled"], enabled); + } + } +} + +#[test] +fn fps_meter_toggle_during_retained_frames_is_contamination_not_a_new_baseline() { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 0, 2); + let origin = Instant::now(); + assert!( + session + .finish_frame_at( + renderer_probe_at(origin, true), + &state, + true, + instant_after(origin, 5), + ) + .is_none() + ); + state.fps_meter.toggle(); + let report = session + .finish_frame_at( + renderer_probe_at(instant_after(origin, 100), true), + &state, + true, + instant_after(origin, 105), + ) + .unwrap(); + assert_eq!(report["render"]["fps_meter_enabled"], false); + assert_eq!(report["samples"][0]["fps_meter_enabled"], false); + assert_eq!(report["samples"][1]["fps_meter_enabled"], true); + assert_eq!(report["validity"]["fps_meter_constant"], false); +} + +#[test] +fn fps_meter_toggle_during_warmup_remains_contamination_after_restoring_baseline() { + let mut state = fixture(); + state.fps_meter.toggle(); + let mut session = renderer_session(&mut state, 1, 1); + state.fps_meter.toggle(); + let origin = Instant::now(); + assert!( + session + .finish_frame_at( + renderer_probe_at(origin, true), + &state, + true, + instant_after(origin, 5), + ) + .is_none() + ); + state.fps_meter.toggle(); + let report = session + .finish_frame_at( + renderer_probe_at(instant_after(origin, 100), true), + &state, + true, + instant_after(origin, 105), + ) + .unwrap(); + assert_eq!(report["render"]["fps_meter_enabled"], true); + assert_eq!(report["samples"][0]["fps_meter_enabled"], true); + assert_eq!(report["validity"]["fps_meter_constant"], false); +} + +#[test] +fn retry_work_is_not_a_sample_but_its_elapsed_gap_remains_in_frame_cadence() { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 0, 2); + let start = Instant::now(); + assert!( + session + .finish_frame_at( + renderer_probe_at(start, true), + &state, + true, + instant_after(start, 5) + ) + .is_none() + ); + let mut aborted = renderer_probe_at(instant_after(start, 100), true); + aborted.mesh_upload(1234); + geometry_rebuilt(); + drop(aborted); + assert_eq!(session.samples.len(), 1); + assert_eq!(session.frame, 1); + assert!(!NESTED.with(|value| value.borrow().active)); + + let report = session + .finish_frame_at( + renderer_probe_at(instant_after(start, 250), true), + &state, + true, + instant_after(start, 257), + ) + .unwrap(); + let sample = &report["samples"][1]; + assert_eq!(sample["cpu_frame_ms"], 7.0); + assert_eq!(sample["frame_start_interval_ms"], 250.0); + assert_eq!(sample["counters"]["mesh_uploads"], 0); + assert_eq!(sample["projection"]["geometry_rebuilds"], 0); + assert_eq!(report["summary"]["frame_start_interval_ms"]["count"], 1); + assert_eq!( + report["summary"]["frame_start_interval_ms"]["median"], + 250.0 + ); +} + +#[test] +fn stage_samples_distinguish_unexecuted_zero_duration_and_accumulated_work() { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 0, 1); + let start = Instant::now(); + let mut probe = renderer_probe_at(start, true); + let data = probe.data.as_mut().unwrap(); + data.end_at(Stage::Acquire, start); + data.end_at(Stage::SceneEncode, instant_after(start, 3)); + data.end_at(Stage::Present, instant_after(start, 4)); + data.end_at(Stage::SceneEncode, instant_after(start, 7)); + let report = session + .finish_frame_at(probe, &state, true, instant_after(start, 9)) + .unwrap(); + let stages = &report["samples"][0]["cpu_stage_ms"]; + let summaries = &report["summary"]["cpu_stage_ms"]; + assert_eq!(stages["surface_acquire"], 0.0); + assert_eq!(stages["scene_encode"], 6.0); + assert_eq!(stages["present_api"], 1.0); + assert!(stages["ui"].is_null()); + assert_eq!(summaries["surface_acquire"]["count"], 1); + assert_eq!(summaries["surface_acquire"]["median"], 0.0); + assert_eq!(summaries["scene_encode"]["median"], 6.0); + assert!(summaries["ui"].is_null()); + assert!(summaries["editor_projection"].is_null()); + // The enclosing CPU timer includes work after the last named stage. + assert_eq!(report["samples"][0]["cpu_frame_ms"], 9.0); +} + +#[test] +fn each_stage_enum_serializes_under_its_own_measurement_name() { + let stages = [ + (Stage::Acquire, "surface_acquire"), + (Stage::Ui, "ui"), + (Stage::Commands, "commands_and_refresh"), + (Stage::HostPrepare, "host_prepare"), + (Stage::CacheSync, "cache_sync"), + (Stage::Tessellate, "tessellation_and_textures"), + (Stage::Feedback, "feedback"), + (Stage::SceneEncode, "scene_encode"), + (Stage::UiEncode, "ui_encode"), + (Stage::Submit, "submit_api"), + (Stage::Present, "present_api"), + (Stage::HostTail, "host_tail"), + (Stage::EditorPrepare, "editor_prepare"), + (Stage::SceneComposite, "scene_composite"), + ]; + for (stage, name) in stages { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 0, 1); + let start = Instant::now(); + let mut probe = renderer_probe_at(start, true); + probe + .data + .as_mut() + .unwrap() + .end_at(stage, instant_after(start, 3)); + let report = session + .finish_frame_at(probe, &state, true, instant_after(start, 4)) + .unwrap(); + let stages = report["samples"][0]["cpu_stage_ms"].as_object().unwrap(); + assert_eq!(stages[name], 3.0, "{name}"); + assert_eq!(stages.values().filter(|value| !value.is_null()).count(), 1); + } +} + +#[test] +fn renderer_mode_rejects_every_kind_of_hidden_editor_or_ui_work() { + type ProbeMutation = fn(&mut FrameProbe); + let violations: [(&str, ProbeMutation); 9] = [ + ("egui pass", FrameProbe::egui_pass), + ("egui tessellation", FrameProbe::egui_tessellate), + ("egui texture upload", FrameProbe::egui_texture_update), + ("egui composition", FrameProbe::egui_composite), + ("ui jobs", |probe| probe.ui_jobs(1)), + ("editor preparation", |_| editor_prepared()), + ("editor feedback", FrameProbe::editor_feedback), + ("projection", |_| projection_started().finish(8)), + ("geometry preparation", |_| geometry_rebuilt()), + ]; + for (name, violate) in violations { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 0, 1); + let mut probe = renderer_probe_at(Instant::now(), false); + violate(&mut probe); + let report = session.finish_frame(probe, &state, true).unwrap(); + assert_eq!( + report["validity"]["mode_contract_satisfied"], false, + "{name}" + ); + assert!(report["samples"][0]["cpu_stage_ms"].is_null()); + } +} + +#[test] +fn every_mode_requires_exactly_one_nonempty_scene_render() { + for mode in [ + ExecutionMode::Editor, + ExecutionMode::Viewport, + ExecutionMode::Renderer, + ] { + for violation in [ + "none", + "missing_scene", + "duplicate_scene", + "empty_scene", + "unknown_scene", + ] { + let mut state = fixture(); + let mut session = Session::new( + Options { + mode, + warmup_frames: 0, + sample_frames: 1, + ..Options::default() + }, + json!({}), + &mut state, + ) + .unwrap(); + let mut probe = session.begin_frame(&mut state); + if mode == ExecutionMode::Editor { + probe.egui_pass(); + probe.egui_tessellate(); + probe.egui_composite(); + } else { + probe.scene_composite(); + } + if mode != ExecutionMode::Renderer { + editor_prepared(); + probe.editor_feedback(); + } + if violation != "missing_scene" { + probe.scene_render(); + } + if violation == "duplicate_scene" { + probe.scene_render(); + } + if violation != "unknown_scene" { + probe.scene_size(if violation == "empty_scene" { + [0, 600] + } else { + [900, 600] + }); + } + let report = session.finish_frame(probe, &state, true).unwrap(); + assert_eq!( + report["validity"]["mode_contract_satisfied"], + violation == "none", + "{mode:?}: {violation}" + ); + } + } +} + +#[test] +fn contaminated_samples_are_retained_and_flagged_instead_of_filtered_out() { + for contamination in ["focus", "error", "revision", "viewport"] { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 0, 2); + let start = Instant::now(); + assert!( + session + .finish_frame_at( + renderer_probe_at(start, true), + &state, + true, + instant_after(start, 5), + ) + .is_none() + ); + match contamination { + "error" => state.error = Some("test frame failed".into()), + "revision" => state.mesh_revision += 1, + "viewport" => state.viewport.max.x += 10.0, + _ => {} + } + let report = session + .finish_frame_at( + renderer_probe_at(instant_after(start, 100), true), + &state, + contamination != "focus", + instant_after(start, 150), + ) + .unwrap(); + assert_eq!(report["samples"].as_array().unwrap().len(), 2); + assert_eq!(report["samples"][1]["cpu_frame_ms"], 50.0); + assert_eq!(report["summary"]["cpu_frame_ms"]["count"], 2); + assert_eq!(report["summary"]["cpu_frame_ms"]["max"], 50.0); + let invalid_field = match contamination { + "focus" => "all_frames_focused", + "error" => "no_frame_errors", + "revision" => "unchanged_mesh_revision", + "viewport" => "viewport_matches_baseline", + _ => unreachable!(), + }; + assert_eq!(report["validity"][invalid_field], false, "{contamination}"); + } +} + +#[test] +fn serialized_report_matches_the_shared_python_admission_contract() { + let contract: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tools/benchmark/tests/fixtures/measurement-schema.json" + ))) + .unwrap(); + let keys = |value: &Value| { + let mut names: Vec<_> = value.as_object().unwrap().keys().cloned().collect(); + names.sort(); + json!(names) + }; + for instrument in [true, false] { + let mut state = fixture(); + let mut session = renderer_session(&mut state, 0, 1); + session.options.instrument_stages = instrument; + let start = Instant::now(); + let report = session + .finish_frame_at( + renderer_probe_at(start, instrument), + &state, + true, + instant_after(start, 5), + ) + .unwrap(); + assert_eq!(report["schema"], contract["schema"]); + assert_eq!(keys(&report["validity"]), contract["validity_fields"]); + let sample = &report["samples"][0]; + assert_eq!(keys(sample), contract["sample_fields"]); + assert_eq!(keys(&sample["counters"]), contract["counter_names"]); + if instrument { + assert_eq!(keys(&sample["cpu_stage_ms"]), contract["stage_names"]); + } else { + assert!(sample["cpu_stage_ms"].is_null()); + } + } +} +#[test] +fn options_bound_retained_samples_and_reject_unknown_fields() { + assert!( + Options { + sample_frames: 0, + ..Options::default() + } + .validate() + .is_err() + ); + assert!( + Options { + sample_frames: 10_001, + ..Options::default() + } + .validate() + .is_err() + ); + assert!(serde_json::from_str::(r#"{"fake":true}"#).is_err()); + let options: Options = serde_json::from_str("{}").unwrap(); + assert_eq!(options.mode, ExecutionMode::Editor); + assert!( + Options { + mode: ExecutionMode::Renderer, + selected: true, + ..options + } + .validate() + .is_err() + ); + assert!(serde_json::from_str::(r#"{"mode":"hidden"}"#).is_err()); +} + +#[test] +fn all_modes_share_scene_area_and_camera_without_reusing_a_hidden_ui_layout() { + let mut poses = Vec::new(); + for mode in [ + ExecutionMode::Editor, + ExecutionMode::Viewport, + ExecutionMode::Renderer, + ] { + let mut state = fixture(); + let viewport = state.viewport; + let usable = state.viewport_ui_rect; + let mut session = Session::new( + Options { + mode, + ..Options::default() + }, + json!({}), + &mut state, + ) + .unwrap(); + if mode != ExecutionMode::Editor { + // A non-UI frame must use the captured layout, never a full + // window placeholder or a stale scene rect supplied by a host. + state.viewport = egui::Rect::NOTHING; + state.viewport_ui_rect = egui::Rect::NOTHING; + } + let probe = session.begin_frame(&mut state); + assert_eq!(state.viewport, viewport); + assert_eq!(state.viewport_ui_rect, usable); + poses.push(state.camera.view_projection(state.aspect())); + assert!(state.show_ui, "measurement modes are not Hide UI"); + drop(probe); + } + assert!(poses.iter().all(|pose| pose == &poses[0])); + let mut state = fixture(); + state.show_ui = false; + assert!(Session::new(Options::default(), json!({}), &mut state).is_err()); + state.show_ui = true; + state.show_preferences = true; + assert!(Session::new(Options::default(), json!({}), &mut state).is_err()); +} + +#[test] +fn untimed_viewport_keeps_execution_and_projection_evidence() { + let mut state = fixture(); + let mut session = Session::new( + Options { + mode: ExecutionMode::Viewport, + warmup_frames: 0, + sample_frames: 1, + instrument_stages: false, + ..Options::default() + }, + json!({}), + &mut state, + ) + .unwrap(); + let mut probe = session.begin_frame(&mut state); + session.prepare_without_ui(&mut state, &mut probe); + // Exercise actual editor preparation above; signal the renderer calls + // here without requiring a GPU in the recorder's unit tests. + probe.editor_feedback(); + probe.scene_render(); + probe.scene_composite(); + probe.scene_size([1800, 1200]); + probe.pixel_scale(2.0); + let report = session.finish_frame(probe, &state, true).unwrap(); + let sample = &report["samples"][0]; + assert_eq!(report["schema"], "n3.viewport-measure.v2"); + assert_eq!(report["validity"]["mode_contract_satisfied"], true); + assert_eq!(sample["counters"]["egui_passes"], 0); + assert_eq!(sample["counters"]["editor_prepares"], 1); + assert_eq!(sample["projection"]["rebuilds"], 1); + assert_eq!(sample["projection"]["vertices_tested"], 8); + assert!(sample["projection"]["cpu_ms"].is_null()); + assert!(sample["cpu_stage_ms"].is_null()); + assert_eq!(sample["viewport_pixels"], json!([1800, 1200])); + assert_eq!(sample["viewport_points"], json!([40.0, 30.0, 940.0, 630.0])); +} + +#[test] +fn renderer_contract_detects_ui_editor_and_missing_scene_execution() { + for violation in ["none", "egui", "editor", "feedback", "missing_scene"] { + let mut state = fixture(); + let mut session = Session::new( + Options { + mode: ExecutionMode::Renderer, + warmup_frames: 0, + sample_frames: 1, + ..Options::default() + }, + json!({}), + &mut state, + ) + .unwrap(); + let mut probe = session.begin_frame(&mut state); + session.prepare_without_ui(&mut state, &mut probe); + match violation { + "egui" => probe.egui_pass(), + "editor" => state + .editor + .prepare_viewport(state.viewport, &state.camera, state.z_up) + .unwrap(), + "feedback" => probe.editor_feedback(), + _ => {} + } + if violation != "missing_scene" { + probe.scene_render(); + probe.scene_composite(); + probe.scene_size([900, 600]); + } + let report = session.finish_frame(probe, &state, true).unwrap(); + assert_eq!( + report["validity"]["mode_contract_satisfied"], + violation == "none", + "{violation}" + ); + assert!(report["samples"][0]["cpu_stage_ms"]["ui"].is_null()); + assert!(report["samples"][0]["cpu_stage_ms"]["editor_prepare"].is_null()); + if violation != "editor" { + assert!(report["summary"]["cpu_stage_ms"]["editor_projection"].is_null()); + } + } +} + +#[test] +fn frozen_scene_rect_does_not_hide_surface_or_scale_changes() { + for change in ["none", "surface", "scale"] { + let mut state = fixture(); + let mut session = Session::new( + Options { + mode: ExecutionMode::Renderer, + workload: Workload::Stationary, + warmup_frames: 0, + sample_frames: 2, + instrument_stages: false, + ..Options::default() + }, + json!({}), + &mut state, + ) + .unwrap(); + let mut report = None; + for frame in 0..2 { + let mut probe = session.begin_frame(&mut state); + probe.scene_render(); + probe.scene_composite(); + probe.scene_size([900, 600]); + probe.surface_size(if change == "surface" && frame == 1 { + [1300, 800] + } else { + [1280, 800] + }); + probe.pixel_scale(if change == "scale" && frame == 1 { + 2.0 + } else { + 1.0 + }); + report = session.finish_frame(probe, &state, true); + } + let report = report.unwrap(); + assert_eq!(report["validity"]["mode_contract_satisfied"], true); + assert_eq!(report["validity"]["viewport_matches_baseline"], true); + assert_eq!(report["validity"]["constant_surface"], change != "surface"); + assert_eq!(report["validity"]["constant_viewport"], change != "scale"); + } +} + +#[test] +fn interrupted_frame_retries_the_same_camera_pose_and_releases_nested_probes() { + let mut state = WorkspaceUi::new(egui::TextureId::Managed(0)); + state + .editor + .insert(crate::document::PrimitiveKind::Cube) + .unwrap(); + state.viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)); + let mut session = Session::new(Options::default(), json!({}), &mut state).unwrap(); + let probe = session.begin_frame(&mut state); + let pose = state.camera.view_projection(state.aspect()); + drop(probe); + assert!(!NESTED.with(|value| value.borrow().active)); + let retry = session.begin_frame(&mut state); + assert_eq!(pose, state.camera.view_projection(state.aspect())); + assert!(session.finish_frame(retry, &state, true).is_none()); + let next = session.begin_frame(&mut state); + assert_ne!(pose, state.camera.view_projection(state.aspect())); + drop(next); +} + +#[test] +fn projection_counters_distinguish_geometry_builds_camera_changes_and_cache_hits() { + let mut document = crate::document::Document::default(); + document + .insert_primitive(crate::document::PrimitiveKind::Cube) + .unwrap(); + let mut editor = crate::editor::Editor::new(document).unwrap(); + let viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)); + let mut camera = Camera::default(); + let frame = FrameProbe::start(true); + editor + .object_selection_bounds(viewport, &camera, false) + .unwrap(); + editor + .object_selection_bounds(viewport, &camera, false) + .unwrap(); + NESTED.with(|value| { + let value = value.borrow(); + assert_eq!(value.stats.geometry_rebuilds, 1); + assert_eq!(value.stats.rebuilds, 1); + assert_eq!(value.stats.vertices_tested, 8); + }); + drop(frame); + let frame = FrameProbe::start(true); + camera.orbit(1.0, 0.0); + editor + .object_selection_bounds(viewport, &camera, false) + .unwrap(); + NESTED.with(|value| { + let value = value.borrow(); + assert_eq!(value.stats.geometry_rebuilds, 0); + assert_eq!(value.stats.rebuilds, 1); + }); + drop(frame); +} diff --git a/src/native.rs b/src/native.rs index 37f89d9..8a88547 100644 --- a/src/native.rs +++ b/src/native.rs @@ -1,8 +1,14 @@ //! macOS window, dialogs and event-loop adapter. +#[cfg(feature = "viewport-measure")] +mod measurement; +#[cfg(not(feature = "viewport-measure"))] +#[path = "native/measurement_disabled.rs"] +mod measurement; mod settings_host; mod settings_store; use crate::asset_io::LoadedDocument; +use crate::measurement::Stage; use crate::render::workspace::{FrameTarget, WorkspaceRenderer}; use crate::{ document_io, keyboard_input, navigation_events, scroll_input, @@ -85,6 +91,7 @@ struct NativeWindow { modifiers: egui::Modifiers, generation: u64, last_camera_tick: Instant, + frame_clock: Instant, proxy: EventLoopProxy, next_repaint: Option, pending_open: bool, @@ -94,10 +101,15 @@ struct NativeWindow { settings_path: Option, settings_open_error: Option, settings_defaults: Settings, + measurement: measurement::Host, } impl NativeWindow { - async fn new(window: Arc, proxy: EventLoopProxy) -> Result { + async fn new( + window: Arc, + proxy: EventLoopProxy, + mut measurement: measurement::Host, + ) -> Result { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::METAL, ..wgpu::InstanceDescriptor::new_without_display_handle() @@ -145,6 +157,7 @@ impl NativeWindow { view_formats: vec![], color_space: wgpu::SurfaceColorSpace::Auto, }; + measurement.set_adapter(&adapter.get_info()); surface.configure(&device, &config); let context = egui::Context::default(); configure_context(&context); @@ -188,6 +201,7 @@ impl NativeWindow { modifiers: egui::Modifiers::NONE, generation: 0, last_camera_tick: Instant::now(), + frame_clock: Instant::now(), proxy, next_repaint: None, pending_open: false, @@ -197,12 +211,19 @@ impl NativeWindow { settings_path: None, settings_open_error: None, settings_defaults, + measurement, }; native.initialize_settings(); Ok(native) } fn initialize_settings(&mut self) { + if self.measurement.configured() { + // Measurement sessions use isolated defaults and never touch the + // user's global store, including while the input file is loading. + self.state.apply_user_settings(&Settings::default()); + return; + } let result = (|| { let store = FileSettingsStore::global()?; let path = store.path().to_path_buf(); @@ -258,6 +279,9 @@ impl NativeWindow { } fn service_settings(&mut self) { + if self.measurement.configured() { + return; + } let now = Instant::now(); // Native input can be queued before egui has processed the next pass. // Do not let a timer apply preferences across that pending boundary. @@ -322,6 +346,9 @@ impl NativeWindow { /// Called only after the user has resolved the document's Save/Discard /// decision. An unrelated malformed settings file must never trap quitting. fn flush_settings_before_quit(&mut self) -> bool { + if self.measurement.configured() { + return true; + } let local = self.state.user_settings(); let result = match &mut self.settings { Some(host) if host.has_pending(&local) => { @@ -530,6 +557,9 @@ impl NativeWindow { } } fn navigate(&mut self, event: navigation_events::Event) { + if self.measurement.configured() { + return; + } navigation_events::route( &mut self.state, &self.context, @@ -707,6 +737,18 @@ impl NativeWindow { if self.window.inner_size().width == 0 || self.window.inner_size().height == 0 { return; } + let mut probe = + match self + .measurement + .begin_frame(&mut self.state, &self.window, &self.config) + { + Ok(probe) => probe, + Err(error) => { + eprintln!("Viewport measurement failed: {error}"); + event_loop.exit(); + return; + } + }; let (frame, reconfigure_after_present) = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) => (frame, false), wgpu::CurrentSurfaceTexture::Suboptimal(frame) => (frame, true), @@ -738,112 +780,161 @@ impl NativeWindow { return; } }; - let now = Instant::now(); - self.state - .camera - .advance_transition(now.duration_since(self.last_camera_tick)); - self.last_camera_tick = now; - // A pose or scene may pass CPU validation yet exceed this GPU's limits. - // Keep cheap Arc-backed state until this redraw's candidate is prepared. + probe.end(Stage::Acquire); + // Preserve the candidate rollback snapshot across either frame path. let previous_assets = self.state.asset_views.clone(); - let input = self.input.take_egui_input(&self.window); let ctx = self.context.clone(); - let mut shortcuts = ShortcutFrame::with_number_events(&ctx, self.number_keys.take()); - let mut output = ctx.run_ui(input, |ui| { - let context = ui.ctx().clone(); - let ctx = &context; - shortcuts.begin_pass(ctx); - self.state.ui(ui); - shortcuts - .collect_with_transform(ctx, self.state.transform_keyboard_context(ctx, false)); - if self.state.camera.is_transitioning() { - ctx.request_repaint(); + let mut output = if self.measurement.without_ui() { + // Drain host events without entering egui's frame/layout machinery. + let _ = self.input.take_egui_input(&self.window); + self.number_keys.reset(); + self.measurement + .prepare_without_ui(&mut self.state, &mut probe); + None + } else { + let now = Instant::now(); + self.state + .camera + .advance_transition(now.duration_since(self.last_camera_tick)); + self.last_camera_tick = now; + let mut input = self.input.take_egui_input(&self.window); + if self.measurement.configured() { + // The synthetic camera workload excludes physical input latency. + // Keep real focus state, but prevent hover or typing from changing + // its selection, overlays, and camera while samples are collected. + input.events.clear(); + input.events.push(egui::Event::PointerGone); + self.number_keys.reset(); + } + let mut shortcuts = ShortcutFrame::with_number_events(&ctx, self.number_keys.take()); + let mut output = ctx.run_ui(input, |ui| { + let context = ui.ctx().clone(); + let ctx = &context; + probe.egui_pass(); + shortcuts.begin_pass(ctx); + self.state.ui(ui); + shortcuts + .collect_with_transform(ctx, self.state.transform_keyboard_context(ctx, false)); + if self.state.camera.is_transitioning() { + ctx.request_repaint(); + } + }); + probe.end(Stage::Ui); + if std::mem::take(&mut self.state.tool_dock.terminal_start_requested) { + // Process creation is a native-host effect, outside repeated UI passes. + // Failure is retained in the terminal status with a Retry control. + let _ = self.state.tool_dock.terminal.start_shell(&ctx); + self.window.request_redraw(); } - }); - if std::mem::take(&mut self.state.tool_dock.terminal_start_requested) { - // Process creation is a native-host effect, outside repeated UI passes. - // Failure is retained in the terminal status with a Retry control. - let _ = self.state.tool_dock.terminal.start_shell(&ctx); - self.window.request_redraw(); - } - if self.applied_window_theme != self.state.theme_mode { - // Explicit preferences also theme the native titlebar. Resetting to - // None gives macOS ownership back; ThemeChanged then tracks the OS. - let native_theme = match self.state.theme_mode { - ThemeMode::System => None, - ThemeMode::Light => Some(winit::window::Theme::Light), - ThemeMode::Dark => Some(winit::window::Theme::Dark), - }; - self.window.set_theme(native_theme); - self.applied_window_theme = self.state.theme_mode; - if native_theme.is_none() { - self.state - .set_system_theme(resolved_window_theme(self.window.theme())); + if self.applied_window_theme != self.state.theme_mode { + // Explicit preferences also theme the native titlebar. Resetting to + // None gives macOS ownership back; ThemeChanged then tracks the OS. + let native_theme = match self.state.theme_mode { + ThemeMode::System => None, + ThemeMode::Light => Some(winit::window::Theme::Light), + ThemeMode::Dark => Some(winit::window::Theme::Dark), + }; + self.window.set_theme(native_theme); + self.applied_window_theme = self.state.theme_mode; + if native_theme.is_none() { + self.state + .set_system_theme(resolved_window_theme(self.window.theme())); + } + self.window.request_redraw(); } - self.window.request_redraw(); - } - for command in self - .state - .take_ui_commands() - .into_iter() - .chain(shortcuts.commands()) - { - match self.state.dispatch(command, &ctx, false) { - HostEffect::None - | HostEffect::CancelNavigation - | HostEffect::NavigationContextChanged => {} - HostEffect::Open => self.pending_open = true, - HostEffect::Import => self.pending_import = true, - HostEffect::Quit => self.pending_quit = true, + for command in self + .state + .take_ui_commands() + .into_iter() + .chain(shortcuts.commands()) + { + match self.state.dispatch(command, &ctx, false) { + HostEffect::None + | HostEffect::CancelNavigation + | HostEffect::NavigationContextChanged => {} + HostEffect::Open => self.pending_open = true, + HostEffect::Import => self.pending_import = true, + HostEffect::Quit => self.pending_quit = true, + } + self.window.request_redraw(); + if egui::Popup::is_any_open(&ctx) { + // A keyboard-opened menu owns the rest of this input batch. + break; + } } - self.window.request_redraw(); - if egui::Popup::is_any_open(&ctx) { - // A keyboard-opened menu owns the rest of this input batch. - break; - } - } - if let Err(error) = self.state.refresh_mesh() { - self.state.error = Some(error); - } - self.input - .handle_platform_output(&self.window, std::mem::take(&mut output.platform_output)); - let title = self - .state - .save_path - .as_deref() - .map(filename) - .unwrap_or_else(|| "Untitled".into()); - self.window.set_title(&format!( - "{}{} — N3", - title, - if self.state.is_dirty() { " •" } else { "" } - )); + if let Err(error) = self.state.refresh_mesh() { + self.state.error = Some(error); + } + probe.end(Stage::Commands); + self.input + .handle_platform_output(&self.window, std::mem::take(&mut output.platform_output)); + let title = self + .state + .save_path + .as_deref() + .map(filename) + .unwrap_or_else(|| "Untitled".into()); + self.window.set_title(&format!( + "{}{} — N3", + title, + if self.state.is_dirty() { " •" } else { "" } + )); + Some(output) + }; let view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); - self.graphics.paint( - FrameTarget { - device: &self.device, - queue: &self.queue, - view: &view, - size: [self.config.width, self.config.height], - }, - &ctx, - &mut self.state, - &mut output, - previous_assets, - ); + probe.end(Stage::HostPrepare); + if let Some(output) = &mut output { + self.graphics.paint( + FrameTarget { + device: &self.device, + queue: &self.queue, + view: &view, + size: [self.config.width, self.config.height], + }, + &ctx, + &mut self.state, + output, + previous_assets, + &mut probe, + ); + } else { + self.measurement.paint_without_ui( + &mut self.graphics, + FrameTarget { + device: &self.device, + queue: &self.queue, + view: &view, + size: [self.config.width, self.config.height], + }, + &mut self.state, + previous_assets, + egui_winit::pixels_per_point(&ctx, &self.window), + &mut probe, + ); + } self.window.pre_present_notify(); self.queue.present(frame); - self.graphics - .finish_frame(std::mem::take(&mut output.textures_delta.free)); + // Passive app cadence: one clock read per successful handoff when enabled. + // This does not observe GPU completion or change redraw scheduling. + if self.state.fps_meter.enabled() { + self.state + .fps_meter + .record_submission(self.frame_clock.elapsed()); + } + if let Some(output) = &mut output { + self.graphics + .finish_frame(std::mem::take(&mut output.textures_delta.free)); + } + probe.end(Stage::Present); if reconfigure_after_present { self.resize(); } let delay = output - .viewport_output - .get(&egui::ViewportId::ROOT) + .as_ref() + .and_then(|output| output.viewport_output.get(&egui::ViewportId::ROOT)) .map(|v| v.repaint_delay) .unwrap_or(Duration::MAX); self.next_repaint = Instant::now().checked_add(delay); @@ -869,6 +960,22 @@ impl NativeWindow { { event_loop.exit(); } + probe.end(Stage::HostTail); + match self + .measurement + .finish_frame(probe, &self.state, self.window.has_focus()) + { + Ok(true) => event_loop.exit(), + Ok(false) => { + if self.measurement.active() { + self.window.request_redraw(); + } + } + Err(error) => { + eprintln!("Viewport measurement failed: {error}"); + event_loop.exit(); + } + } } } struct App { @@ -882,6 +989,19 @@ impl ApplicationHandler for App { if self.workspace.is_some() { return; } + let measurement = match measurement::Host::from_environment() { + Ok(host) => host, + Err(error) => { + eprintln!("Viewport measurement configuration failed: {error}"); + event_loop.exit(); + return; + } + }; + if measurement.configured() && self.initial.is_none() { + eprintln!("Viewport measurement requires an input document path."); + event_loop.exit(); + return; + } let size = initial_window_size( event_loop .primary_monitor() @@ -894,12 +1014,13 @@ impl ApplicationHandler for App { MINIMUM_WINDOW_SIZE.width.min(size.width), MINIMUM_WINDOW_SIZE.height.min(size.height), )); + let attributes = measurement.window_attributes(attributes); let window = Arc::new( event_loop .create_window(attributes) .expect("create macOS window"), ); - match pollster::block_on(NativeWindow::new(window, self.proxy.clone())) { + match pollster::block_on(NativeWindow::new(window, self.proxy.clone(), measurement)) { Ok(mut workspace) => { workspace.state.editor.set_access(self.access); if let Some(path) = self.initial.take() { @@ -919,7 +1040,7 @@ impl ApplicationHandler for App { workspace.event(&event, event_loop); } } - fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) { + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent) { let Some(workspace) = &mut self.workspace else { return; }; @@ -950,6 +1071,13 @@ impl ApplicationHandler for App { workspace.state.error = Some(error); } } + if workspace.measurement.configured() + && let Some(error) = &workspace.state.error + { + eprintln!("Viewport measurement input failed: {error}"); + event_loop.exit(); + return; + } workspace.window.request_redraw(); } _ => {} diff --git a/src/native/measurement.rs b/src/native/measurement.rs new file mode 100644 index 0000000..2177da1 --- /dev/null +++ b/src/native/measurement.rs @@ -0,0 +1,357 @@ +//! Opt-in native measurement lifecycle and output. The shared session owns the +//! workload and sample schema; ordinary native scheduling and preferences remain +//! unchanged when no measurement was requested. +use crate::{ + measurement::{FrameProbe, Options, Session}, + workspace_ui::WorkspaceUi, +}; +use serde_json::{Value, json}; +use std::{env, io::Write, path::PathBuf}; +use winit::{ + dpi::PhysicalSize, + window::{Window, WindowAttributes}, +}; + +#[derive(Default)] +pub(super) struct Host { + pending: Option, + session: Option, + output: Option, + metadata: Value, + size: Option>, +} + +impl Host { + pub(super) fn from_environment() -> Result { + let Some(options) = env::var_os("N3_VIEWPORT_MEASURE") else { + return Ok(Self::default()); + }; + let options = options + .into_string() + .map_err(|_| "N3_VIEWPORT_MEASURE must be UTF-8 JSON")?; + let options: Options = serde_json::from_str(&options) + .map_err(|error| format!("Invalid N3_VIEWPORT_MEASURE: {error}"))?; + options.validate()?; + let output = env::var_os("N3_VIEWPORT_MEASURE_OUTPUT") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .ok_or("N3_VIEWPORT_MEASURE_OUTPUT must name the output JSON file")?; + let metadata: Value = match env::var("N3_VIEWPORT_MEASURE_METADATA") { + Ok(value) => serde_json::from_str(&value) + .map_err(|error| format!("Invalid N3_VIEWPORT_MEASURE_METADATA: {error}"))?, + Err(env::VarError::NotPresent) => json!({}), + Err(error) => return Err(format!("Invalid N3_VIEWPORT_MEASURE_METADATA: {error}")), + }; + if !metadata.is_object() { + return Err("N3_VIEWPORT_MEASURE_METADATA must be a JSON object".into()); + } + let size = match env::var("N3_VIEWPORT_MEASURE_SIZE") { + Ok(value) => Some(parse_size(&value)?), + Err(env::VarError::NotPresent) => None, + Err(error) => return Err(format!("Invalid N3_VIEWPORT_MEASURE_SIZE: {error}")), + }; + Ok(Self { + pending: Some(options), + session: None, + output: Some(output), + metadata, + size, + }) + } + + pub(super) fn configured(&self) -> bool { + self.output.is_some() + } + + pub(super) fn active(&self) -> bool { + self.pending.is_some() || self.session.as_ref().is_some_and(Session::active) + } + + pub(super) fn window_attributes(&self, attributes: WindowAttributes) -> WindowAttributes { + match self.size { + Some(size) => attributes + .with_min_inner_size(PhysicalSize::new(1, 1)) + .with_inner_size(size), + None => attributes, + } + } + + pub(super) fn set_adapter(&mut self, adapter: &wgpu::AdapterInfo) { + if self.configured() { + self.metadata["host"] = json!("native"); + self.metadata["adapter"] = json!({ + "name": adapter.name, + "vendor": adapter.vendor, + "device": adapter.device, + "device_type": format!("{:?}", adapter.device_type), + "driver": adapter.driver, + "driver_info": adapter.driver_info, + "backend": format!("{:?}", adapter.backend), + }); + } + } + + pub(super) fn without_ui(&self) -> bool { + self.session.as_ref().is_some_and(Session::without_ui) + } + + pub(super) fn prepare_without_ui(&self, state: &mut WorkspaceUi, probe: &mut FrameProbe) { + if let Some(session) = &self.session { + session.prepare_without_ui(state, probe); + } + } + + pub(super) fn paint_without_ui( + &self, + graphics: &mut crate::render::workspace::WorkspaceRenderer, + target: crate::render::workspace::FrameTarget<'_>, + state: &mut WorkspaceUi, + previous_assets: std::collections::BTreeMap< + crate::document::AssetInstance, + crate::scene_view::SceneView, + >, + pixels_per_point: f32, + probe: &mut FrameProbe, + ) { + let session = self.session.as_ref().expect("active no-UI measurement"); + graphics.paint_without_ui( + target, + state, + previous_assets, + pixels_per_point, + session.mode() == crate::measurement::ExecutionMode::Viewport, + probe, + ); + } + + pub(super) fn begin_frame( + &mut self, + state: &mut WorkspaceUi, + window: &Window, + config: &wgpu::SurfaceConfiguration, + ) -> Result { + if !self.configured() || state.loading.is_some() { + return Ok(FrameProbe::default()); + } + if let Some(error) = &state.error { + return Err(error.clone()); + } + if state.mesh.is_none() { + return Err("The measurement input contains no renderable mesh".into()); + } + if !state.viewport.is_finite() || !state.viewport.is_positive() { + return Ok(FrameProbe::default()); + } + if let Some(options) = self.pending.take() { + // Snapshot actual configured dimensions after initial window and UI + // layout events; requested dimensions alone are not measurement data. + self.metadata["surface"] = json!({ + "width": config.width, + "height": config.height, + "scale_factor": window.scale_factor(), + "format": format!("{:?}", config.format), + "present_mode": format!("{:?}", config.present_mode), + "desired_maximum_frame_latency": config.desired_maximum_frame_latency, + "alpha_mode": format!("{:?}", config.alpha_mode), + "color_space": format!("{:?}", config.color_space), + }); + self.session = Some(Session::new(options, self.metadata.take(), state)?); + } + Ok(self + .session + .as_mut() + .map(|session| session.begin_frame(state)) + .unwrap_or_default()) + } + + /// Return true only after the complete report has reached the requested file. + /// Serialization and filesystem work occur after collection, outside samples. + pub(super) fn finish_frame( + &mut self, + probe: FrameProbe, + state: &WorkspaceUi, + focused: bool, + ) -> Result { + let Some(session) = &mut self.session else { + return Ok(false); + }; + if let Some(error) = &state.error { + return Err(error.clone()); + } + let Some(report) = session.finish_frame(probe, state, focused) else { + return Ok(false); + }; + let output = self.output.as_ref().expect("configured measurement output"); + let mut bytes = serde_json::to_vec_pretty(&report) + .map_err(|error| format!("Could not encode viewport measurement: {error}"))?; + bytes.push(b'\n'); + // A completed run must never replace evidence from an earlier run. + // create_new makes this guarantee atomic even when callers race. + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(output) + .and_then(|mut file| file.write_all(&bytes)) + .map_err(|error| { + format!( + "Could not write viewport measurement {}: {error}", + output.display() + ) + })?; + println!("Viewport measurement saved to {}", output.display()); + Ok(true) + } +} + +fn parse_size(value: &str) -> Result, String> { + let invalid = || "N3_VIEWPORT_MEASURE_SIZE must be positive WIDTHxHEIGHT pixels".to_owned(); + let (width, height) = value.split_once('x').ok_or_else(invalid)?; + let width = width.parse::().map_err(|_| invalid())?; + let height = height.parse::().map_err(|_| invalid())?; + if width == 0 || height == 0 { + return Err(invalid()); + } + Ok(PhysicalSize::new(width, height)) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct OutputDirectory(PathBuf); + + impl OutputDirectory { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = env::temp_dir().join(format!( + "n3-native-measure-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn report(&self) -> PathBuf { + self.0.join("report.json") + } + } + + impl Drop for OutputDirectory { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + + fn fixture() -> WorkspaceUi { + let mut state = WorkspaceUi::new(egui::TextureId::User(0)); + state.viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0)); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/obj/cube-quads.obj"); + let loaded = crate::asset_io::load(&path).unwrap(); + state.install_loaded_document(path, loaded).unwrap(); + state + } + + fn collecting_host(options: Value, output: PathBuf, state: &mut WorkspaceUi) -> Host { + let options = serde_json::from_value(options).unwrap(); + Host { + session: Some(Session::new(options, json!({"host": "native"}), state).unwrap()), + output: Some(output), + ..Host::default() + } + } + + fn next_frame(host: &mut Host, state: &mut WorkspaceUi) -> Result { + let probe = host.session.as_mut().unwrap().begin_frame(state); + host.finish_frame(probe, state, true) + } + + #[test] + fn measurement_size_is_explicit_positive_physical_pixels() { + assert_eq!( + parse_size("1280x720").unwrap(), + PhysicalSize::new(1280, 720) + ); + for invalid in ["0x720", "1280x0", "-1x720", "1280", "1280x720x2", "inf x 1"] { + assert!(parse_size(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn report_is_written_only_after_warmup_and_all_samples_without_mutating_document() { + let output = OutputDirectory::new(); + let mut state = fixture(); + let document = state.editor.document.clone(); + let revision = state.mesh_revision; + let mut host = collecting_host( + json!({ + "warmup_frames": 2, + "sample_frames": 2, + "workload": "stationary", + "selected": true, + "instrument_stages": false, + }), + output.report(), + &mut state, + ); + for _ in 0..3 { + assert!(!next_frame(&mut host, &mut state).unwrap()); + assert!(!output.report().exists()); + assert!(host.active()); + } + assert!(next_frame(&mut host, &mut state).unwrap()); + assert!(!host.active()); + assert!( + host.configured(), + "settings remain isolated after collection" + ); + let bytes = std::fs::read(output.report()).unwrap(); + let report: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(report["schema"], "n3.viewport-measure.v2"); + assert_eq!(report["metadata"]["host"], "native"); + assert_eq!(report["samples"].as_array().unwrap().len(), 2); + assert_eq!(report["samples"][0]["frame"], 0); + assert_eq!(report["samples"][1]["frame"], 1); + assert!(report["samples"][0]["cpu_stage_ms"].is_null()); + assert_eq!(report["render"]["selected_objects"], 1); + assert_eq!(state.editor.document, document); + assert_eq!(state.mesh_revision, revision); + } + + #[test] + fn existing_report_is_never_replaced() { + let output = OutputDirectory::new(); + let previous = b"existing measurement evidence\n"; + std::fs::write(output.report(), previous).unwrap(); + let mut state = fixture(); + let mut host = collecting_host( + json!({"warmup_frames": 0, "sample_frames": 1}), + output.report(), + &mut state, + ); + let error = next_frame(&mut host, &mut state).unwrap_err(); + assert!(error.contains("Could not write viewport measurement")); + assert_eq!(std::fs::read(output.report()).unwrap(), previous); + } + + #[test] + fn frame_failure_does_not_publish_a_completed_report() { + let output = OutputDirectory::new(); + let mut state = fixture(); + let mut host = collecting_host( + json!({"warmup_frames": 0, "sample_frames": 1}), + output.report(), + &mut state, + ); + state.error = Some("Rejected GPU upload".into()); + assert_eq!( + next_frame(&mut host, &mut state).unwrap_err(), + "Rejected GPU upload" + ); + assert!(!output.report().exists()); + } +} diff --git a/src/native/measurement_disabled.rs b/src/native/measurement_disabled.rs new file mode 100644 index 0000000..2912d6f --- /dev/null +++ b/src/native/measurement_disabled.rs @@ -0,0 +1,64 @@ +//! Zero-cost ordinary-host counterpart to the optional measurement adapter. +use crate::{measurement::FrameProbe, workspace_ui::WorkspaceUi}; +use winit::window::{Window, WindowAttributes}; + +pub(super) struct Host; + +impl Host { + pub(super) fn from_environment() -> Result { + Ok(Self) + } + + pub(super) fn configured(&self) -> bool { + false + } + + pub(super) fn active(&self) -> bool { + false + } + + pub(super) fn window_attributes(&self, attributes: WindowAttributes) -> WindowAttributes { + attributes + } + + pub(super) fn set_adapter(&mut self, _: &wgpu::AdapterInfo) {} + + #[inline(always)] + pub(super) fn without_ui(&self) -> bool { + false + } + + #[inline(always)] + pub(super) fn prepare_without_ui(&self, _: &mut WorkspaceUi, _: &mut FrameProbe) {} + + #[inline(always)] + pub(super) fn paint_without_ui( + &self, + _: &mut crate::render::workspace::WorkspaceRenderer, + _: crate::render::workspace::FrameTarget<'_>, + _: &mut WorkspaceUi, + _: std::collections::BTreeMap, + _: f32, + _: &mut FrameProbe, + ) { + unreachable!("measurement mode is disabled") + } + + pub(super) fn begin_frame( + &mut self, + _: &mut WorkspaceUi, + _: &Window, + _: &wgpu::SurfaceConfiguration, + ) -> Result { + Ok(FrameProbe::default()) + } + + pub(super) fn finish_frame( + &mut self, + _: FrameProbe, + _: &WorkspaceUi, + _: bool, + ) -> Result { + Ok(false) + } +} diff --git a/src/render/workspace.rs b/src/render/workspace.rs index 9c12b5c..ffb2880 100644 --- a/src/render/workspace.rs +++ b/src/render/workspace.rs @@ -15,6 +15,12 @@ pub(crate) struct FrameTarget<'a> { pub size: [u32; 2], } +#[cfg(feature = "viewport-measure")] +#[path = "workspace/no_ui.rs"] +mod presentation; +#[cfg(not(feature = "viewport-measure"))] +#[path = "workspace/no_ui_disabled.rs"] +mod presentation; #[cfg(test)] mod tests; @@ -22,7 +28,9 @@ pub(crate) struct WorkspaceRenderer { ui: egui_wgpu::Renderer, scene: SceneRenderer, scene_size: [u32; 2], + scene_texture_dirty: bool, uploaded_revision: u64, + presentation: presentation::ScenePresentation, } impl WorkspaceRenderer { @@ -40,7 +48,9 @@ impl WorkspaceRenderer { ui, scene, scene_size: size, + scene_texture_dirty: false, uploaded_revision: u64::MAX, + presentation: presentation::ScenePresentation::new(format), }, texture, ) @@ -102,22 +112,24 @@ impl WorkspaceRenderer { state.scene_texture, ); self.scene = gpu; + self.scene_texture_dirty = false; + self.presentation.invalidate(); self.uploaded_revision = state.mesh_revision; Ok(()) } - /// Synchronize shared caches, encode and submit this frame. Surface acquire, - /// presentation notification, and presentation remain host operations. - pub(crate) fn paint( + + /// All frame variants use the same validation, rollback and revision caches. + /// Texture registration belongs to the UI path, not to scene synchronization. + fn sync_scene( &mut self, - target: FrameTarget<'_>, - ctx: &egui::Context, + device: &wgpu::Device, + queue: &wgpu::Queue, state: &mut WorkspaceUi, - output: &mut egui::FullOutput, previous_assets: BTreeMap, + pixels_per_point: f32, + probe: &mut crate::measurement::FrameProbe, ) { - let device = target.device; - let queue = target.queue; - let pixels_per_point = output.pixels_per_point; + probe.pixel_scale(pixels_per_point); let limit = device.limits().max_texture_dimension_2d; let width = (state.viewport.width() * pixels_per_point) .round() @@ -128,7 +140,12 @@ impl WorkspaceRenderer { let resized = self.scene_size != [width, height]; if resized { self.scene.resize(device, width, height); + self.scene_size = [width, height]; + self.scene_texture_dirty = true; + self.presentation.invalidate(); + probe.resized(); } + probe.scene_size(self.scene_size); // Preflight expanded proxy buffers before updating imported geometry: // either cache must retain the previous frame if this GPU cannot upload. let upload_allowed = match state @@ -161,18 +178,16 @@ impl WorkspaceRenderer { let _ = state.refresh_mesh(); state.error = Some(error); } - if resized { - self.ui.update_egui_texture_from_wgpu_texture( - device, - &self.scene.view, - wgpu::FilterMode::Linear, - state.scene_texture, - ); - self.scene_size = [width, height]; - } if upload_allowed && self.uploaded_revision != state.mesh_revision { let result = if let Some(mesh) = &state.mesh { - self.scene.set_mesh(device, mesh) + let result = self.scene.set_mesh(device, mesh); + if result.is_ok() { + probe.mesh_upload( + (mesh.vertices.len() + mesh.edges.len()) + * std::mem::size_of::(), + ); + } + result } else { self.scene.clear_mesh(); Ok(()) @@ -182,45 +197,60 @@ impl WorkspaceRenderer { Err(error) => state.error = Some(error), } } + } + + /// Synchronize shared caches, encode and submit this frame. Surface acquire, + /// presentation notification, and presentation remain host operations. + pub(crate) fn paint( + &mut self, + target: FrameTarget<'_>, + ctx: &egui::Context, + state: &mut WorkspaceUi, + output: &mut egui::FullOutput, + previous_assets: BTreeMap, + probe: &mut crate::measurement::FrameProbe, + ) { + let device = target.device; + let queue = target.queue; + let pixels_per_point = output.pixels_per_point; + probe.surface_size(target.size); + self.sync_scene( + device, + queue, + state, + previous_assets, + pixels_per_point, + probe, + ); + if self.scene_texture_dirty { + self.ui.update_egui_texture_from_wgpu_texture( + device, + &self.scene.view, + wgpu::FilterMode::Linear, + state.scene_texture, + ); + self.scene_texture_dirty = false; + probe.egui_texture_update(); + } + probe.end(crate::measurement::Stage::CacheSync); + probe.egui_tessellate(); let jobs = ctx.tessellate(std::mem::take(&mut output.shapes), pixels_per_point); + probe.ui_jobs(jobs.len()); for (id, deltas) in std::mem::take(&mut output.textures_delta.set) { for delta in deltas { + probe.egui_texture_update(); self.ui.update_texture(device, queue, id, &delta); } } + probe.end(crate::measurement::Stage::Tessellate); let view = target.view; let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("workspace frame"), }); - self.scene.set_visible_objects(state.visible_objects()); - self.scene.set_highlights(state.object_highlights()); - self.scene - .set_edit_selection(device, state.edit_selection()); - self.scene.set_pixel_scale(pixels_per_point); - let gizmo = state - .editor - .transform_gizmo_vertices(state.viewport, &state.camera, state.z_up) - .unwrap_or_else(|error| { - state.error = Some(error); - Vec::new() - }); - self.scene.set_transform_gizmo(queue, gizmo); - if let Err(error) = self.scene.render( - queue, - &mut encoder, - &state.camera, - ViewportRenderOptions { - shading: state.shading, - xray: state.editor.xray_enabled(), - show_edges: state.show_edges, - show_grid: state.show_grid, - z_up: state.z_up, - background: crate::theme::Palette::new(state.resolved_theme(), state.accent_color) - .workbench_viewport, - }, - ) { - state.error = Some(error); - } + self.prepare_feedback(device, queue, state, pixels_per_point, true, probe); + probe.end(crate::measurement::Stage::Feedback); + self.encode_scene(queue, &mut encoder, state, probe); + probe.end(crate::measurement::Stage::SceneEncode); let screen = egui_wgpu::ScreenDescriptor { size_in_pixels: target.size, pixels_per_point, @@ -242,9 +272,71 @@ impl WorkspaceRenderer { })], ..Default::default() }); + probe.egui_composite(); self.ui.render(&mut pass.forget_lifetime(), &jobs, &screen); } + probe.end(crate::measurement::Stage::UiEncode); queue.submit(buffers.into_iter().chain(std::iter::once(encoder.finish()))); + probe.end(crate::measurement::Stage::Submit); + } + + fn prepare_feedback( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + state: &mut WorkspaceUi, + pixels_per_point: f32, + editor_feedback: bool, + probe: &mut crate::measurement::FrameProbe, + ) { + self.scene.set_visible_objects(state.visible_objects()); + self.scene.set_pixel_scale(pixels_per_point); + if editor_feedback { + probe.editor_feedback(); + self.scene.set_highlights(state.object_highlights()); + self.scene + .set_edit_selection(device, state.edit_selection()); + let gizmo = state + .editor + .transform_gizmo_vertices(state.viewport, &state.camera, state.z_up) + .unwrap_or_else(|error| { + state.error = Some(error); + Vec::new() + }); + self.scene.set_transform_gizmo(queue, gizmo); + } else { + // A renderer measurement must not inherit selection or a live gizmo + // from the preceding editor frame. Object visibility is scene state. + self.scene.set_highlights(Default::default()); + self.scene.set_edit_selection(device, Default::default()); + self.scene.set_transform_gizmo(queue, Vec::new()); + } + } + + fn encode_scene( + &mut self, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + state: &mut WorkspaceUi, + probe: &mut crate::measurement::FrameProbe, + ) { + probe.scene_render(); + if let Err(error) = self.scene.render( + queue, + encoder, + &state.camera, + ViewportRenderOptions { + shading: state.shading, + xray: state.editor.xray_enabled(), + show_edges: state.show_edges, + show_grid: state.show_grid, + z_up: state.z_up, + background: crate::theme::Palette::new(state.resolved_theme(), state.accent_color) + .workbench_viewport, + }, + ) { + state.error = Some(error); + } } /// Free UI textures only after the host has presented the submitted frame. diff --git a/src/render/workspace/no_ui.rs b/src/render/workspace/no_ui.rs new file mode 100644 index 0000000..2061ff0 --- /dev/null +++ b/src/render/workspace/no_ui.rs @@ -0,0 +1,252 @@ +//! Measurement-only presentation of the production offscreen scene. No egui +//! frame, texture registration, tessellation, buffers or compositor execute here. +use super::{FrameTarget, WorkspaceRenderer}; +use crate::{ + document::AssetInstance, + measurement::{FrameProbe, Stage}, + scene_view::SceneView, + workspace_ui::WorkspaceUi, +}; +use std::collections::BTreeMap; + +impl WorkspaceRenderer { + pub(crate) fn paint_without_ui( + &mut self, + target: FrameTarget<'_>, + state: &mut WorkspaceUi, + previous_assets: BTreeMap, + pixels_per_point: f32, + editor_feedback: bool, + probe: &mut FrameProbe, + ) { + probe.surface_size(target.size); + self.sync_scene( + target.device, + target.queue, + state, + previous_assets, + pixels_per_point, + probe, + ); + if !editor_feedback { + self.prepare_feedback( + target.device, + target.queue, + state, + pixels_per_point, + false, + probe, + ); + } + probe.end(Stage::CacheSync); + let mut encoder = target + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("workspace frame without UI"), + }); + if editor_feedback { + self.prepare_feedback( + target.device, + target.queue, + state, + pixels_per_point, + true, + probe, + ); + probe.end(Stage::Feedback); + } + self.encode_scene(target.queue, &mut encoder, state, probe); + probe.end(Stage::SceneEncode); + self.presentation.render( + &target, + &mut encoder, + &self.scene.view, + state.viewport, + pixels_per_point, + ); + probe.scene_composite(); + probe.end(Stage::SceneComposite); + target.queue.submit([encoder.finish()]); + probe.end(Stage::Submit); + } +} + +pub(super) struct ScenePresentation { + format: wgpu::TextureFormat, + resources: Option, +} +struct Resources { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + rect: wgpu::Buffer, + scene: Option, +} +impl ScenePresentation { + pub(super) fn new(format: wgpu::TextureFormat) -> Self { + Self { + format, + resources: None, + } + } + pub(super) fn invalidate(&mut self) { + if let Some(resources) = &mut self.resources { + resources.scene = None; + } + } + fn render( + &mut self, + target: &FrameTarget<'_>, + encoder: &mut wgpu::CommandEncoder, + scene: &wgpu::TextureView, + viewport: egui::Rect, + pixels_per_point: f32, + ) { + let resources = self + .resources + .get_or_insert_with(|| Resources::new(target.device, self.format)); + let scene = resources.scene.get_or_insert_with(|| { + target.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("measurement scene presentation"), + layout: &resources.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(scene), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&resources.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: resources.rect.as_entire_binding(), + }, + ], + }) + }); + // Preserve the editor's scene rectangle and pixel density. Expanding to + // the surface would silently benchmark more pixels and a different view. + let scale = [ + pixels_per_point / target.size[0] as f32, + pixels_per_point / target.size[1] as f32, + ]; + let rect = [ + viewport.min.x * scale[0] * 2.0 - 1.0, + 1.0 - viewport.min.y * scale[1] * 2.0, + viewport.max.x * scale[0] * 2.0 - 1.0, + 1.0 - viewport.max.y * scale[1] * 2.0, + ]; + target + .queue + .write_buffer(&resources.rect, 0, bytemuck::cast_slice(&rect)); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("scene-only composite"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target.view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&resources.pipeline); + pass.set_bind_group(0, &*scene, &[]); + pass.draw(0..6, 0..1); + } +} +impl Resources { + fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("measurement scene presentation layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(16), + }, + count: None, + }, + ], + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("measurement scene presentation sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let rect = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("measurement scene presentation rectangle"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("measurement scene presentation shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("scene_composite.wgsl").into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("measurement scene presentation pipeline layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("measurement scene presentation pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some(if format.is_srgb() { + "fs_srgb" + } else { + "fs_gamma" + }), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + Self { + pipeline, + layout, + sampler, + rect, + scene: None, + } + } +} diff --git a/src/render/workspace/no_ui_disabled.rs b/src/render/workspace/no_ui_disabled.rs new file mode 100644 index 0000000..13f8b72 --- /dev/null +++ b/src/render/workspace/no_ui_disabled.rs @@ -0,0 +1,8 @@ +//! Ordinary builds do not allocate or compile the measurement compositor. +pub(super) struct ScenePresentation; +impl ScenePresentation { + pub(super) fn new(_: wgpu::TextureFormat) -> Self { + Self + } + pub(super) fn invalidate(&mut self) {} +} diff --git a/src/render/workspace/scene_composite.wgsl b/src/render/workspace/scene_composite.wgsl new file mode 100644 index 0000000..ed7f739 --- /dev/null +++ b/src/render/workspace/scene_composite.wgsl @@ -0,0 +1,39 @@ +// The production scene is gamma encoded in Rgba8Unorm. Match egui-wgpu 0.36.2 +// default linear sampling and gamma-space dithering, and undo gamma before an +// sRGB attachment applies its automatic conversion. The scene is always opaque. +@group(0) @binding(0) var scene: texture_2d; +@group(0) @binding(1) var scene_sampler: sampler; +@group(0) @binding(2) var rect: vec4; + +struct Vertex { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> Vertex { + let corners = array, 6>( + vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), + vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0) + ); + var out: Vertex; + out.uv = corners[index]; + out.position = vec4(mix(rect.xy, rect.zw, out.uv), 0.0, 1.0); + return out; +} +fn sample_gamma(in: Vertex) -> vec4 { + let color = textureSample(scene, scene_sampler, in.uv); + let f = 0.06711056 * in.position.x + 0.00583715 * in.position.y; + let noise = (fract(52.9829189 * fract(f)) - 0.5) * 0.95; + return vec4(color.rgb + noise / 255.0, color.a); +} +@fragment +fn fs_gamma(in: Vertex) -> @location(0) vec4 { + return sample_gamma(in); +} +@fragment +fn fs_srgb(in: Vertex) -> @location(0) vec4 { + let color = sample_gamma(in); + let low = color.rgb / vec3(12.92); + let high = pow((color.rgb + vec3(0.055)) / vec3(1.055), vec3(2.4)); + return vec4(select(high, low, color.rgb < vec3(0.04045)), color.a); +} diff --git a/src/render/workspace/tests.rs b/src/render/workspace/tests.rs index bbe17e2..793dd69 100644 --- a/src/render/workspace/tests.rs +++ b/src/render/workspace/tests.rs @@ -82,6 +82,7 @@ fn shared_frame_preserves_revisions_and_rejects_invalid_replacement() { &mut state, &mut output, previous_assets, + &mut crate::measurement::FrameProbe::default(), ); graphics.finish_frame(std::mem::take(&mut output.textures_delta.free)); assert_eq!( @@ -99,3 +100,200 @@ fn shared_frame_preserves_revisions_and_rejects_invalid_replacement() { }) .unwrap(); } + +#[cfg(feature = "viewport-measure")] +#[test] +fn scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize() { + let capture = pollster::block_on(crate::doc_capture::Capture::new(640, 480)).unwrap(); + let device = &capture.device; + let queue = &capture.queue; + let size = [640, 480]; + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/obj/cube-quads.obj"); + for format in [ + wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Rgba8UnormSrgb, + ] { + let (mut graphics, texture) = WorkspaceRenderer::new(device, format, size); + let mut state = WorkspaceUi::new(texture); + graphics + .install( + device, + queue, + &mut state, + fixture.clone(), + crate::asset_io::load(&fixture).unwrap(), + false, + ) + .unwrap(); + state.editor.selected_objects.clear(); + state.viewport = + egui::Rect::from_min_size(egui::pos2(20.0, 16.0), egui::vec2(220.0, 164.0)); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("scene presentation equivalence test"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let frame_target = || FrameTarget { + device, + queue, + view: &view, + size, + }; + let ctx = egui::Context::default(); + let mut probe = crate::measurement::FrameProbe::default(); + // A no-UI resize deliberately leaves egui's old texture registered. + // Re-entering normal paint must rebind it even though size is now stable. + for pixels_per_point in [1.0, 2.0] { + graphics.paint_without_ui( + frame_target(), + &mut state, + BTreeMap::new(), + pixels_per_point, + true, + &mut probe, + ); + assert!(graphics.scene_texture_dirty); + assert_eq!( + graphics.scene_size, + [ + (220.0 * pixels_per_point) as u32, + (164.0 * pixels_per_point) as u32 + ] + ); + let without_ui = read_pixels(device, queue, &target); + // The no-UI composite retains exactly the same scene rectangle. + assert_eq!(&without_ui[..4], &[0, 0, 0, 255]); + assert!( + without_ui + .as_chunks::<4>() + .0 + .iter() + .any(|pixel| *pixel != [0, 0, 0, 255]) + ); + + ctx.set_pixels_per_point(pixels_per_point); + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(size[0] as f32, size[1] as f32) / pixels_per_point, + )), + ..Default::default() + }, + |ui| { + ui.painter().image( + texture, + state.viewport, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + }, + ); + assert_eq!(output.pixels_per_point, pixels_per_point); + graphics.paint( + frame_target(), + &ctx, + &mut state, + &mut output, + BTreeMap::new(), + &mut probe, + ); + graphics.finish_frame(std::mem::take(&mut output.textures_delta.free)); + assert!(!graphics.scene_texture_dirty); + let with_egui = read_pixels(device, queue, &target); + assert!( + without_ui == with_egui, + "Scene composite pixels differ for {format:?} at {pixels_per_point} pixels/point" + ); + + // Renderer isolation must remove feedback left by an editor frame, + // without mutating the editor's selection or rebuilding geometry. + state.dispatch(crate::shortcuts::Command::SelectAll, &ctx, false); + graphics.paint_without_ui( + frame_target(), + &mut state, + BTreeMap::new(), + pixels_per_point, + true, + &mut probe, + ); + assert!(without_ui != read_pixels(device, queue, &target)); + let selected = state.editor.selected_objects.clone(); + let revision = state.mesh_revision; + graphics.paint_without_ui( + frame_target(), + &mut state, + BTreeMap::new(), + pixels_per_point, + false, + &mut probe, + ); + assert!( + without_ui == read_pixels(device, queue, &target), + "Renderer isolation retained selection feedback" + ); + assert_eq!(state.editor.selected_objects, selected); + assert_eq!(state.mesh_revision, revision); + assert_eq!(graphics.uploaded_revision, revision); + assert!(state.error.is_none(), "{:?}", state.error); + state.editor.selected_objects.clear(); + } + } +} + +#[cfg(feature = "viewport-measure")] +fn read_pixels(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture) -> Vec { + let size = texture.size(); + let stride = (size.width * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene presentation equivalence readback"), + size: u64::from(stride) * u64::from(size.height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(stride), + rows_per_image: Some(size.height), + }, + }, + size, + ); + let submission = queue.submit([encoder.finish()]); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| tx.send(result).unwrap()); + device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: Some(std::time::Duration::from_secs(30)), + }) + .unwrap(); + rx.recv_timeout(std::time::Duration::from_secs(1)) + .unwrap() + .unwrap(); + buffer + .slice(..) + .get_mapped_range() + .unwrap() + .chunks_exact(stride as usize) + .flat_map(|row| row[..size.width as usize * 4].iter().copied()) + .collect() +} diff --git a/src/ui/action_state.rs b/src/ui/action_state.rs index 5872564..75b0fb9 100644 --- a/src/ui/action_state.rs +++ b/src/ui/action_state.rs @@ -14,6 +14,10 @@ impl WorkspaceUi { New | Open => idle, Import | Save | SaveAs => idle && self.editor.can_edit(), Preferences => true, + ToggleFpsMeter => { + state.checked = Some(self.fps_meter.enabled()); + true + } AnimationPanel => { state.checked = Some(self.animation_panel_is_open()); !self.editor.is_interacting() && !self.mouse_navigation.wants_input() diff --git a/src/ui/controls.rs b/src/ui/controls.rs index 1068b10..3955e93 100644 --- a/src/ui/controls.rs +++ b/src/ui/controls.rs @@ -43,6 +43,9 @@ pub enum Control { Frame, Projection, ViewMenu, + DeveloperMenu, + ShowFpsMeter, + FpsMeter, LocalViewMenu, Xray, ViewPie, @@ -223,6 +226,9 @@ impl Control { Self::Frame => "frame", Self::Projection => "projection", Self::ViewMenu => "view", + Self::DeveloperMenu => "view.developer", + Self::ShowFpsMeter => "view.fps-meter.toggle", + Self::FpsMeter => "developer.fps-meter", Self::LocalViewMenu => "view.local-view", Self::Xray => "view.xray", Self::ViewPie => "view-pie", @@ -402,6 +408,9 @@ impl Control { Self::Frame => "Frame", Self::Projection => "Projection", Self::ViewMenu => "View", + Self::DeveloperMenu => "Developer", + Self::ShowFpsMeter => crate::input::actions::ActionId::ToggleFpsMeter.label(), + Self::FpsMeter => "App FPS Meter", Self::LocalViewMenu => crate::input::actions::ActionId::LocalView.label(), Self::Xray => crate::input::actions::ActionId::Xray.label(), Self::ViewPie => "View", diff --git a/src/ui/controls/trace.rs b/src/ui/controls/trace.rs index 9cba29b..beb64f6 100644 --- a/src/ui/controls/trace.rs +++ b/src/ui/controls/trace.rs @@ -6,7 +6,7 @@ use std::{ }; impl Control { - pub const ALL: [Self; 175] = [ + pub const ALL: [Self; 178] = [ Self::ToolDock, Self::TerminalPanelToggle, Self::TerminalPanel, @@ -37,6 +37,9 @@ impl Control { Self::Frame, Self::Projection, Self::ViewMenu, + Self::DeveloperMenu, + Self::ShowFpsMeter, + Self::FpsMeter, Self::LocalViewMenu, Self::Xray, Self::ViewPie, diff --git a/src/ui/fps_meter.rs b/src/ui/fps_meter.rs new file mode 100644 index 0000000..8c96af4 --- /dev/null +++ b/src/ui/fps_meter.rs @@ -0,0 +1,123 @@ +//! Passive application-window readout. Collection belongs to the shared frame meter; +//! this painter adds neither input ownership nor an independent repaint loop. +use super::*; +use crate::frame_meter::Sample; + +struct Content { + sample: Option, + labels: [String; 3], + label: String, +} + +#[derive(Default)] +pub(super) struct Display { + content: Option, +} + +impl WorkspaceUi { + pub(super) fn paint_fps_meter(&mut self, ctx: &egui::Context) { + if !self.show_ui || !self.fps_meter.enabled() { + return; + } + let window = ctx.viewport_rect(); + // A painter layer has no Area or response: ordinary controls retain + // pointer ownership even when this diagnostic paints over them. + let painter = ctx + .layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("n3.fps-meter"), + )) + .with_clip_rect(window); + let sample = self.fps_meter.sample(); + if self + .fps_meter_display + .content + .as_ref() + .map(|content| content.sample) + != Some(sample) + { + let labels = [ + "App FPS · last sample".to_owned(), + sample.map_or_else(|| "Waiting for frames".to_owned(), sample_label), + sample.map_or_else( + || "Updates with app frames".to_owned(), + |sample| format!("Measured over {:.2} s", sample.elapsed().as_secs_f64()), + ), + ]; + let label = labels.join("\n"); + self.fps_meter_display.content = Some(Content { + sample, + labels, + label, + }); + } + let content = self.fps_meter_display.content.as_ref().unwrap(); + // Reuse egui's font/layout cache rather than retaining atlas-dependent + // galleys across font resets and scale changes. Number formatting only + // happens when the completed sample changes. + let lines: [_; 3] = std::array::from_fn(|index| { + painter.layout_no_wrap( + content.labels[index].clone(), + egui::FontId::monospace(theme::text::XS), + if index == 1 { + egui::Color32::WHITE + } else { + egui::Color32::from_gray(176) + }, + ) + }); + let size = egui::vec2( + lines.iter().map(|line| line.size().x).fold(0.0, f32::max), + lines.iter().map(|line| line.size().y).sum::() + theme::space::XS * 2.0, + ) + egui::Vec2::splat(theme::space::MD * 2.0); + let rect = egui::Rect::from_min_size(window.right_bottom() - size, size); + // Developer instrumentation deliberately uses a plain black readout in + // both themes, separate from the editor's ordinary surface styling. + painter.rect_filled(rect, egui::CornerRadius::ZERO, egui::Color32::BLACK); + let mut y = rect.top() + theme::space::MD; + for (index, line) in lines.iter().enumerate() { + painter.galley( + egui::pos2(rect.left() + theme::space::MD, y), + line.clone(), + if index == 1 { + egui::Color32::WHITE + } else { + egui::Color32::from_gray(176) + }, + ); + y += line.size().y + theme::space::XS; + } + controls::record(ctx, Control::FpsMeter, &content.label, rect, true); + } +} + +fn sample_label(sample: Sample) -> String { + let rate = if sample.fps() < 0.1 { + "<0.1".to_owned() + } else { + format!("{:.1}", sample.fps()) + }; + format!("{rate} FPS · {:.2} ms", sample.mean_interval_ms()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sparse_redraw_sample_does_not_display_false_zero_fps() { + let mut meter = crate::frame_meter::FrameMeter::default(); + meter.toggle(); + meter.record_submission(Duration::ZERO); + meter.record_submission(Duration::from_secs(20)); + assert_eq!( + sample_label(meter.sample().unwrap()), + "<0.1 FPS · 20000.00 ms" + ); + meter.record_submission(Duration::from_secs(30)); + assert_eq!( + sample_label(meter.sample().unwrap()), + "0.1 FPS · 10000.00 ms" + ); + } +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 5babeb1..6a62c4f 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -344,6 +344,7 @@ fn control_for_action(action: ActionId) -> Control { ActionId::Xray => Control::Xray, ActionId::Ruler2D => Control::Ruler2DViewToggle, ActionId::Edges => Control::Edges, + ActionId::ToggleFpsMeter => Control::ShowFpsMeter, ActionId::ViewPerspective => Control::ViewPerspective, ActionId::ViewFront => Control::ViewFront, ActionId::ViewRight => Control::ViewRight, diff --git a/src/ui/workspace_ui.rs b/src/ui/workspace_ui.rs index 43463e5..8b78ba8 100644 --- a/src/ui/workspace_ui.rs +++ b/src/ui/workspace_ui.rs @@ -48,6 +48,8 @@ mod action_state; mod animation_panel; #[path = "asset_instances.rs"] mod asset_instances; +#[path = "fps_meter.rs"] +mod fps_meter; #[path = "tool_dock.rs"] mod tool_dock; #[path = "user_settings.rs"] @@ -435,6 +437,8 @@ pub struct WorkspaceUi { pub show_2d_ruler: bool, pub show_grid: bool, pub show_ui: bool, + pub(crate) fps_meter: crate::frame_meter::FrameMeter, + fps_meter_display: fps_meter::Display, pub show_preferences: bool, preferences_focus_pending: bool, pub settings_error: Option, @@ -523,6 +527,8 @@ impl WorkspaceUi { show_2d_ruler: true, show_grid: true, show_ui: true, + fps_meter: Default::default(), + fps_meter_display: Default::default(), show_preferences: false, preferences_focus_pending: false, settings_error: None, @@ -1408,6 +1414,12 @@ impl WorkspaceUi { ctx.memory_mut(|memory| memory.request_focus(shortcuts::viewport_focus_id())); ctx.request_repaint(); } + Command::ToggleFpsMeter => { + self.fps_meter.toggle(); + // The command changes visible UI once; the meter itself never + // schedules a frame. Recording follows ordinary host redraws. + ctx.request_repaint(); + } Command::SelectAll => { self.cancel_mouse_navigation(); let result = self @@ -1987,6 +1999,10 @@ impl WorkspaceUi { for &action in crate::ui::workspace_menus::VIEW_TOGGLE_ACTIONS { self.menu_action(ui, action, None); } + menu::separator(ui); + menu::submenu(ui, Control::DeveloperMenu, |ui| { + self.menu_action(ui, ActionId::ToggleFpsMeter, None); + }); }); }); menu::separator(ui); @@ -2606,6 +2622,7 @@ impl WorkspaceUi { self.error = Some(e) } menu::finish_frame(ctx); + self.paint_fps_meter(ctx); } fn toasts_available(&self, ctx: &egui::Context) -> bool { diff --git a/src/ui/workspace_ui_tests.rs b/src/ui/workspace_ui_tests.rs index 749725e..d28d73b 100644 --- a/src/ui/workspace_ui_tests.rs +++ b/src/ui/workspace_ui_tests.rs @@ -1724,3 +1724,246 @@ fn linked_scene_evaluation_failure_preserves_native_and_valid_asset_siblings() { state.editor.select_object(native).unwrap(); assert!(state.editor.enter_edit().unwrap()); } + +#[test] +fn fps_meter_toggle_is_transient_unbound_and_preserves_history() { + let ctx = test_context(); + let mut state = WorkspaceUi::new(egui::TextureId::User(0)); + assert!(!state.fps_meter.enabled()); + assert_eq!( + state.action_state(ActionId::ToggleFpsMeter).checked, + Some(false) + ); + assert!(ActionId::ToggleFpsMeter.shortcut().is_none()); + state.editor.insert(PrimitiveKind::Cube).unwrap(); + let before = state.editor.document.clone(); + let selected = state.editor.selected_objects.clone(); + let settings = state.user_settings(); + state.dispatch(Command::ToggleFpsMeter, &ctx, false); + assert!(state.fps_meter.enabled()); + assert_eq!( + state.action_state(ActionId::ToggleFpsMeter).checked, + Some(true) + ); + assert_eq!(state.editor.document, before); + assert_eq!(state.editor.selected_objects, selected); + assert_eq!(state.user_settings(), settings); + state.dispatch(Command::ToggleFpsMeter, &ctx, false); + assert!(!state.fps_meter.enabled()); + assert!(state.editor.undo()); + assert!(state.editor.document.objects.is_empty()); + assert!(!state.editor.undo()); + assert!(state.editor.redo()); + assert_eq!(state.editor.document, before); +} + +#[test] +fn fps_meter_paints_real_sample_and_leaves_viewport_clicks_available() { + for theme in [ThemeMode::Light, ThemeMode::Dark] { + let ctx = test_context(); + controls::enable(&ctx); + let mut state = WorkspaceUi::new(egui::TextureId::User(0)); + state.theme_mode = theme; + state.editor.insert(PrimitiveKind::Cube).unwrap(); + state.fps_meter.toggle(); + layout_test_frame(&mut state, &ctx, Vec::new()); + let waiting = controls::snapshot(&ctx) + .get(Control::FpsMeter) + .unwrap() + .label + .clone(); + assert!(waiting.contains("Waiting for frames")); + for milliseconds in (0..=500).step_by(10) { + state + .fps_meter + .record_submission(Duration::from_millis(milliseconds)); + } + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1600.0, 900.0), + )), + focused: true, + ..Default::default() + }, + |ui| state.ui(ui), + ); + output.textures_delta.clear(); + let trace = controls::snapshot(&ctx); + let meter = trace.get(Control::FpsMeter).unwrap(); + assert!( + output + .shapes + .iter() + .any(|shape| matches!(&shape.shape, egui::Shape::Rect(shape) + if shape.rect == meter.rect + && shape.fill == egui::Color32::BLACK + && shape.corner_radius == egui::CornerRadius::ZERO + && shape.stroke == egui::Stroke::NONE)) + ); + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) + if text.galley.job.text == "100.0 FPS · 10.00 ms" + && text.galley.job.sections.iter().all(|section| section.format.font_id.family == egui::FontFamily::Monospace)))); + assert_eq!( + meter.label, + "App FPS · last sample\n100.0 FPS · 10.00 ms\nMeasured over 0.50 s" + ); + assert_eq!( + meter.rect.right_bottom(), + ctx.viewport_rect().right_bottom() + ); + assert!(!state.viewport_ui_rect.contains_rect(meter.rect)); + assert!(output.shapes.iter().any(|shape| shape.clip_rect == ctx.viewport_rect() + && matches!(&shape.shape, egui::Shape::Rect(shape) if shape.rect == meter.rect))); + let point = state.viewport_ui_rect.right_bottom() - egui::Vec2::splat(24.0); + assert!(crate::navigation_events::viewport_accepts_pointer( + &ctx, + state.viewport, + point + )); + assert!(!state.editor.selected_objects.is_empty()); + for pressed in [true, false] { + layout_test_frame( + &mut state, + &ctx, + vec![ + egui::Event::PointerMoved(point), + egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + } + assert!( + state.editor.selected_objects.is_empty(), + "the diagnostic must leave viewport selection routing unchanged" + ); + assert_eq!( + ctx.memory(|memory| memory.focused()), + Some(shortcuts::viewport_focus_id()) + ); + state.dispatch(Command::ToggleUi, &ctx, false); + layout_test_frame(&mut state, &ctx, Vec::new()); + assert!(state.fps_meter.enabled()); + assert!(controls::snapshot(&ctx).get(Control::FpsMeter).is_err()); + } +} + +#[test] +fn fps_meter_stays_at_window_corner_across_app_layout_and_adds_no_idle_repaint() { + let run = |state: &mut WorkspaceUi, ctx: &egui::Context, time| { + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1160.0, 900.0), + )), + time: Some(time), + focused: true, + ..Default::default() + }, + |ui| state.ui(ui), + ); + output.textures_delta.clear(); + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay + }; + let ctx = test_context(); + controls::enable(&ctx); + let mut state = WorkspaceUi::new(egui::TextureId::User(0)); + state.fps_meter.toggle(); + for frame in 0..10 { + run(&mut state, &ctx, frame as f64); + } + assert!(controls::snapshot(&ctx).get(Control::FpsMeter).is_ok()); + let before = ctx.cumulative_pass_nr(); + let delay = run(&mut state, &ctx, 11.0); + assert_eq!(ctx.cumulative_pass_nr() - before, 1); + assert_eq!( + delay, + Duration::MAX, + "a passive meter must leave idle redraws unscheduled" + ); + let rect = controls::snapshot(&ctx) + .get(Control::FpsMeter) + .unwrap() + .rect; + let viewport = state.viewport; + state + .toasts + .push(Toast::new("A notification does not move diagnostics").persistent()); + state.dispatch(Command::ToggleAnimationPanel, &ctx, false); + run(&mut state, &ctx, 12.0); + run(&mut state, &ctx, 13.0); + let trace = controls::snapshot(&ctx); + assert!(trace.get(Control::ToastStack).is_ok()); + assert_ne!(state.viewport, viewport); + assert_eq!(trace.get(Control::FpsMeter).unwrap().rect, rect); + assert_eq!(rect.right_bottom(), ctx.viewport_rect().right_bottom()); +} + +#[test] +fn fps_meter_paints_over_app_widgets_without_taking_their_input() { + let ctx = test_context(); + controls::enable(&ctx); + let mut state = WorkspaceUi::new(egui::TextureId::User(0)); + state.fps_meter.toggle(); + let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let button_rect = egui::Rect::from_min_size(egui::pos2(430.0, 350.0), egui::vec2(170.0, 50.0)); + let mut clicks = 0; + let mut frame = |events| { + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(window), + focused: true, + events, + ..Default::default() + }, + |ui| { + controls::begin_pass(ui.ctx()); + if ui + .put(button_rect, egui::Button::new("Underlying app button")) + .clicked() + { + clicks += 1; + } + state.paint_fps_meter(ui.ctx()); + }, + ); + output.textures_delta.clear(); + output + }; + frame(Vec::new()); + let output = frame(Vec::new()); + let meter = controls::snapshot(&ctx) + .get(Control::FpsMeter) + .unwrap() + .rect; + let point = meter.intersect(button_rect).center(); + assert!(meter.contains(point) && button_rect.contains(point)); + assert_eq!(ctx.layer_id_at(point), Some(egui::LayerId::background())); + let meter_shape = output.shapes.iter().position(|shape| matches!(&shape.shape, egui::Shape::Rect(shape) if shape.rect == meter && shape.fill == egui::Color32::BLACK)).unwrap(); + let button_text = output.shapes.iter().position(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.job.text == "Underlying app button")).unwrap(); + assert!( + meter_shape > button_text, + "diagnostic paints above the existing UI" + ); + for pressed in [true, false] { + frame(vec![ + egui::Event::PointerMoved(point), + egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }, + ]); + } + assert_eq!( + clicks, 1, + "the visible diagnostic must let the underlying button receive the click" + ); +} diff --git a/src/web.rs b/src/web.rs index ac9babf..33fff0a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -23,9 +23,15 @@ use winit::{ }; mod gestures; +#[cfg(feature = "viewport-measure")] +mod measurement; +#[cfg(not(feature = "viewport-measure"))] +#[path = "web/measurement_disabled.rs"] +mod measurement; mod settings; mod settings_events; mod status; +use crate::measurement::Stage; const SETTINGS_KEY: &str = "n3.settings.v1"; struct BrowserSettingsStore; @@ -89,11 +95,13 @@ struct WebWindow { webkit_gesture: crate::input::browser_navigation::WebKitGesture, webkit_gesture_owned: bool, last_camera_tick: Instant, + frame_clock: Instant, next_repaint: Option, settings: settings::BrowserSettings, settings_clock: Instant, frames: u64, status: status::StatusCache, + measurement: measurement::Host, } impl WebWindow { async fn new(window: Arc, proxy: EventLoopProxy) -> Result { @@ -171,6 +179,7 @@ impl WebWindow { Some("Preferences are stored in this browser for this site.".into()); let settings_defaults = state.user_settings(); let settings = settings::BrowserSettings::new(BrowserSettingsStore, settings_defaults)?; + let measurement = measurement::Host::new(&window, &adapter.get_info()); let mut native = Self { instance, window, @@ -188,11 +197,13 @@ impl WebWindow { webkit_gesture: Default::default(), webkit_gesture_owned: false, last_camera_tick: Instant::now(), + frame_clock: Instant::now(), next_repaint: None, settings, settings_clock: Instant::now(), frames: 0, status: status::StatusCache::default(), + measurement, }; native.service_settings(true); // Creating the canvas can focus it before async WebGPU setup completes. @@ -223,6 +234,9 @@ impl WebWindow { ) } fn navigate(&mut self, event: navigation_events::Event) { + if self.measurement.isolated() { + return; + } navigation_events::route( &mut self.state, &self.context, @@ -339,6 +353,7 @@ impl WebWindow { if self.window.inner_size().width == 0 || self.window.inner_size().height == 0 { return; } + let mut probe = self.measurement.begin_frame(&mut self.state); let (frame, reconfigure_after_present) = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) => (frame, false), wgpu::CurrentSurfaceTexture::Suboptimal(frame) => (frame, true), @@ -376,71 +391,117 @@ impl WebWindow { return; } }; - let now = Instant::now(); - self.state - .camera - .advance_transition(now.duration_since(self.last_camera_tick)); - self.last_camera_tick = now; - // A pose or scene may pass CPU validation yet exceed this GPU's limits. - // Keep cheap Arc-backed state until this redraw's candidate is prepared. + probe.end(Stage::Acquire); + // Preserve the candidate rollback snapshot across either frame path. let previous_assets = self.state.asset_views.clone(); - let input = self.input.take_egui_input(&self.window); let ctx = self.context.clone(); - let mut shortcuts = ShortcutFrame::with_number_events(&ctx, self.number_keys.take()); - let mut output = ctx.run_ui(input, |ui| { - let context = ui.ctx().clone(); - let ctx = &context; - shortcuts.begin_pass(ctx); - self.state.ui(ui); - shortcuts - .collect_with_transform(ctx, self.state.transform_keyboard_context(ctx, false)); - if self.state.camera.is_transitioning() { - ctx.request_repaint(); + let mut output = if self.measurement.without_ui() { + // Drain host events without entering egui's frame/layout machinery. + let _ = self.input.take_egui_input(&self.window); + self.number_keys.reset(); + self.measurement + .prepare_without_ui(&mut self.state, &mut probe); + None + } else { + let now = Instant::now(); + self.state + .camera + .advance_transition(now.duration_since(self.last_camera_tick)); + self.last_camera_tick = now; + let mut input = self.input.take_egui_input(&self.window); + if self.measurement.isolated() { + input.events.clear(); + input.events.push(egui::Event::PointerGone); + self.number_keys.reset(); } - }); - for command in self - .state - .take_ui_commands() - .into_iter() - .chain(shortcuts.commands()) - { - self.dispatch(command); - self.window.request_redraw(); - if egui::Popup::is_any_open(&ctx) { - // A keyboard-opened menu owns the rest of this input batch. - break; + let mut shortcuts = ShortcutFrame::with_number_events(&ctx, self.number_keys.take()); + let mut output = ctx.run_ui(input, |ui| { + let context = ui.ctx().clone(); + let ctx = &context; + probe.egui_pass(); + shortcuts.begin_pass(ctx); + self.state.ui(ui); + shortcuts + .collect_with_transform(ctx, self.state.transform_keyboard_context(ctx, false)); + if self.state.camera.is_transitioning() { + ctx.request_repaint(); + } + }); + probe.end(Stage::Ui); + for command in self + .state + .take_ui_commands() + .into_iter() + .chain(shortcuts.commands()) + { + self.dispatch(command); + self.window.request_redraw(); + if egui::Popup::is_any_open(&ctx) { + // A keyboard-opened menu owns the rest of this input batch. + break; + } } - } - if let Err(error) = self.state.refresh_mesh() { - self.state.error = Some(error); - } - self.input - .handle_platform_output(&self.window, std::mem::take(&mut output.platform_output)); + if let Err(error) = self.state.refresh_mesh() { + self.state.error = Some(error); + } + probe.end(Stage::Commands); + self.input + .handle_platform_output(&self.window, std::mem::take(&mut output.platform_output)); + Some(output) + }; let view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); - self.graphics.paint( - FrameTarget { - device: &self.device, - queue: &self.queue, - view: &view, - size: [self.config.width, self.config.height], - }, - &ctx, - &mut self.state, - &mut output, - previous_assets, - ); + probe.end(Stage::HostPrepare); + if let Some(output) = &mut output { + self.graphics.paint( + FrameTarget { + device: &self.device, + queue: &self.queue, + view: &view, + size: [self.config.width, self.config.height], + }, + &ctx, + &mut self.state, + output, + previous_assets, + &mut probe, + ); + } else { + self.measurement.paint_without_ui( + &mut self.graphics, + FrameTarget { + device: &self.device, + queue: &self.queue, + view: &view, + size: [self.config.width, self.config.height], + }, + &mut self.state, + previous_assets, + egui_winit::pixels_per_point(&ctx, &self.window), + &mut probe, + ); + } self.window.pre_present_notify(); self.queue.present(frame); - self.graphics - .finish_frame(std::mem::take(&mut output.textures_delta.free)); + // Use the browser's monotonic clock only when enabled, once per handoff. + // This is app submission cadence, not GPU completion or display timing. + if self.state.fps_meter.enabled() { + self.state + .fps_meter + .record_submission(self.frame_clock.elapsed()); + } + if let Some(output) = &mut output { + self.graphics + .finish_frame(std::mem::take(&mut output.textures_delta.free)); + } + probe.end(Stage::Present); if reconfigure_after_present { self.resize(); } let delay = output - .viewport_output - .get(&egui::ViewportId::ROOT) + .as_ref() + .and_then(|output| output.viewport_output.get(&egui::ViewportId::ROOT)) .map(|v| v.repaint_delay) .unwrap_or(Duration::MAX); self.next_repaint = Instant::now().checked_add(delay); @@ -461,6 +522,12 @@ impl WebWindow { if let Some(snapshot) = self.status.update(status) { self.emit("n3-state", &snapshot); } + probe.end(Stage::HostTail); + self.measurement + .finish_frame(probe, &self.state, self.window.has_focus()); + if self.measurement.active() { + self.window.request_redraw(); + } } fn emit(&self, name: &str, detail: &str) { @@ -495,6 +562,9 @@ impl WebWindow { } fn service_settings(&mut self, force: bool) { + if self.measurement.isolated() { + return; + } let reload = self.state.request_reload_settings; let export = self.state.request_open_settings; if force || export { @@ -728,6 +798,9 @@ impl WebApp { } pub fn settings_json(&self) -> Result { self.with_mut(|workspace| { + if workspace.measurement.isolated() { + return Err("Preference storage is disabled in the measurement harness".into()); + } if !workspace.settings_ready_for_sync() { return Err( "Finish the active input or edit, then retry exporting preferences.".into(), diff --git a/src/web/measurement.rs b/src/web/measurement.rs new file mode 100644 index 0000000..ca5b1d7 --- /dev/null +++ b/src/web/measurement.rs @@ -0,0 +1,115 @@ +//! Contributor-only browser collection, isolated from origin preferences. +use super::*; +use crate::measurement::{FrameProbe, Options, Session}; +use serde_json::{Value, json}; + +pub(super) struct Host { + isolated: bool, + session: Option, + result: Option, + adapter: Value, +} +impl Host { + pub(super) fn new(window: &Window, adapter: &wgpu::AdapterInfo) -> Self { + Self { + isolated: window.canvas().is_some_and(|canvas| { + canvas.get_attribute("data-n3-measure").as_deref() == Some("true") + }), + session: None, + result: None, + adapter: json!({"name": adapter.name, "vendor": adapter.vendor, "device": adapter.device, "device_type": format!("{:?}", adapter.device_type), "driver": adapter.driver, "driver_info": adapter.driver_info, "backend": format!("{:?}", adapter.backend)}), + } + } + pub(super) fn isolated(&self) -> bool { + self.isolated + } + pub(super) fn active(&self) -> bool { + self.session.as_ref().is_some_and(Session::active) + } + pub(super) fn without_ui(&self) -> bool { + self.session.as_ref().is_some_and(Session::without_ui) + } + + pub(super) fn prepare_without_ui(&self, state: &mut WorkspaceUi, probe: &mut FrameProbe) { + if let Some(session) = &self.session { + session.prepare_without_ui(state, probe); + } + } + + pub(super) fn paint_without_ui( + &self, + graphics: &mut crate::render::workspace::WorkspaceRenderer, + target: crate::render::workspace::FrameTarget<'_>, + state: &mut WorkspaceUi, + previous_assets: std::collections::BTreeMap< + crate::document::AssetInstance, + crate::scene_view::SceneView, + >, + pixels_per_point: f32, + probe: &mut FrameProbe, + ) { + let session = self.session.as_ref().expect("active no-UI measurement"); + graphics.paint_without_ui( + target, + state, + previous_assets, + pixels_per_point, + session.mode() == crate::measurement::ExecutionMode::Viewport, + probe, + ); + } + + pub(super) fn begin_frame(&mut self, state: &mut WorkspaceUi) -> FrameProbe { + self.session + .as_mut() + .map(|session| session.begin_frame(state)) + .unwrap_or_default() + } + pub(super) fn finish_frame(&mut self, probe: FrameProbe, state: &WorkspaceUi, focused: bool) { + if let Some(session) = &mut self.session + && let Some(result) = session.finish_frame(probe, state, focused) + { + self.result = Some(result); + } + } +} +#[wasm_bindgen] +impl WebApp { + /// Available only in the contributor feature, on an isolated harness canvas. + pub fn measure_start(&self, options_json: &str, metadata_json: &str) -> Result<(), JsValue> { + self.with_mut(|workspace| { + if !workspace.measurement.isolated || workspace.measurement.active() { + return Err("Use an idle contributor measurement canvas".into()); + } + let options: Options = serde_json::from_str(options_json).map_err(|error| error.to_string())?; + options.validate()?; + if workspace.state.loading.is_some() + || workspace.state.mesh.is_none() + || !workspace.state.viewport.is_finite() + || !workspace.state.viewport.is_positive() + || !workspace.state.document_action_allowed() + { + return Err("Wait for a loaded, laid-out viewport without an active edit".into()); + } + if let Some(error) = &workspace.state.error { + return Err(error.clone()); + } + let mut metadata: Value = serde_json::from_str(metadata_json).map_err(|error| error.to_string())?; + if !metadata.is_object() { return Err("Measurement metadata must be an object".into()); } + metadata["host"] = json!("web"); + metadata["adapter"] = workspace.measurement.adapter.clone(); + metadata["surface"] = json!({"width": workspace.config.width, "height": workspace.config.height, "scale_factor": workspace.window.scale_factor(), "format": format!("{:?}", workspace.config.format), "present_mode": format!("{:?}", workspace.config.present_mode), "desired_maximum_frame_latency": workspace.config.desired_maximum_frame_latency, "alpha_mode": format!("{:?}", workspace.config.alpha_mode)}); + workspace.reset_input(); + let session = Session::new(options, metadata, &mut workspace.state)?; + workspace.measurement.session = Some(session); + workspace.measurement.result = None; + workspace.window.request_redraw(); + Ok(()) + }) + } + pub fn measure_result(&self) -> Result { + self.with_mut(|workspace| { + serde_json::to_string(&workspace.measurement.result).map_err(|error| error.to_string()) + }) + } +} diff --git a/src/web/measurement_disabled.rs b/src/web/measurement_disabled.rs new file mode 100644 index 0000000..8845a12 --- /dev/null +++ b/src/web/measurement_disabled.rs @@ -0,0 +1,38 @@ +use crate::{measurement::FrameProbe, workspace_ui::WorkspaceUi}; +pub(super) struct Host; +impl Host { + pub(super) fn new(_: &winit::window::Window, _: &wgpu::AdapterInfo) -> Self { + Self + } + pub(super) fn isolated(&self) -> bool { + false + } + pub(super) fn active(&self) -> bool { + false + } + #[inline(always)] + pub(super) fn without_ui(&self) -> bool { + false + } + + #[inline(always)] + pub(super) fn prepare_without_ui(&self, _: &mut WorkspaceUi, _: &mut FrameProbe) {} + + #[inline(always)] + pub(super) fn paint_without_ui( + &self, + _: &mut crate::render::workspace::WorkspaceRenderer, + _: crate::render::workspace::FrameTarget<'_>, + _: &mut WorkspaceUi, + _: std::collections::BTreeMap, + _: f32, + _: &mut FrameProbe, + ) { + unreachable!("measurement mode is disabled") + } + + pub(super) fn begin_frame(&mut self, _: &mut WorkspaceUi) -> FrameProbe { + FrameProbe::default() + } + pub(super) fn finish_frame(&mut self, _: FrameProbe, _: &WorkspaceUi, _: bool) {} +} diff --git a/tools/benchmark/README.md b/tools/benchmark/README.md new file mode 100644 index 0000000..525e466 --- /dev/null +++ b/tools/benchmark/README.md @@ -0,0 +1,69 @@ +# Benchmark tooling + +This is N3's application-specific measurement tooling, not an adopted benchmark +framework. Playwright owns isolated browser automation. Python's standard library +owns orchestration and descriptive statistics. Rust `libtest`, Python `unittest`, +and Node's built-in test runner verify the tooling itself. + +Use the root commands from the repository root: + +```sh +just measure-viewport --help +just benchmark-viewport --help +just benchmark-test +``` + +The Python entry points are `python3 -m tools.benchmark.measure` for one capture +and `python3 -m tools.benchmark` for repeated runs, offline reports, and comparisons. +The [measurement guide](../../docs/architecture/viewport-measurement.md) owns usage, +workload definitions, methodology, and report interpretation. + +## Ownership + +| Location | Responsibility | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `measure.py` | Build one host, record source/environment metadata, launch a bounded capture, and verify process completion | +| `transport.py` | Local capability server, bounded capture envelopes, explicit file allowlist, and exclusive report writes | +| `suite.py` | Build snapshots, fresh serial repetitions, order, lifecycle receipts, and source/artifact stability | +| `contracts.py` | Admit complete raw evidence, independently check observed facts, enforce compatibility, and validate suite receipts | +| `statistics.py` | Pure within-run distributions and descriptive aggregates across runs | +| `reports.py` | Group admitted runs, preserve exclusions, compare compatible cases, and render JSON/text summaries | +| `browser/` | Contributor page, environment observations, isolated Playwright process, and runner policy | +| `tests/` | Numeric oracles, invalid-evidence mutations, lifecycle/transport tests, CLI checks, and browser policy tests | +| `tests/fixtures/` | Reviewed measurement wire-contract fixtures; these are test data, not performance baselines | +| [`src/measurement/`](../../src/measurement/) | Feature-gated in-process Rust recorder, mode contract, stage/counter serialization, and deterministic recorder tests | + +The product wrapper stays in [`web/`](../../web/). Benchmark pages are physically +outside that directory and cannot enter the ordinary web bundle. Selected 3D assets +and their provenance stay in [`fixtures/benchmarks/`](../../fixtures/benchmarks/). +Builds, copied artifacts, reports, and logs stay in ignored output directories. + +## Trust boundaries + +Saving a transport envelope does not establish that its performance evidence is +valid. Raw records pass through `contracts.py` before aggregation. Claimed validity +flags cannot override contradictory counters, mode stages, dimensions, focus, or +visibility observations. Contamination remains visible; slow samples are retained. + +Tests use explicit numeric expectations and adversarial records. They must not +derive every expected field or answer from the validator under test. The shared +wire-contract fixture is checked against the actual Rust serializer and Python +admission schema, so a producer/consumer naming change fails visibly. Change that +fixture only with a deliberate contract review; never regenerate it silently. + +`just benchmark-tools-test` runs Python and Node checks without a browser or GPU. +`just benchmark-rust-test` checks recorder semantics and exact real-renderer pixel +parity for scene-only composition. `just benchmark-test` runs both. These checks +are included in `just verify` and the CI checks lane. They verify correctness, not +a timing threshold; ordinary verification never installs or launches Chromium. + +`just benchmark-web-check` separately compiles and lints the optional browser +recorder. The WASM CI lane runs it alongside the ordinary browser build, without +requiring Chromium or GPU access. Fixture tests verify the pinned Chess sources +and exact GLB reproduction without rewriting them. + +Real native/browser captures remain an explicit integration check after changes +to collection, launch, packaging, or host instrumentation. Use a short bounded +matrix to verify those paths, then repeat representative workloads when making a +performance claim. Passing unit tests does not prove display latency, input feel, +GPU completion time, or statistical significance. diff --git a/tools/benchmark/__init__.py b/tools/benchmark/__init__.py new file mode 100644 index 0000000..721f8b2 --- /dev/null +++ b/tools/benchmark/__init__.py @@ -0,0 +1 @@ +"""N3 real-host benchmark collection, orchestration, and evidence analysis.""" diff --git a/tools/benchmark/__main__.py b/tools/benchmark/__main__.py new file mode 100644 index 0000000..340d012 --- /dev/null +++ b/tools/benchmark/__main__.py @@ -0,0 +1,6 @@ +"""Run, aggregate, or compare a viewport benchmark suite.""" + +from .suite import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/benchmark/browser/measure.html b/tools/benchmark/browser/measure.html new file mode 100644 index 0000000..47c4ef2 --- /dev/null +++ b/tools/benchmark/browser/measure.html @@ -0,0 +1,32 @@ + + + + + + + N3 viewport measurement + + + +
+ + Loading contributor measurement harness… +
+ +

+    
+  
+
diff --git a/tools/benchmark/browser/measure.js b/tools/benchmark/browser/measure.js
new file mode 100644
index 0000000..7d91c4b
--- /dev/null
+++ b/tools/benchmark/browser/measure.js
@@ -0,0 +1,143 @@
+// Contributor harness: the feature-gated Rust host owns the actual workload.
+import init, { start } from "./pkg/n3.js";
+import { observeRun } from "./observations.mjs";
+
+const canvas = document.querySelector("#editor");
+const button = document.querySelector("#run");
+const status = document.querySelector("#status");
+const result = document.querySelector("#result");
+const sleep = (milliseconds) =>
+  new Promise((resolve) => setTimeout(resolve, milliseconds));
+let app;
+let config;
+let failure;
+let loading;
+const fail = (error) => {
+  failure = String(error);
+  console.error(`N3 measurement failed: ${failure}`);
+  status.textContent = `Measurement failed: ${failure}`;
+  button.disabled = true;
+};
+canvas.addEventListener("n3-error", (event) => fail(event.detail));
+
+async function setup() {
+  if (!navigator.gpu || !window.isSecureContext) {
+    throw new Error("A WebGPU-capable browser on localhost is required");
+  }
+  const response = await fetch("./config.json", { cache: "no-store" });
+  if (!response.ok)
+    throw new Error("Run this page through python3 -m tools.benchmark.measure");
+  config = await response.json();
+  const ratio = window.devicePixelRatio;
+  canvas.style.width = `${config.width / ratio}px`;
+  canvas.style.height = `${config.height / ratio}px`;
+  await init();
+  await new Promise((resolve, reject) => {
+    canvas.addEventListener("n3-ready", resolve, { once: true });
+    canvas.addEventListener(
+      "n3-error",
+      (event) => reject(new Error(event.detail)),
+      { once: true },
+    );
+    app = start(canvas);
+  });
+  app.resize(config.width / ratio, config.height / ratio);
+  const fetchStart = performance.now();
+  const source = await fetch("./input", { cache: "no-store" });
+  if (!source.ok) throw new Error("Could not read the selected input");
+  const bytes = new Uint8Array(await source.arrayBuffer());
+  const fetchEnd = performance.now();
+  app.load_file(config.input_name, bytes, false);
+  loading = {
+    selected_bytes_fetch_ms: fetchEnd - fetchStart,
+    load_file_call_ms: performance.now() - fetchEnd,
+  };
+  canvas.focus();
+  // Allow the asynchronous surface resize and imported scene to reach a frame.
+  await sleep(250);
+  if (failure) throw new Error(failure);
+  if (canvas.width !== config.width || canvas.height !== config.height) {
+    throw new Error(
+      `Requested ${config.width}×${config.height} physical pixels, got ${canvas.width}×${canvas.height}`,
+    );
+  }
+  button.disabled = false;
+  status.textContent = `${config.input_name} · ${config.metadata.profile} · ${config.options.mode} · ${config.width}×${config.height} physical surface. Press Run and keep this tab focused.`;
+}
+
+button.addEventListener("click", async () => {
+  button.disabled = true;
+  const initialRatio = window.devicePixelRatio;
+  const observation = observeRun({
+    window,
+    document,
+    canvas,
+    width: config.width,
+    height: config.height,
+  });
+  try {
+    if (document.visibilityState !== "visible" || !document.hasFocus()) {
+      throw new Error(
+        "Keep the browser page visible and focused before running",
+      );
+    }
+    canvas.focus();
+    const metadata = {
+      ...config.metadata,
+      loading,
+      browser: {
+        automation: window.n3MeasurementAutomation ?? null,
+        user_agent: navigator.userAgent,
+        device_pixel_ratio: initialRatio,
+        physical_canvas: [canvas.width, canvas.height],
+        css_canvas: [
+          canvas.getBoundingClientRect().width,
+          canvas.getBoundingClientRect().height,
+        ],
+        viewport_css: [window.innerWidth, window.innerHeight],
+        visibility: document.visibilityState,
+        cross_origin_isolated: window.crossOriginIsolated,
+        report_poll_interval_ms: 250,
+      },
+    };
+    status.textContent = `Measuring ${config.options.mode}… keep the page focused and avoid interacting with the canvas.`;
+    app.measure_start(JSON.stringify(config.options), JSON.stringify(metadata));
+    const deadline = performance.now() + config.timeout_seconds * 1000;
+    let report;
+    while (!report) {
+      await sleep(250);
+      if (failure) throw new Error(failure);
+      if (performance.now() > deadline)
+        throw new Error("Measurement timed out");
+      observation.sample();
+      report = JSON.parse(app.measure_result());
+    }
+    report.browser_observations = observation.finish();
+    if (report.validity?.mode_contract_satisfied !== true) {
+      throw new Error(
+        "Measured paths did not satisfy the requested mode contract",
+      );
+    }
+    result.textContent = JSON.stringify(report, null, 2);
+    const response = await fetch("./report", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(report),
+    });
+    if (!response.ok)
+      throw new Error(`Report was not saved (${response.status})`);
+    window.n3MeasurementSaved?.();
+    const comparable =
+      report.browser_observations.comparable &&
+      Object.values(report.validity).every((valid) => valid === true);
+    status.textContent = comparable
+      ? "Report saved. This sample does not establish a performance conclusion; repeat under controlled conditions."
+      : "Report saved with comparison validity failures; inspect the report and exclude it from comparisons.";
+  } catch (error) {
+    fail(error);
+  } finally {
+    observation.stop();
+  }
+});
+
+setup().catch(fail);
diff --git a/tools/benchmark/browser/observations.mjs b/tools/benchmark/browser/observations.mjs
new file mode 100644
index 0000000..0655b77
--- /dev/null
+++ b/tools/benchmark/browser/observations.mjs
@@ -0,0 +1,47 @@
+// Observe the run's environment without driving camera input or rendering.
+export function observeRun({ window, document, canvas, width, height }) {
+  const observations = {
+    visibility_changes: 0,
+    focus_losses: 0,
+    size_changed: false,
+  };
+  const initialRatio = window.devicePixelRatio;
+  const abort = new AbortController();
+  document.addEventListener(
+    "visibilitychange",
+    () => {
+      observations.visibility_changes += 1;
+    },
+    { signal: abort.signal },
+  );
+  window.addEventListener(
+    "blur",
+    () => {
+      observations.focus_losses += 1;
+    },
+    { signal: abort.signal },
+  );
+  const sample = () => {
+    observations.size_changed ||=
+      canvas.width !== width ||
+      canvas.height !== height ||
+      window.devicePixelRatio !== initialRatio;
+  };
+  return {
+    sample,
+    finish() {
+      sample();
+      abort.abort();
+      return {
+        ...observations,
+        comparable:
+          observations.visibility_changes === 0 &&
+          observations.focus_losses === 0 &&
+          !observations.size_changed,
+      };
+    },
+    stop() {
+      abort.abort();
+    },
+  };
+}
diff --git a/tools/benchmark/browser/policy.mjs b/tools/benchmark/browser/policy.mjs
new file mode 100644
index 0000000..7ae1b9f
--- /dev/null
+++ b/tools/benchmark/browser/policy.mjs
@@ -0,0 +1,52 @@
+// Browser-runner policy is tested without installing or launching Chromium.
+export function clearExternalControl(environment) {
+  for (const name of ["SELENIUM_REMOTE_URL", "PWDEBUG"]) {
+    for (const key of [
+      name,
+      `npm_config_${name.toLowerCase()}`,
+      `npm_package_config_${name.toLowerCase()}`,
+    ]) {
+      delete environment[key];
+    }
+  }
+}
+
+export function parseOptions(arguments_) {
+  if (arguments_.length !== 6) throw new Error("Expected six runner arguments");
+  const [address, mode, widthText, heightText, scaleText, timeoutText] =
+    arguments_;
+  const url = new URL(address);
+  const width = Number(widthText);
+  const height = Number(heightText);
+  const scale = Number(scaleText);
+  const seconds = Number(timeoutText);
+  if (
+    url.protocol !== "http:" ||
+    url.hostname !== "127.0.0.1" ||
+    url.username ||
+    url.password ||
+    !["headless", "headed"].includes(mode) ||
+    ![width, height].every(
+      (value) => Number.isInteger(value) && value >= 64 && value <= 8192,
+    ) ||
+    !Number.isFinite(scale) ||
+    scale <= 0 ||
+    scale > 4 ||
+    !Number.isInteger(seconds) ||
+    seconds < 1 ||
+    seconds > 7200
+  ) {
+    throw new Error(
+      "Expected a local measurement URL, browser mode, dimensions, scale, and timeout",
+    );
+  }
+  return { url, mode, width, height, scale, timeout: seconds * 1000 };
+}
+
+export function recordedLaunchArguments(arguments_) {
+  return arguments_.map((argument) =>
+    argument.startsWith("--user-data-dir=")
+      ? "--user-data-dir=[temporary-profile]"
+      : argument,
+  );
+}
diff --git a/tools/benchmark/browser/runner.mjs b/tools/benchmark/browser/runner.mjs
new file mode 100644
index 0000000..c3e35a6
--- /dev/null
+++ b/tools/benchmark/browser/runner.mjs
@@ -0,0 +1,179 @@
+// Optional contributor runner. It never connects to an existing browser/profile.
+import { readFile } from "node:fs/promises";
+
+import {
+  clearExternalControl,
+  parseOptions,
+  recordedLaunchArguments,
+} from "./policy.mjs";
+
+// Ambient automation settings must not redirect this owned local browser.
+clearExternalControl(process.env);
+const { url, mode, width, height, scale, timeout } = parseOptions(
+  process.argv.slice(2),
+);
+
+const installation = new URL(
+  "../../../.cache/viewport-tools/node_modules/playwright/",
+  import.meta.url,
+);
+const { version } = JSON.parse(
+  await readFile(new URL("package.json", installation), "utf8"),
+);
+if (version !== "1.63.0") {
+  throw new Error(
+    "Run `just measure-browser-setup` to install the pinned Playwright version",
+  );
+}
+const { chromium } = await import(new URL("index.mjs", installation).href);
+let browser;
+let browserServer;
+let interrupted = false;
+let rejectFailure;
+const failure = new Promise((_, reject) => {
+  rejectFailure = reject;
+});
+// The same failure promise is raced against setup and completion operations.
+failure.catch(() => {});
+const onSignal = (signal) => {
+  interrupted = true;
+  rejectFailure(new Error(`Browser measurement interrupted (${signal})`));
+};
+process.on("SIGTERM", onSignal);
+process.on("SIGINT", onSignal);
+const watchdog = setTimeout(() => {
+  rejectFailure(new Error("Browser measurement timed out"));
+}, timeout);
+
+try {
+  // Context DPR emulation alone does not change Chrome's device-pixel resize
+  // observer on this host. winit consumes that observer; match the real scale.
+  const launchArguments = [`--force-device-scale-factor=${scale}`];
+  // Full Chromium/new headless; leave the sandbox and default GPU policy enabled.
+  browserServer = await chromium.launchServer({
+    channel: "chromium",
+    headless: mode === "headless",
+    chromiumSandbox: true,
+    args: launchArguments,
+    host: "127.0.0.1",
+    timeout: Math.min(timeout, 30_000),
+  });
+  if (interrupted)
+    throw new Error("Browser measurement interrupted during launch");
+  // This endpoint belongs only to the fresh process launched above. Server
+  // ownership also provides a public force-kill API if graceful shutdown hangs.
+  // Race the complete owned session: setup calls can stall before navigation.
+  // Any interruption must reach finally even when a Playwright await is pending.
+  const collect = async () => {
+    browser = await chromium.connect(browserServer.wsEndpoint(), {
+      timeout: Math.min(timeout, 30_000),
+    });
+    const viewport = {
+      width: Math.max(1280, Math.ceil(width / scale) + 24),
+      height: Math.max(800, Math.ceil(height / scale) + 100),
+    };
+    const context = await browser.newContext({
+      viewport,
+      deviceScaleFactor: scale,
+    });
+    const page = await context.newPage();
+    page.setDefaultTimeout(timeout);
+    page.on("pageerror", (error) => rejectFailure(error));
+    page.on("console", (message) => {
+      if (
+        message.type() === "error" &&
+        message.text().startsWith("N3 measurement failed:")
+      ) {
+        rejectFailure(new Error(message.text()));
+      }
+    });
+    const automation = {
+      tool: "playwright",
+      version,
+      browser_version: browser.version(),
+      mode,
+      channel: "chromium",
+      fresh_profile: true,
+      chromium_sandbox: true,
+      custom_launch_arguments: launchArguments,
+      launch_arguments: recordedLaunchArguments(
+        browserServer.process().spawnargs.slice(1),
+      ),
+      viewport_css: [viewport.width, viewport.height],
+      device_scale_factor: scale,
+      gpu_process: null,
+      gpu_diagnostics_error: null,
+    };
+    // Collected once before loading/measuring; no tracing or CDP polling in the run.
+    try {
+      const cdp = await browser.newBrowserCDPSession();
+      const { gpu } = await cdp.send("SystemInfo.getInfo");
+      automation.gpu_process = {
+        devices: gpu.devices,
+        feature_status: gpu.featureStatus,
+        gl_renderer: gpu.auxAttributes?.glRenderer ?? null,
+        gl_vendor: gpu.auxAttributes?.glVendor ?? null,
+        gl_version: gpu.auxAttributes?.glVersion ?? null,
+        // This describes Chrome's GPU process, not necessarily wgpu's chosen adapter.
+        software_renderer_detected:
+          /swiftshader|llvmpipe|lavapipe|software/i.test(
+            JSON.stringify([gpu.devices, gpu.auxAttributes?.glRenderer]),
+          ),
+      };
+      await cdp.detach();
+    } catch (error) {
+      automation.gpu_diagnostics_error = String(error);
+    }
+    await page.addInitScript((metadata) => {
+      window.n3MeasurementAutomation = metadata;
+    }, automation);
+    let resolveSaved;
+    const saved = new Promise((resolve) => {
+      resolveSaved = resolve;
+    });
+    // One completion notification after the server accepts the report. There is
+    // no per-frame automation, trace, video, or screenshot while measuring.
+    await page.exposeFunction("n3MeasurementSaved", () => {
+      resolveSaved();
+    });
+    await page.goto(url.href, { timeout });
+    await page.waitForFunction(
+      () => document.querySelector("#run")?.disabled === false,
+    );
+    await page.getByRole("button", { name: "Run", exact: true }).click();
+    await saved;
+    console.log(
+      `Isolated ${mode} Chromium ${browser.version()} measurement finished.`,
+    );
+  };
+  await Promise.race([collect(), failure]);
+} catch (error) {
+  console.error(String(error));
+  process.exitCode = 1;
+} finally {
+  clearTimeout(watchdog);
+  // Playwright owns the fresh process and temporary profile; close both on every
+  // exit path, including the Python parent's timeout or keyboard interruption.
+  if (browserServer) {
+    let deadline;
+    try {
+      await Promise.race([
+        browserServer.close(),
+        new Promise((_, reject) => {
+          deadline = setTimeout(
+            () => reject(new Error("Browser shutdown timed out")),
+            5_000,
+          );
+        }),
+      ]);
+    } catch (error) {
+      console.error(String(error));
+      process.exitCode = 1;
+      await browserServer.kill();
+    } finally {
+      clearTimeout(deadline);
+    }
+  }
+  process.removeListener("SIGTERM", onSignal);
+  process.removeListener("SIGINT", onSignal);
+}
diff --git a/tools/benchmark/contracts.py b/tools/benchmark/contracts.py
new file mode 100644
index 0000000..d2cc5f5
--- /dev/null
+++ b/tools/benchmark/contracts.py
@@ -0,0 +1,407 @@
+"""Raw measurement schema and evidence admission policies.
+
+A producer's boolean validity summary is evidence, never authority. Admission
+cross-checks the observed per-frame and browser facts before aggregation.
+"""
+
+import json
+import math
+import struct
+
+VALIDITY_FIELDS = (
+    "all_frames_focused", "constant_surface", "constant_viewport",
+    "fps_meter_constant", "mode_contract_satisfied", "no_frame_errors", "unchanged_mesh_revision",
+    "viewport_matches_baseline",
+)
+STAGES = (
+    "cache_sync", "commands_and_refresh", "editor_prepare", "feedback",
+    "host_prepare", "host_tail", "present_api", "scene_composite",
+    "scene_encode", "submit_api", "surface_acquire", "tessellation_and_textures",
+    "ui", "ui_encode", "editor_projection",
+)
+COUNTERS = frozenset((
+    "mesh_uploads", "base_mesh_upload_bytes", "viewport_resizes", "ui_jobs",
+    "egui_passes", "egui_tessellations", "egui_texture_updates", "egui_composites",
+    "editor_prepares", "editor_feedback_updates", "scene_renders", "scene_composites",
+))
+_COMMON_STAGES = frozenset((
+    "surface_acquire", "host_prepare", "cache_sync", "scene_encode", "submit_api",
+    "present_api", "host_tail",
+))
+_MODE_STAGES = {
+    "editor": _COMMON_STAGES | {"ui", "commands_and_refresh", "tessellation_and_textures", "feedback", "ui_encode"},
+    "viewport": _COMMON_STAGES | {"editor_prepare", "feedback", "scene_composite"},
+    "renderer": _COMMON_STAGES | {"scene_composite"},
+}
+def require(condition, message):
+    if not condition:
+        raise ValueError(message)
+
+
+def number(value, name, nullable=False, positive=False):
+    if nullable and value is None:
+        return
+    require(type(value) in (int, float) and math.isfinite(value), f"{name} must be finite numeric data")
+    require(value > 0 if positive else value >= 0, f"{name} must be {'positive' if positive else 'nonnegative'}")
+
+
+def vector(value, size, name, positive=False):
+    require(isinstance(value, list) and len(value) == size, f"{name} must contain {size} numbers")
+    for item in value:
+        require(type(item) in (int, float) and math.isfinite(item), f"{name} must contain finite numbers")
+        if positive:
+            require(item > 0, f"{name} must contain positive numbers")
+
+
+def _pixel_size(value, name, *, positive=False):
+    vector(value, 2, name, positive=positive)
+    require(all(type(item) is int and item >= 0 for item in value), f"{name} must contain integer pixels")
+
+
+def _rect(value, name):
+    vector(value, 4, name)
+    require(value[2] > value[0] and value[3] > value[1], f"{name} must have positive width and height")
+
+
+def _integer(value, name, minimum=0):
+    require(type(value) is int and value >= minimum, f"malformed {name}")
+
+
+def _text(value, name, *, empty=False):
+    require(isinstance(value, str) and (empty or bool(value)), f"malformed {name}")
+
+
+def _same_scale(left, right):
+    # The browser/request uses f64 while winit/egui also expose f32 scales.
+    return math.isclose(left, right, rel_tol=1e-6, abs_tol=0)
+
+
+def _scene_extent(bounds, scale):
+    # Mirror the renderer's f32 subtraction, multiplication and positive round.
+    # Its device texture limit is not in v2, so this is only an upper bound.
+    def f32(value):
+        return struct.unpack("f", struct.pack("f", value))[0]
+
+    try:
+        extent = []
+        for minimum, maximum in zip(bounds[:2], bounds[2:]):
+            length = f32(f32(maximum) - f32(minimum))
+            pixels = f32(length * f32(scale))
+            extent.append(max(1, math.floor(pixels + 0.5)))
+        return extent
+    except (OverflowError, ValueError):
+        return None
+
+
+def canonical(value):
+    return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
+
+
+def validate_raw(report):
+    require(isinstance(report, dict) and report.get("schema") == "n3.viewport-measure.v2",
+             "expected n3.viewport-measure.v2 raw report")
+    # Reject NaN, infinity and overflowing JSON numbers anywhere, including metadata.
+    canonical(report)
+    for name in ("metadata", "options", "render", "model", "validity"):
+        require(isinstance(report.get(name), dict), f"missing or malformed {name}")
+    metadata, options = report["metadata"], report["options"]
+    required_metadata = (
+        "host", "adapter", "architecture", "browser_mode", "build_command",
+        "cargo_encoded_rustflags", "dirty", "input", "operating_system", "profile",
+        "profile_overrides", "recorded_at_utc", "requested_device_scale_factor",
+        "requested_physical_size", "revision", "rustc", "rustflags",
+        "source_fingerprint", "surface",
+    )
+    for name in required_metadata:
+        require(name in metadata, f"missing metadata.{name}")
+    require(metadata["host"] in ("native", "web"), "unsupported metadata.host")
+    for name in ("architecture", "operating_system", "profile", "revision", "rustc", "recorded_at_utc"):
+        require(isinstance(metadata[name], str) and bool(metadata[name]), f"malformed metadata.{name}")
+    for name in ("adapter", "input", "profile_overrides", "source_fingerprint", "surface"):
+        require(isinstance(metadata[name], dict), f"malformed metadata.{name}")
+    adapter = metadata["adapter"]
+    for name in ("name", "driver", "driver_info", "backend", "device_type"):
+        # BrowserWebGpu deliberately leaves identifying strings empty on some
+        # browsers. Preserve that observed unknown, but require the wire field.
+        _text(adapter.get(name), f"metadata.adapter.{name}", empty=name in ("name", "driver", "driver_info"))
+    for name in ("vendor", "device"):
+        _integer(adapter.get(name), f"metadata.adapter.{name}")
+    for container, name in ((metadata["input"], "input"), (metadata["source_fingerprint"], "source_fingerprint")):
+        digest = container.get("sha256")
+        require(isinstance(digest, str) and len(digest) == 64 and all(c in "0123456789abcdef" for c in digest),
+                 f"malformed metadata.{name}.sha256")
+    require(type(metadata["input"].get("bytes")) is int and metadata["input"]["bytes"] > 0,
+             "malformed metadata.input.bytes")
+    require(type(metadata["dirty"]) is bool, "malformed metadata.dirty")
+    _pixel_size(metadata["requested_physical_size"], "metadata.requested_physical_size", positive=True)
+    for name in ("width", "height"):
+        value = metadata["surface"].get(name)
+        require(type(value) is int and value > 0, f"malformed metadata.surface.{name}")
+    surface = metadata["surface"]
+    for name in ("format", "present_mode", "alpha_mode"):
+        _text(surface.get(name), f"metadata.surface.{name}")
+    number(surface.get("scale_factor"), "metadata.surface.scale_factor", positive=True)
+    _integer(surface.get("desired_maximum_frame_latency"), "metadata.surface.desired_maximum_frame_latency")
+    require(isinstance(metadata["build_command"], list) and metadata["build_command"]
+             and all(isinstance(value, str) for value in metadata["build_command"]), "malformed build_command")
+    require(options.get("mode") in ("editor", "viewport", "renderer"), "unsupported mode")
+    require(options.get("workload") in ("orbit", "stationary"), "unsupported workload")
+    for name in ("instrument_stages", "selected"):
+        require(type(options.get(name)) is bool, f"malformed options.{name}")
+    for name in ("sample_frames", "warmup_frames"):
+        require(type(options.get(name)) is int and options[name] >= (1 if name == "sample_frames" else 0),
+                 f"malformed options.{name}")
+    require(not (options["mode"] == "renderer" and options["selected"]), "renderer cannot include selection")
+    model, render = report["model"], report["render"]
+    for name in ("objects", "vertices", "faces", "triangles", "linked_assets"):
+        _integer(model.get(name), f"model.{name}", minimum=1 if name == "objects" else 0)
+    for name in ("shading", "theme"):
+        _text(render.get(name), f"render.{name}")
+    for name in ("edges", "grid"):
+        require(type(render.get(name)) is bool, f"malformed render.{name}")
+    _integer(render.get("selected_objects"), "render.selected_objects")
+    require("hovered_object" in render, "missing render.hovered_object")
+    if render["hovered_object"] is not None:
+        _integer(render["hovered_object"], "render.hovered_object")
+    _rect(render.get("baseline_viewport_ui_points"), "baseline viewport UI")
+    validity = report["validity"]
+    samples = report.get("samples")
+    require(isinstance(samples, list) and len(samples) == options["sample_frames"], "sample count differs from requested frames")
+    # The first v2 captures predate this observation. Preserve their missing
+    # render key as unknown (and incompatible with both observed on and off).
+    # A partially present extension is corruption, never a legacy default.
+    observes_fps_meter = ("fps_meter_enabled" in report["render"]
+                          or "fps_meter_constant" in validity
+                          or any(isinstance(sample, dict) and "fps_meter_enabled" in sample for sample in samples))
+    for name in VALIDITY_FIELDS:
+        if name == "fps_meter_constant" and not observes_fps_meter:
+            continue
+        require(type(validity.get(name)) is bool, f"missing or malformed validity.{name}")
+    require(all(type(value) is bool for value in validity.values()), "validity flags must be boolean")
+    if observes_fps_meter:
+        require(type(report["render"].get("fps_meter_enabled")) is bool,
+                 "missing or malformed render.fps_meter_enabled")
+    _rect(report["render"].get("baseline_viewport_points"), "baseline viewport")
+    vector(report["render"].get("initial_camera_view_projection"), 16, "initial camera")
+    number(report["render"].get("pixels_per_point"), "render.pixels_per_point", positive=True)
+    for index, sample in enumerate(samples):
+        require(isinstance(sample, dict) and type(sample.get("frame")) is int and sample["frame"] == index,
+                 "sample frame indices must be consecutive")
+        number(sample.get("cpu_frame_ms"), "cpu_frame_ms")
+        require("frame_start_interval_ms" in sample, "missing frame_start_interval_ms")
+        number(sample["frame_start_interval_ms"], "frame_start_interval_ms", nullable=index == 0)
+        require(type(sample.get("focused")) is bool, "malformed sample.focused")
+        if observes_fps_meter:
+            require(type(sample.get("fps_meter_enabled")) is bool,
+                     "missing or malformed sample.fps_meter_enabled")
+        require("error" in sample and (sample["error"] is None or isinstance(sample["error"], str)), "malformed sample.error")
+        require(type(sample.get("mesh_revision")) is int and sample["mesh_revision"] >= 0, "malformed mesh_revision")
+        # Null dimensions are an observed invalid frame, not corrupt JSON.
+        for name in ("surface_pixels", "viewport_pixels"):
+            require(name in sample, f"missing {name}")
+            if sample[name] is not None:
+                _pixel_size(sample[name], name)
+        _rect(sample.get("viewport_points"), "viewport_points")
+        vector(sample.get("camera_view_projection"), 16, "camera_view_projection")
+        number(sample.get("pixels_per_point"), "pixels_per_point", positive=True)
+        projection = sample.get("projection")
+        require(isinstance(projection, dict) and "cpu_ms" in projection, "missing projection.cpu_ms")
+        number(projection["cpu_ms"], "projection.cpu_ms", nullable=True)
+        for name in ("rebuilds", "geometry_rebuilds", "vertices_tested"):
+            require(type(projection.get(name)) is int and projection[name] >= 0, f"malformed projection.{name}")
+        counters = sample.get("counters")
+        require(isinstance(counters, dict) and set(counters) == COUNTERS and all(type(value) is int and value >= 0 for value in counters.values()),
+                 "missing or malformed execution counters")
+        require("cpu_stage_ms" in sample, "missing cpu_stage_ms")
+        stages = sample["cpu_stage_ms"]
+        if options["instrument_stages"]:
+            require(isinstance(stages, dict) and set(stages) == set(STAGES) - {"editor_projection"}, "missing or unknown CPU stages")
+            for name, value in stages.items():
+                number(value, f"cpu_stage_ms.{name}", nullable=True)
+        else:
+            require(stages is None and projection["cpu_ms"] is None, "disabled stage timing must be null")
+    if metadata["host"] == "web":
+        browser = metadata.get("browser")
+        require(isinstance(browser, dict), "missing browser metadata")
+        for name in ("user_agent", "device_pixel_ratio", "physical_canvas", "css_canvas", "visibility"):
+            require(name in browser, f"missing browser.{name}")
+        number(browser["device_pixel_ratio"], "browser.device_pixel_ratio", positive=True)
+        _text(browser["user_agent"], "browser.user_agent")
+        require(browser["visibility"] in ("visible", "hidden"), "malformed browser.visibility")
+        _pixel_size(browser["physical_canvas"], "browser.physical_canvas", positive=True)
+        vector(browser["css_canvas"], 2, "browser.css_canvas", positive=True)
+        require(metadata["browser_mode"] in ("headed", "headless", "manual"), "unsupported browser_mode")
+        requested_scale = metadata["requested_device_scale_factor"]
+        if metadata["browser_mode"] == "manual":
+            require(requested_scale is None, "manual browser must observe rather than request device scale")
+        else:
+            number(requested_scale, "metadata.requested_device_scale_factor", positive=True)
+        automation = browser.get("automation")
+        require(automation is None or isinstance(automation, dict), "malformed browser automation metadata")
+        if metadata["browser_mode"] != "manual":
+            require(isinstance(automation, dict), "missing browser automation metadata")
+            for name in ("browser_version", "mode", "tool", "version", "launch_arguments", "gpu_process"):
+                require(name in automation, f"missing browser.automation.{name}")
+        if automation:
+            require(automation.get("mode") in ("headed", "headless"), "malformed browser.automation.mode")
+            for name in ("browser_version", "tool", "version"):
+                _text(automation.get(name), f"browser.automation.{name}")
+            arguments = automation.get("launch_arguments")
+            require(isinstance(arguments, list) and all(isinstance(value, str) for value in arguments),
+                     "malformed browser.automation.launch_arguments")
+            if "device_scale_factor" in automation:
+                number(automation["device_scale_factor"], "browser.automation.device_scale_factor", positive=True)
+            gpu = automation.get("gpu_process")
+            require(gpu is None or isinstance(gpu, dict), "malformed browser.automation.gpu_process")
+            if gpu is not None and "software_renderer_detected" in gpu:
+                require(type(gpu["software_renderer_detected"]) is bool,
+                        "malformed browser.automation.gpu_process.software_renderer_detected")
+        observations = report.get("browser_observations")
+        if observations is not None:
+            require(isinstance(observations, dict) and type(observations.get("comparable")) is bool,
+                     "malformed browser_observations")
+            for name in ("visibility_changes", "focus_losses"):
+                require(type(observations.get(name)) is int and observations[name] >= 0,
+                        f"malformed browser_observations.{name}")
+            require(type(observations.get("size_changed")) is bool,
+                    "malformed browser_observations.size_changed")
+
+
+def mode_contract_satisfied(sample, mode):
+    """Independently check what executed, matching the Rust producer contract."""
+    counts = sample["counters"]
+    if counts["scene_renders"] != 1 or not sample["viewport_pixels"] or min(sample["viewport_pixels"]) <= 0:
+        return False
+    if mode == "editor":
+        return (counts["egui_passes"] > 0 and counts["egui_tessellations"] == 1
+                and counts["egui_composites"] == 1 and counts["editor_prepares"] > 0
+                and counts["editor_feedback_updates"] == 1 and counts["scene_composites"] == 0)
+    if any(counts[name] != 0 for name in (
+        "egui_passes", "egui_tessellations", "egui_texture_updates", "egui_composites", "ui_jobs",
+    )) or counts["scene_composites"] != 1:
+        return False
+    if mode == "viewport":
+        return counts["editor_prepares"] > 0 and counts["editor_feedback_updates"] == 1
+    return (counts["editor_prepares"] == 0 and counts["editor_feedback_updates"] == 0
+            and all(sample["projection"][name] == 0 for name in ("rebuilds", "vertices_tested", "geometry_rebuilds"))
+            and sample["projection"]["cpu_ms"] is None)
+
+
+def contamination_reasons(report):
+    reasons = [f"validity.{name}=false" for name, value in report["validity"].items() if not value]
+    samples = report["samples"]
+    if "fps_meter_enabled" in report["render"] and any(
+        sample["fps_meter_enabled"] != report["render"]["fps_meter_enabled"] for sample in samples
+    ):
+        reasons.append("raw FPS meter state differs from initial render configuration")
+    if any(not sample["focused"] for sample in samples):
+        reasons.append("raw samples include lost focus")
+    if any(sample["error"] is not None for sample in samples):
+        reasons.append("raw samples include frame errors")
+    if any(sample["mesh_revision"] != samples[0]["mesh_revision"] for sample in samples):
+        reasons.append("raw samples changed mesh revision")
+    mode = report["options"]["mode"]
+    if any(not mode_contract_satisfied(sample, mode) for sample in samples):
+        reasons.append("raw execution counters violate measurement mode contract")
+    if report["options"]["instrument_stages"]:
+        expected = _MODE_STAGES[mode]
+        if any({name for name, value in sample["cpu_stage_ms"].items() if value is not None} != expected
+               for sample in samples):
+            reasons.append("raw stage availability violates measurement mode contract")
+    for name in ("surface_pixels", "viewport_pixels"):
+        if any(not sample[name] or min(sample[name]) <= 0 or sample[name] != samples[0][name] for sample in samples):
+            reasons.append(f"raw samples have invalid or changing {name}")
+    for name in ("viewport_points", "pixels_per_point"):
+        if any(sample[name] != samples[0][name] for sample in samples):
+            reasons.append(f"raw samples changed {name}")
+    if any(sample["viewport_points"] != report["render"]["baseline_viewport_points"] for sample in samples):
+        reasons.append("raw viewport differs from baseline")
+    requested = report["metadata"]["requested_physical_size"]
+    surface = report["metadata"]["surface"]
+    if any(sample["surface_pixels"] != requested for sample in samples):
+        reasons.append("raw surface differs from requested physical size")
+    if [surface["width"], surface["height"]] != requested:
+        reasons.append("surface metadata differs from requested physical size")
+    if any(sample["pixels_per_point"] != report["render"]["pixels_per_point"] for sample in samples):
+        reasons.append("raw pixel scale differs from render metadata")
+    if any(not _same_scale(sample["pixels_per_point"], surface["scale_factor"]) for sample in samples):
+        reasons.append("raw pixel scale differs from surface scale")
+    # The production renderer may clamp the offscreen texture to its device
+    # limit. Smaller dimensions are possible; dimensions larger than the rounded
+    # logical extent or the complete surface contradict the recorded geometry.
+    for sample in samples:
+        pixels, bounds = sample["viewport_pixels"], sample["viewport_points"]
+        scale = sample["pixels_per_point"]
+        extent = _scene_extent(bounds, scale)
+        if extent is None or (pixels and any(pixels[index] > requested[index] or pixels[index] > extent[index]
+                                            for index in range(2))):
+            reasons.append("raw viewport pixels exceed the surface or logical viewport extent")
+            break
+    if report["render"]["selected_objects"] != int(report["options"]["selected"]):
+        reasons.append("observed selection differs from requested workload")
+    if report["render"]["selected_objects"] > report["model"]["objects"]:
+        reasons.append("observed selection exceeds document objects")
+    if report["model"]["linked_assets"] > report["model"]["objects"]:
+        reasons.append("linked asset count exceeds document objects")
+    cameras = [sample["camera_view_projection"] for sample in samples]
+    if report["options"]["workload"] == "stationary":
+        if any(camera != report["render"]["initial_camera_view_projection"] for camera in cameras):
+            reasons.append("stationary workload changed camera")
+    elif any(left == right for left, right in zip(cameras, cameras[1:])):
+        reasons.append("orbit workload did not advance camera between completed frames")
+    browser = report["metadata"].get("browser", {})
+    automation = browser.get("automation") or {}
+    if browser.get("visibility", "visible") != "visible":
+        reasons.append("browser was not visible")
+    if (automation.get("gpu_process") or {}).get("software_renderer_detected") is True:
+        reasons.append("browser software renderer detected")
+    if browser and browser["physical_canvas"] != requested:
+        reasons.append("browser canvas differs from requested physical size")
+    if browser:
+        ratio = browser["device_pixel_ratio"]
+        physical_css = [css * ratio for css in browser["css_canvas"]]
+        if any(not math.isfinite(observed) or math.floor(observed + 0.5) != pixels
+               for observed, pixels in zip(physical_css, browser["physical_canvas"])):
+            reasons.append("browser CSS canvas and device pixel ratio disagree with physical canvas")
+        if not _same_scale(ratio, surface["scale_factor"]):
+            reasons.append("browser device pixel ratio differs from surface scale")
+        requested_scale = report["metadata"]["requested_device_scale_factor"]
+        if requested_scale is not None and not _same_scale(ratio, requested_scale):
+            reasons.append("browser device pixel ratio differs from requested scale")
+        if "device_scale_factor" in automation and not _same_scale(ratio, automation["device_scale_factor"]):
+            reasons.append("browser device pixel ratio differs from automation scale")
+    if automation and automation["mode"] != report["metadata"]["browser_mode"]:
+        reasons.append("browser automation mode differs from requested mode")
+    observations = report.get("browser_observations", {})
+    if observations.get("comparable") is False:
+        reasons.append("browser_observations.comparable=false")
+    if observations.get("visibility_changes", 0) > 0:
+        reasons.append("raw browser observations include visibility changes")
+    if observations.get("focus_losses", 0) > 0:
+        reasons.append("raw browser observations include focus loss")
+    if observations.get("size_changed") is True:
+        reasons.append("raw browser observations include size changes")
+    return reasons
+
+
+
+def suite_provenance(path, digest, report):
+    if "suite" not in report["metadata"]:
+        return [], None
+    manifest_path = path.parent.parent / "manifest.json"
+    try:
+        raw = manifest_path.read_bytes()
+        manifest = json.loads(raw)
+        canonical(manifest)
+        require(isinstance(manifest, dict) and manifest.get("schema") == "n3.viewport-suite-manifest.v1",
+                 "unsupported manifest schema")
+        require(isinstance(manifest.get("runs"), list), "missing manifest runs")
+        relative = path.relative_to(manifest_path.parent).as_posix()
+        entries = [entry for entry in manifest["runs"] if isinstance(entry, dict) and entry.get("report") == relative]
+        require(len(entries) == 1, "manifest must identify this raw report exactly once")
+        entry = entries[0]
+        require(entry.get("status") == "complete", "suite run did not complete teardown and stability checks")
+        require(entry.get("sha256") == digest, "raw report hash differs from completed suite receipt")
+        return [], {"path": str(manifest_path), "entry": entry}
+    except (OSError, ValueError, TypeError) as error:
+        return [f"suite receipt unavailable or invalid: {error}"], {"path": str(manifest_path)}
diff --git a/tools/benchmark/measure.py b/tools/benchmark/measure.py
new file mode 100644
index 0000000..6c1b43e
--- /dev/null
+++ b/tools/benchmark/measure.py
@@ -0,0 +1,333 @@
+"""Build and measure the real native or browser viewport into a local JSON report."""
+
+import argparse
+from datetime import datetime, timezone
+import hashlib
+import json
+import math
+import os
+from pathlib import Path
+import platform
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import webbrowser
+
+from tools import web
+from .transport import MAX_REPORT_BYTES, MeasurementServer, validate_envelope, write_report
+
+ROOT = Path(__file__).resolve().parents[2]
+PLAYWRIGHT_VERSION = "1.63.0"
+
+
+def browser_environment(root=ROOT):
+    environment = os.environ.copy()
+    # Playwright reads these before launch, including npm's lowercase aliases.
+    # Ambient debugging or Selenium configuration must not redirect this owned
+    # local run or silently change a requested headless run into headed mode.
+    for name in ("SELENIUM_REMOTE_URL", "PWDEBUG"):
+        for key in (name, f"npm_config_{name.lower()}", f"npm_package_config_{name.lower()}"):
+            environment.pop(key, None)
+    environment["PLAYWRIGHT_BROWSERS_PATH"] = str(root / ".cache/viewport-browsers")
+    environment.setdefault("npm_config_cache", str(root / ".cache/npm"))
+    return environment
+
+
+def setup_browser(root=ROOT):
+    environment = browser_environment(root)
+    installation = root / ".cache/viewport-tools"
+    subprocess.run([
+        "npm", "install", "--prefix", str(installation), "--ignore-scripts",
+        "--no-audit", "--no-fund", "--save-exact", f"playwright@{PLAYWRIGHT_VERSION}",
+    ], cwd=root, env=environment, check=True)
+    subprocess.run([
+        "node", str(installation / "node_modules/playwright/cli.js"),
+        "install", "chromium", "--no-shell",
+    ], cwd=root, env=environment, check=True)
+    require_browser_tools(root)
+    print(f"Measurement browser: Playwright {PLAYWRIGHT_VERSION} with isolated Chromium", flush=True)
+
+
+def require_browser_tools(root=ROOT):
+    package = root / ".cache/viewport-tools/node_modules/playwright"
+    hint = "Run `python3 -m tools.benchmark.measure setup-browser` first."
+    try:
+        installed = json.loads((package / "package.json").read_text(encoding="utf-8"))
+        if not isinstance(installed, dict) or installed.get("version") != PLAYWRIGHT_VERSION or not (package / "cli.js").is_file():
+            raise ValueError("Playwright version does not match")
+        executable = Path(subprocess.check_output([
+            "node", "-e", "console.log(require(process.argv[1]).chromium.executablePath())", str(package),
+        ], cwd=root, env=browser_environment(root), text=True).strip())
+        executable.resolve().relative_to((root / ".cache/viewport-browsers").resolve())
+        if not executable.is_file():
+            raise ValueError("Chromium executable is missing")
+    except (OSError, ValueError, subprocess.SubprocessError) as error:
+        raise RuntimeError(f"Optional measurement browser tools are missing or mismatched. {hint}") from error
+
+
+def build_command(host, profile):
+    command = ["cargo", "build", "--locked", "--features", "viewport-measure", "--profile", profile]
+    return command + (["--target", web.TARGET, "--lib"] if host == "web" else ["--bin", "n3"])
+
+
+def build(host, profile, root=ROOT, *, log=None):
+    environment = web.cargo_environment(root)
+    generator = web.require_bindgen(root) if host == "web" else None
+    subprocess.run(build_command(host, profile), cwd=root, env=environment, check=True,
+                   **({"stdout": log, "stderr": subprocess.STDOUT} if log is not None else {}))
+    profile_dir = "debug" if profile == "dev" else "release"
+    target = Path(environment["CARGO_TARGET_DIR"])
+    if not target.is_absolute():
+        target = root / target
+    if host == "native":
+        return target / profile_dir / ("n3.exe" if os.name == "nt" else "n3")
+    output = root / "build/measure-web" / profile
+    output.parent.mkdir(parents=True, exist_ok=True)
+    with tempfile.TemporaryDirectory(prefix="measure-stage-", dir=output.parent) as temporary:
+        stage = Path(temporary) / "site"
+        stage.mkdir()
+        for name in ("measure.html", "measure.js", "observations.mjs"):
+            shutil.copyfile(root / "tools/benchmark/browser" / name, stage / name)
+        (stage / "licenses").mkdir()
+        shutil.copyfile(root / "assets/fonts/inter/LICENSE.txt", stage / "licenses/Inter.txt")
+        shutil.copyfile(root / "assets/fonts/lucide/LICENSE", stage / "licenses/Lucide.txt")
+        subprocess.run([
+            str(generator), str(target / web.TARGET / profile_dir / "n3.wasm"),
+            "--target", "web", "--out-name", "n3", "--out-dir", str(stage / "pkg"),
+        ], cwd=root, check=True,
+            **({"stdout": log, "stderr": subprocess.STDOUT} if log is not None else {}))
+        if output.exists():
+            shutil.rmtree(output)
+        stage.rename(output)
+    return output
+
+
+def source_fingerprint(root):
+    paths = subprocess.check_output([
+        "git", "ls-files", "--cached", "--others", "--exclude-standard", "-z",
+    ], cwd=root).split(b"\0")
+    digest = hashlib.sha256()
+    paths = sorted(set(path for path in paths if path))
+    for name in paths:
+        digest.update(len(name).to_bytes(8, "big"))
+        digest.update(name)
+        path = root / os.fsdecode(name)
+        if path.is_symlink():
+            digest.update(b"symlink\0" + os.fsencode(os.readlink(path)))
+        elif path.is_file():
+            digest.update(b"file\0")
+            digest.update((path.stat().st_mode & 0o777).to_bytes(2, "big"))
+            content = hashlib.sha256()
+            with path.open("rb") as source:
+                for chunk in iter(lambda: source.read(1024 * 1024), b""):
+                    content.update(chunk)
+            digest.update(content.digest())
+        elif path.is_dir():
+            digest.update(b"directory\0")
+        elif not path.exists():
+            digest.update(b"missing\0")
+        else:
+            raise RuntimeError(f"Unsupported source file type: {os.fsdecode(name)}")
+    return {"sha256": digest.hexdigest(), "paths": len(paths), "scope": "sorted tracked and nonignored untracked paths, modes, symlinks, file bytes, and missing markers"}
+
+
+def metadata(args, root=ROOT):
+    def git(*arguments):
+        return subprocess.check_output(["git", *arguments], cwd=root, text=True).strip()
+    digest = hashlib.sha256()
+    with args.input.open("rb") as source:
+        for chunk in iter(lambda: source.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return {
+        "recorded_at_utc": datetime.now(timezone.utc).isoformat(),
+        "revision": git("rev-parse", "HEAD"),
+        "source_fingerprint": source_fingerprint(root),
+        "dirty": bool(git("status", "--porcelain")),
+        "profile": args.profile,
+        "browser_mode": args.browser if args.host == "web" else None,
+        "requested_device_scale_factor": args.device_scale_factor if args.host == "web" and args.browser != "manual" else None,
+        "build_command": build_command(args.host, args.profile),
+        "rustflags": os.environ.get("RUSTFLAGS"),
+        "cargo_encoded_rustflags": os.environ.get("CARGO_ENCODED_RUSTFLAGS"),
+        "profile_overrides": {key: value for key, value in os.environ.items() if key.startswith("CARGO_PROFILE_")},
+        "rustc": subprocess.check_output(["rustc", "--version"], cwd=root, text=True).strip(),
+        "operating_system": platform.platform(),
+        "architecture": platform.machine(),
+        "input": {"name": args.input.name, "bytes": args.input.stat().st_size, "sha256": digest.hexdigest()},
+        "requested_physical_size": [args.width, args.height],
+        "display_refresh_hz": None,
+        "observed_presentation_timestamps": None,
+        "gpu_timestamps": None,
+        "loading": None,
+    }
+
+
+
+
+def options(args):
+    return {
+        "warmup_frames": args.warmup,
+        "sample_frames": args.frames,
+        "workload": args.workload,
+        "mode": args.mode,
+        "selected": args.selected,
+        "instrument_stages": not args.no_stage_timing,
+    }
+
+
+def stop_browser_runner(process):
+    if process is None or process.poll() is not None:
+        return
+    # The Node owner handles SIGTERM by closing its browser and context. Give
+    # that cleanup time to finish before bounding a stuck runner's lifetime.
+    # The runner caps launch at 30s, then closes/kills its owned BrowserServer;
+    # do not kill Node before it can complete that browser-process cleanup.
+    process.terminate()
+    try:
+        process.wait(timeout=45)
+    except subprocess.TimeoutExpired:
+        process.kill()
+        process.wait(timeout=5)
+
+
+def run_browser(args, artifact, info, root=ROOT, *, log=None):
+    config = {"options": options(args), "metadata": info, "input_name": args.input.name,
+              "width": args.width, "height": args.height, "timeout_seconds": args.timeout}
+    with MeasurementServer(artifact, args.input, args.output, config) as server:
+        server.timeout = 0.1
+        process = None
+        try:
+            print(f"Viewport measurement: {server.url}", flush=True)
+            if args.browser == "manual":
+                print("Manual mode: press Run and keep the page visible and focused.", flush=True)
+                if args.open:
+                    webbrowser.open(server.url)
+            else:
+                process = subprocess.Popen([
+                    "node", str(root / "tools/benchmark/browser/runner.mjs"), server.url,
+                    args.browser, str(args.width), str(args.height),
+                    str(args.device_scale_factor), str(args.timeout),
+                ], cwd=root, env=browser_environment(root), start_new_session=os.name == "posix",
+                    **({"stdout": log, "stderr": subprocess.STDOUT} if log is not None else {}))
+            deadline = time.monotonic() + args.timeout
+            while True:
+                if time.monotonic() >= deadline:
+                    raise RuntimeError("Browser run timed out before a complete report and browser shutdown")
+                if process is not None:
+                    code = process.poll()
+                    if code is not None:
+                        if code != 0:
+                            raise RuntimeError(f"Automated measurement browser exited with status {code}")
+                        if not server.completed.is_set():
+                            raise RuntimeError("Automated browser exited without a complete report")
+                        break
+                elif server.completed.is_set():
+                    break
+                # Keep serving through the report response and browser cleanup;
+                # a saved report alone does not prove the owned runner succeeded.
+                server.handle_request()
+        finally:
+            stop_browser_runner(process)
+
+
+def parser():
+    result = argparse.ArgumentParser(description=__doc__)
+    result.add_argument("--host", choices=("native", "web"), required=True)
+    result.add_argument("--profile", choices=("dev", "release"), default="release")
+    result.add_argument("--input", type=Path, required=True)
+    result.add_argument("--output", type=Path, required=True)
+    result.add_argument("--frames", type=int, default=180)
+    result.add_argument("--warmup", type=int, default=60)
+    result.add_argument("--workload", choices=("stationary", "orbit"), default="orbit")
+    result.add_argument("--mode", choices=("editor", "viewport", "renderer"), default="editor", help="complete editor, editor viewport without UI, or production scene renderer without UI/editor feedback")
+    result.add_argument("--selected", action="store_true")
+    result.add_argument("--width", type=int, default=1280, help="physical editor width in pixels")
+    result.add_argument("--height", type=int, default=800, help="physical editor height in pixels")
+    result.add_argument("--no-stage-timing", action="store_true", help="disable optional CPU stage instrumentation")
+    result.add_argument("--browser", choices=("headless", "headed", "manual"), default="headless", help="isolated automated Chromium, or an explicit manual browser session")
+    result.add_argument("--device-scale-factor", type=float, default=2.0, help="automated browser device scale factor, greater than zero and at most 4")
+    result.add_argument("--open", action="store_true", help="open the local browser page only in --browser manual mode")
+    result.add_argument("--timeout", type=int, default=600, help="runtime timeout in seconds, excluding build")
+    return result
+
+
+def validate_arguments(args, arguments):
+    """Shared option policy for single runs and repeated suites."""
+    if args.mode == "renderer" and args.selected:
+        arguments.error("--selected requires --mode editor or viewport; renderer mode excludes editor feedback")
+    if not 1 <= args.frames <= 10000 or not 0 <= args.warmup <= 10000:
+        arguments.error("frames must be 1..10000 and warmup 0..10000")
+    if not 64 <= args.width <= 8192 or not 64 <= args.height <= 8192:
+        arguments.error("physical width and height must be 64..8192")
+    if not 1 <= args.timeout <= 7200:
+        arguments.error("timeout must be 1..7200 seconds")
+    if not math.isfinite(args.device_scale_factor) or not 0 < args.device_scale_factor <= 4:
+        arguments.error("device scale factor must be finite, greater than zero, and at most 4")
+    if args.open and (args.host != "web" or args.browser != "manual"):
+        arguments.error("--open requires --host web --browser manual")
+    if args.host != "web" and args.browser != "headless":
+        arguments.error("--browser only applies to the web host")
+    args.input = args.input.expanduser().resolve()
+    args.output = args.output.expanduser().absolute()
+
+
+def run_prepared(args, artifact, info, *, log=None):
+    """Run one fresh host using an already built artifact, preserving raw evidence."""
+    if args.output.exists() or args.output.is_symlink():
+        raise RuntimeError("Output already exists; choose a new report path")
+    if args.host == "native":
+        environment = web.cargo_environment(ROOT)
+        environment.update({
+            "N3_VIEWPORT_MEASURE": json.dumps(options(args)),
+            "N3_VIEWPORT_MEASURE_METADATA": json.dumps(info),
+            "N3_VIEWPORT_MEASURE_OUTPUT": str(args.output),
+            "N3_VIEWPORT_MEASURE_SIZE": f"{args.width}x{args.height}",
+        })
+        subprocess.run([str(artifact), str(args.input)], cwd=ROOT, env=environment,
+                       check=True, timeout=args.timeout,
+                       **({"stdout": log, "stderr": subprocess.STDOUT} if log is not None else {}))
+    else:
+        run_browser(args, artifact, info, **({"log": log} if log is not None else {}))
+    if not args.output.is_file():
+        raise RuntimeError(f"{args.host.title()} run exited without writing its report")
+    if args.output.stat().st_size > MAX_REPORT_BYTES:
+        raise RuntimeError("Measurement report exceeds the size limit")
+    return validate_envelope(json.loads(args.output.read_text(encoding="utf-8")), options(args))
+
+
+def main(argv=None):
+    argv = sys.argv[1:] if argv is None else argv
+    if argv[:1] == ["setup-browser"]:
+        setup_parser = argparse.ArgumentParser(description="Install the optional isolated measurement browser")
+        setup_parser.parse_args(argv[1:])
+        try:
+            setup_browser()
+        except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
+            setup_parser.exit(1, f"Measurement browser setup failed: {error}\n")
+        return 0
+    arguments = parser()
+    args = arguments.parse_args(argv)
+    validate_arguments(args, arguments)
+    try:
+        if not args.input.is_file():
+            raise RuntimeError("Selected input must be an existing file")
+        if args.output.exists() or args.output.is_symlink():
+            raise RuntimeError("Output already exists; choose a new report path")
+        if args.host == "web" and args.browser != "manual":
+            require_browser_tools()
+        args.output.parent.mkdir(parents=True, exist_ok=True)
+        info = metadata(args)
+        artifact = build(args.host, args.profile)
+        run_prepared(args, artifact, info)
+        print(f"Measurement report: {args.output}", flush=True)
+    except KeyboardInterrupt:
+        return 130
+    except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
+        arguments.exit(1, f"Viewport measurement failed: {error}\n")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tools/benchmark/reports.py b/tools/benchmark/reports.py
new file mode 100644
index 0000000..1806329
--- /dev/null
+++ b/tools/benchmark/reports.py
@@ -0,0 +1,336 @@
+"""Auditable aggregation of independent viewport measurement runs.
+
+Frames in one process are correlated. Each accepted process contributes one median
+per metric; frames are never pooled into a pretend larger independent sample.
+"""
+
+from copy import deepcopy
+import hashlib
+import json
+from pathlib import Path
+
+from .contracts import (
+    STAGES,
+    VALIDITY_FIELDS,
+    canonical as _canonical,
+    contamination_reasons as _contamination,
+    number as _number,
+    require as _require,
+    suite_provenance as _suite_provenance,
+    validate_raw as _validate,
+)
+from .statistics import aggregate as _aggregate, distribution as _distribution
+
+SCHEMA = "n3.viewport-suite.v1"
+METHOD = {
+    "independent_unit": "one fresh application/browser process",
+    "estimate": "median of independent run medians; frames are not pooled",
+    "spread": "unscaled median absolute deviation of run medians; relative MAD = 100 * MAD / median",
+    "tails": "nearest-rank per-run p95 and maximum retained separately",
+    "slow_samples_removed": False,
+    "confidence_intervals": None,
+    "limitations": [
+        "Fewer than three accepted runs provide insufficient repeat evidence.",
+        "Ranges and MAD are descriptive, not confidence intervals or significance tests.",
+        "Fixed warmup frames do not prove steady state; short frame windows give sparse tail estimates.",
+        "CPU API timings do not establish GPU execution time or displayed FPS.",
+        "Frame-start cadence includes scheduling; semantic orbit bypasses device event delivery.",
+        "editor_projection is nested inside ui/editor_prepare; do not add it to enclosing stages.",
+        "Zero duration can reflect clock quantization; percentage changes from zero are undefined.",
+    ],
+}
+
+
+def _hash(value):
+    return hashlib.sha256(_canonical(value).encode()).hexdigest()
+
+
+def _differences(left, right, prefix=""):
+    if isinstance(left, dict) and isinstance(right, dict):
+        result = []
+        for key in sorted(left.keys() | right.keys()):
+            name = f"{prefix}.{key}" if prefix else key
+            if key not in left or key not in right:
+                result.append(name)
+            else:
+                result.extend(_differences(left[key], right[key], name))
+        return result
+    return [] if left == right else [prefix]
+
+
+def _identity(report):
+    options = report["options"]
+    return {
+        "host": report["metadata"]["host"], "mode": options["mode"],
+        "workload": options["workload"], "selected": options["selected"],
+        "instrument_stages": options["instrument_stages"],
+    }
+
+
+def _case_id(identity):
+    return "/".join((identity["host"], identity["mode"], identity["workload"],
+                     "selected" if identity["selected"] else "unselected",
+                     "stages" if identity["instrument_stages"] else "untimed"))
+
+
+def _configuration(report):
+    metadata = deepcopy(report["metadata"])
+    source = {name: metadata.pop(name) for name in ("revision", "source_fingerprint", "dirty")}
+    if "artifact_fingerprint" in metadata:
+        source["artifact_fingerprint"] = metadata.pop("artifact_fingerprint")
+    for name in ("recorded_at_utc", "loading", "suite"):
+        metadata.pop(name, None)
+    # Names identify provenance for readers; bytes/hash identify the workload.
+    metadata["input"].pop("name", None)
+    samples = report["samples"]
+    geometry = {name: samples[0][name] for name in ("surface_pixels", "viewport_pixels", "viewport_points", "pixels_per_point")}
+    # Normalize JSON's equivalent integer/float spellings before hashing matrices.
+    cameras = [[float(value) if value else 0.0 for value in sample["camera_view_projection"]] for sample in samples]
+    return source, {
+        "metadata": metadata, "options": report["options"], "render": report["render"],
+        "model": report["model"], "frame_geometry": geometry,
+        "camera_sequence_sha256": _hash(cameras),
+    }
+
+
+def _run_metrics(report):
+    samples = report["samples"]
+    metrics = {name: _distribution(sample[name] for sample in samples)
+               for name in ("cpu_frame_ms", "frame_start_interval_ms")}
+    for stage in STAGES:
+        values = [sample["projection"]["cpu_ms"] if stage == "editor_projection"
+                  else (sample["cpu_stage_ms"] or {}).get(stage) for sample in samples]
+        metrics[f"cpu_stage_ms.{stage}"] = _distribution(values)
+    return metrics
+
+
+def analyze(paths: list[Path]) -> dict:
+    """Read raw reports, exclude contaminated runs, and refuse incompatible repeats.
+
+    Malformed reports and duplicate inputs raise ValueError. Exclusions remain in
+    the returned receipt; a slow sample alone never makes a run contaminated.
+    """
+    _require(bool(paths), "at least one raw report is required")
+    cases, excluded, seen_paths, seen_content, seen_evidence = {}, [], set(), set(), set()
+    for path in paths:
+        path = Path(path).resolve()
+        _require(path not in seen_paths, f"duplicate report path: {path}")
+        seen_paths.add(path)
+        raw = path.read_bytes()
+        digest = hashlib.sha256(raw).hexdigest()
+        _require(digest not in seen_content, f"duplicate report contents: {path}; copies are not independent runs")
+        seen_content.add(digest)
+        try:
+            report = json.loads(raw)
+            _validate(report)
+        except (ValueError, TypeError, KeyError, OverflowError) as error:
+            raise ValueError(f"{path}: {error}") from error
+        evidence_digest = _hash(report)
+        _require(evidence_digest not in seen_evidence,
+                 f"duplicate report evidence: {path}; reformatted copies are not independent runs")
+        seen_evidence.add(evidence_digest)
+        identity = _identity(report)
+        case_id = _case_id(identity)
+        reference = {"path": str(path), "sha256": digest, "recorded_at_utc": report["metadata"]["recorded_at_utc"]}
+        suite_reasons, provenance = _suite_provenance(path, digest, report)
+        reference["suite_receipt"] = provenance
+        reasons = _contamination(report) + suite_reasons
+        if reasons:
+            excluded.append({**reference, "case_id": case_id, "reasons": reasons,
+                             "validity": report["validity"], "browser_observations": report.get("browser_observations")})
+            continue
+        source, configuration = _configuration(report)
+        if case_id in cases:
+            case = cases[case_id]
+            mismatch = _differences(case["configuration"], configuration, "configuration") + _differences(case["source"], source, "source")
+            _require(not mismatch, f"{path}: incompatible repeats for {case_id}: {', '.join(mismatch)}")
+        else:
+            case = {"id": case_id, "identity": identity, "configuration": configuration,
+                    "source": source, "fixture_name": report["metadata"]["input"].get("name"), "runs": []}
+            cases[case_id] = case
+        case["runs"].append({**reference, "frame_count": len(report["samples"]),
+                             "metrics": _run_metrics(report), "validity": report["validity"],
+                             "browser_observations": report.get("browser_observations"),
+                             "suite": report["metadata"].get("suite")})
+    for case in cases.values():
+        case["run_count"] = len(case["runs"])
+        case["frame_count"] = sum(run["frame_count"] for run in case["runs"])
+        case["metrics"] = {name: _aggregate([run["metrics"][name] for run in case["runs"]]) for name in case["runs"][0]["metrics"]}
+        case["warnings"] = []
+        if case["run_count"] < 3:
+            case["warnings"].append(f"Insufficient repeats: {case['run_count']} accepted run(s); use at least 3.")
+        if any(run["suite_receipt"] is None for run in case["runs"]):
+            case["warnings"].append("Standalone reports lack a completed suite receipt proving teardown and source/artifact stability.")
+        if case["identity"]["host"] == "web" and any(run["browser_observations"] is None for run in case["runs"]):
+            case["warnings"].append("Some reports lack browser visibility/focus/resize observation across the complete run.")
+    warnings = []
+    if excluded:
+        warnings.append(f"Excluded {len(excluded)} contaminated run(s); inspect excluded_runs and retained raw files.")
+    if not cases:
+        warnings.append("No accepted runs; this receipt cannot establish performance.")
+    return {"schema": SCHEMA, "method": deepcopy(METHOD), "input_run_count": len(paths),
+            "accepted_run_count": sum(case["run_count"] for case in cases.values()),
+            "excluded_run_count": len(excluded), "cases": [cases[key] for key in sorted(cases)],
+            "excluded_runs": excluded, "warnings": warnings}
+
+
+def render_text(summary: dict) -> str:
+    _require(summary.get("schema") == SCHEMA, "unsupported suite schema")
+    lines = ["Viewport measurement report", f"Accepted runs: {summary['accepted_run_count']}; excluded: {summary['excluded_run_count']}",
+             "Estimate: median of independent run medians (ms); slow samples are retained."]
+    lines += [f"WARNING: {warning}" for warning in summary["warnings"]]
+    for case in summary["cases"]:
+        geometry = case["configuration"]["frame_geometry"]
+        lines += ["", f"{case['id']} — {case['run_count']} run(s), {case['frame_count']} retained frames",
+                  f"Fixture: {case['fixture_name']}; surface {geometry['surface_pixels']}; scene {geometry['viewport_pixels']}",
+                  f"Source: {case['source']['revision']} / {case['source']['source_fingerprint']['sha256']}"]
+        lines += [f"WARNING: {warning}" for warning in case["warnings"]]
+        lines.append("metric | median | run-median range | relative MAD | per-run p95 range | worst frame")
+        for name, metric in case["metrics"].items():
+            if metric is None:
+                lines.append(f"{name} | unavailable/excluded")
+                continue
+            medians, tails = metric["run_medians"], metric["run_p95"]
+            spread = "undefined (zero median)" if medians["relative_mad_percent"] is None else f"{medians['relative_mad_percent']:.2f}%"
+            lines.append(f"{name} | {medians['median']:.3f} | {medians['min']:.3f}..{medians['max']:.3f} | {spread} | "
+                         f"{tails['min']:.3f}..{tails['max']:.3f} | {metric['run_max']['max']:.3f}")
+        for run in case["runs"]:
+            cpu, cadence = run["metrics"]["cpu_frame_ms"], run["metrics"]["frame_start_interval_ms"]
+            lines.append(f"Run: {run['path']} | CPU median/p95/max {cpu['median']:.3f}/{cpu['p95']:.3f}/{cpu['max']:.3f}"
+                         + (f" | cadence median/p95/max {cadence['median']:.3f}/{cadence['p95']:.3f}/{cadence['max']:.3f}" if cadence else " | cadence unavailable"))
+    for run in summary["excluded_runs"]:
+        lines += ["", f"EXCLUDED: {run['path']}", *[f"  {reason}" for reason in run["reasons"]]]
+    lines += ["", *[f"Note: {limitation}" for limitation in summary["method"]["limitations"]]]
+    return "\n".join(lines) + "\n"
+
+
+
+def _validate_summary(summary):
+    """Do not turn malformed or internally inconsistent saved summaries into deltas."""
+    try:
+        _require(isinstance(summary, dict) and summary.get("schema") == SCHEMA, "unsupported suite schema")
+        _canonical(summary)
+        _require(isinstance(summary.get("cases"), list), "missing summary cases")
+        _require(isinstance(summary.get("excluded_runs"), list), "missing excluded runs")
+        _require(isinstance(summary.get("warnings"), list) and all(isinstance(item, str) for item in summary["warnings"]),
+                 "malformed summary warnings")
+        for name in ("input_run_count", "accepted_run_count", "excluded_run_count"):
+            _require(type(summary.get(name)) is int and summary[name] >= 0, f"malformed {name}")
+        ids = set()
+        accepted = 0
+        expected_metrics = {"cpu_frame_ms", "frame_start_interval_ms"} | {f"cpu_stage_ms.{name}" for name in STAGES}
+        for case in summary["cases"]:
+            _require(isinstance(case, dict), "malformed case")
+            _require(isinstance(case.get("id"), str) and case["id"] not in ids, "missing or duplicate case id")
+            ids.add(case["id"])
+            for name in ("configuration", "source", "identity", "metrics"):
+                _require(isinstance(case.get(name), dict), f"malformed case {name}")
+            _require(case["id"] == _case_id(case["identity"]), "case id differs from identity")
+            configuration = case["configuration"]
+            for name in ("metadata", "options", "render", "model", "frame_geometry"):
+                _require(isinstance(configuration.get(name), dict) and configuration[name], f"missing configuration.{name}")
+            _require(isinstance(configuration.get("camera_sequence_sha256"), str) and len(configuration["camera_sequence_sha256"]) == 64,
+                     "missing camera sequence identity")
+            _require(configuration["metadata"].get("host") == case["identity"]["host"], "configuration host differs from case")
+            for name in ("mode", "workload", "selected", "instrument_stages"):
+                _require(configuration["options"].get(name) == case["identity"][name], f"configuration {name} differs from case")
+            _require(isinstance(case["source"].get("revision"), str) and isinstance(case["source"].get("source_fingerprint"), dict),
+                     "missing source identity")
+            _require(isinstance(case.get("warnings"), list) and all(isinstance(item, str) for item in case["warnings"]),
+                     "malformed case warnings")
+            _require(type(case.get("run_count")) is int and case["run_count"] > 0, "malformed case run_count")
+            _require(isinstance(case.get("runs"), list) and len(case["runs"]) == case["run_count"], "case run count differs from receipt")
+            _require(set(case["metrics"]) == expected_metrics, "missing or unknown summary metrics")
+            sample_frames = configuration["options"].get("sample_frames")
+            _require(type(sample_frames) is int and sample_frames > 0, "malformed requested frame count")
+            _require(type(case.get("frame_count")) is int and case["frame_count"] == sample_frames * case["run_count"],
+                     "case frame count differs from requested frames and runs")
+            for run in case["runs"]:
+                _require(isinstance(run, dict) and isinstance(run.get("metrics"), dict) and set(run["metrics"]) == expected_metrics,
+                         "missing or unknown per-run metrics")
+                _require(type(run.get("frame_count")) is int and run["frame_count"] == sample_frames,
+                         "run frame count differs from requested frames")
+                for distribution in run["metrics"].values():
+                    if distribution is None:
+                        continue
+                    _require(isinstance(distribution, dict), "malformed per-run distribution")
+                    _require(type(distribution.get("count")) is int and distribution["count"] > 0, "malformed distribution count")
+                    _require(distribution["count"] <= sample_frames, "distribution count exceeds retained frames")
+                    for name in ("min", "median", "p95", "max"):
+                        _number(distribution.get(name), f"distribution.{name}")
+                    _require(distribution["min"] <= distribution["median"] <= distribution["p95"] <= distribution["max"],
+                             "inconsistent per-run distribution")
+                cpu = run["metrics"]["cpu_frame_ms"]
+                _require(cpu is not None and cpu["count"] == sample_frames, "CPU distribution must retain every frame")
+            for name, metric in case["metrics"].items():
+                expected = _aggregate([run["metrics"][name] for run in case["runs"]])
+                _require(metric == expected, f"aggregate {name} differs from per-run metrics")
+            accepted += case["run_count"]
+        _require(accepted == summary["accepted_run_count"], "accepted run count differs from cases")
+        _require(len(summary["excluded_runs"]) == summary["excluded_run_count"], "excluded run count differs from receipt")
+        _require(summary["input_run_count"] == summary["accepted_run_count"] + summary["excluded_run_count"], "input run count differs from receipt")
+    except (KeyError, TypeError, AttributeError, OverflowError) as error:
+        raise ValueError(f"malformed suite summary: {error}") from error
+
+
+def compare(baseline: dict, candidate: dict) -> dict:
+    """Compare matching cases, allowing source changes but no workload/env drift."""
+    _validate_summary(baseline)
+    _validate_summary(candidate)
+    before = {case["id"]: case for case in baseline["cases"]}
+    after = {case["id"]: case for case in candidate["cases"]}
+    rows = []
+    for case_id in sorted(before.keys() | after.keys()):
+        row = {"id": case_id, "status": "unsupported", "reasons": [], "metrics": {}}
+        rows.append(row)
+        if case_id not in before or case_id not in after:
+            row["reasons"].append("case missing from " + ("baseline" if case_id not in before else "candidate"))
+            continue
+        left, right = before[case_id], after[case_id]
+        row.update({"baseline_source": left["source"], "candidate_source": right["source"],
+                    "baseline_run_count": left["run_count"], "candidate_run_count": right["run_count"],
+                    "warnings": left["warnings"] + right["warnings"]})
+        mismatch = _differences(left["configuration"], right["configuration"])
+        if mismatch:
+            row["reasons"] = [f"incompatible {name}" for name in mismatch]
+            continue
+        row["status"] = "comparable"
+        for name in sorted(left["metrics"].keys() | right["metrics"].keys()):
+            old, new = left["metrics"].get(name), right["metrics"].get(name)
+            metric = {"baseline": old, "candidate": new, "delta_ms": None, "delta_percent": None}
+            row["metrics"][name] = metric
+            if old is None or new is None:
+                metric["status"] = "unavailable"
+                continue
+            old_median, new_median = old["run_medians"]["median"], new["run_medians"]["median"]
+            metric["status"] = "descriptive"
+            metric["delta_ms"] = new_median - old_median
+            if old_median:
+                metric["delta_percent"] = 100 * (new_median - old_median) / old_median
+    if not rows:
+        rows.append({"id": "(suite)", "status": "unsupported", "reasons": ["no accepted cases in either receipt"], "metrics": {}})
+    return {"schema": "n3.viewport-comparison.v1", "cases": rows,
+            "warnings": baseline["warnings"] + candidate["warnings"],
+            "interpretation": "Positive delta means more time; negative means less. Descriptive comparison, not a significance test."}
+
+
+def render_comparison(comparison: dict) -> str:
+    _require(comparison.get("schema") == "n3.viewport-comparison.v1", "unsupported comparison schema")
+    lines = ["Viewport baseline/candidate comparison", comparison["interpretation"]]
+    lines += [f"WARNING: {warning}" for warning in comparison["warnings"]]
+    for case in comparison["cases"]:
+        lines += ["", f"{case['id']}: {case['status']}"]
+        lines += [f"  {reason}" for reason in case["reasons"]]
+        if case["status"] != "comparable":
+            continue
+        lines += [f"WARNING: {warning}" for warning in case["warnings"]]
+        lines.append("metric | baseline median [run range] | candidate median [run range] | delta")
+        for name, metric in case["metrics"].items():
+            if metric["status"] == "unavailable":
+                lines.append(f"{name} | unavailable/excluded")
+                continue
+            left, right = metric["baseline"]["run_medians"], metric["candidate"]["run_medians"]
+            delta = "undefined from zero baseline" if metric["delta_percent"] is None else f"{metric['delta_percent']:+.2f}%"
+            lines.append(f"{name} | {left['median']:.3f} [{left['min']:.3f}..{left['max']:.3f}] | "
+                         f"{right['median']:.3f} [{right['min']:.3f}..{right['max']:.3f}] | {delta} ({metric['delta_ms']:+.3f} ms)")
+    return "\n".join(lines) + "\n"
diff --git a/tools/benchmark/statistics.py b/tools/benchmark/statistics.py
new file mode 100644
index 0000000..b935cf0
--- /dev/null
+++ b/tools/benchmark/statistics.py
@@ -0,0 +1,43 @@
+"""Pure descriptive statistics for one run and independent-run aggregation.
+
+No I/O, report schema or performance decisions belong here. Missing stage values
+stay unavailable; zero durations remain observed data; slow values are retained.
+"""
+
+import math
+import statistics
+
+def distribution(values):
+    retained = sorted(value for value in values if value is not None)
+    if not retained:
+        return None
+    count = len(retained)
+    return {
+        "count": count,
+        "median": statistics.median(retained),
+        "p95": retained[math.ceil(count * 0.95) - 1],
+        "min": retained[0],
+        "max": retained[-1],
+    }
+
+
+def sample_range(values):
+    return {"median": statistics.median(values), "min": min(values), "max": max(values)}
+
+
+def aggregate(distributions):
+    present = [value for value in distributions if value is not None]
+    if not present:
+        return None
+    medians = [value["median"] for value in present]
+    result = sample_range(medians)
+    deviation = statistics.median(abs(value - result["median"]) for value in medians)
+    result.update({
+        "mad": deviation,
+        "relative_mad_percent": 100 * deviation / result["median"] if result["median"] else None,
+    })
+    return {
+        "unit": "ms", "run_count": len(present), "run_medians": result,
+        "run_p95": sample_range([value["p95"] for value in present]),
+        "run_max": sample_range([value["max"] for value in present]),
+    }
diff --git a/tools/benchmark/suite.py b/tools/benchmark/suite.py
new file mode 100644
index 0000000..78c68cc
--- /dev/null
+++ b/tools/benchmark/suite.py
@@ -0,0 +1,273 @@
+"""Repeat real viewport measurements and report run-level variation or comparisons."""
+
+import argparse
+from contextlib import redirect_stderr, redirect_stdout
+from datetime import datetime, timezone
+import hashlib
+import json
+from pathlib import Path
+import random
+import shutil
+import subprocess
+import sys
+
+from . import measure, reports
+
+
+def parser():
+    result = argparse.ArgumentParser(description=__doc__)
+    commands = result.add_subparsers(dest="command", required=True)
+    run = commands.add_parser("run", help="build once, repeat fresh serial host sessions, and aggregate")
+    run.add_argument("--input", type=Path, required=True)
+    run.add_argument("--output", type=Path, required=True, help="new directory for raw reports, logs, and summaries")
+    run.add_argument("--hosts", nargs="+", choices=("native", "web"), default=["native", "web"])
+    run.add_argument("--modes", nargs="+", choices=("editor", "viewport", "renderer"), default=["editor", "viewport", "renderer"])
+    run.add_argument("--repeats", type=int, default=5)
+    run.add_argument("--seed", type=int, default=0, help="reproducible shuffled case order within each round")
+    run.add_argument("--profile", choices=("dev", "release"), default="release")
+    run.add_argument("--frames", type=int, default=180)
+    run.add_argument("--warmup", type=int, default=60)
+    run.add_argument("--workload", choices=("orbit", "stationary"), default="orbit")
+    run.add_argument("--selected", action="store_true")
+    run.add_argument("--width", type=int, default=1280, help="physical editor surface pixels, not inner viewport width")
+    run.add_argument("--height", type=int, default=800)
+    run.add_argument("--no-stage-timing", action="store_true")
+    run.add_argument("--browser", choices=("headed", "headless"), default="headed")
+    run.add_argument("--device-scale-factor", type=float, default=2.0)
+    run.add_argument("--timeout", type=int, default=600, help="seconds per session, excluding builds")
+    report = commands.add_parser("report", help="recompute a summary from saved raw measurement JSON files")
+    report.add_argument("inputs", nargs="+", type=Path)
+    report.add_argument("--output", type=Path, required=True, help="new summary directory")
+    compare = commands.add_parser("compare", help="describe baseline/candidate changes for compatible cases")
+    compare.add_argument("baseline", type=Path)
+    compare.add_argument("candidate", type=Path)
+    compare.add_argument("--output", type=Path, required=True, help="new comparison directory")
+    return result
+
+
+def timestamp():
+    return datetime.now(timezone.utc).isoformat()
+
+
+def plan_cases(args):
+    cases = [(host, mode) for host in args.hosts for mode in args.modes]
+    generator = random.Random(args.seed)
+    plan = []
+    for repeat in range(1, args.repeats + 1):
+        order = list(cases)
+        generator.shuffle(order)
+        for host, mode in order:
+            name = f"{host}-{mode}-{repeat:02d}"
+            plan.append({"host": host, "mode": mode, "repeat": repeat,
+                         "report": f"raw/{name}.json", "log": f"logs/{name}.log",
+                         "status": "pending"})
+    return plan
+
+
+def session_arguments(args, entry, arguments):
+    values = vars(args).copy()
+    values.update(host=entry["host"], mode=entry["mode"],
+                  output=args.output / entry["report"], open=False,
+                  browser=args.browser if entry["host"] == "web" else "headless")
+    session = argparse.Namespace(**values)
+    measure.validate_arguments(session, arguments)
+    return session
+
+
+def new_output(path):
+    path = path.expanduser().absolute()
+    # A suite writes evidence while checking source identity. Nonignored output
+    # inside the checkout would contaminate that identity on the first run.
+    try:
+        path.resolve().relative_to(measure.ROOT.resolve())
+    except ValueError:
+        pass
+    else:
+        ignored = subprocess.run(["git", "check-ignore", "--quiet", str(path)], cwd=measure.ROOT)
+        if ignored.returncode != 0:
+            raise ValueError("Benchmark output inside the repository must be ignored; use .cache/measure/")
+    path.mkdir(parents=True, exist_ok=False)
+    return path
+
+
+def write_json(path, value):
+    measure.write_report(path, value)
+
+
+def save_manifest(directory, manifest):
+    temporary = directory / "manifest.tmp"
+    temporary.write_text(json.dumps(manifest, indent=2, allow_nan=False) + "\n", encoding="utf-8")
+    temporary.replace(directory / "manifest.json")
+
+
+def summarize(paths, output):
+    summary = reports.analyze(paths)
+    rendered = reports.render_text(summary)
+    write_json(output / "summary.json", summary)
+    (output / "summary.txt").write_text(rendered, encoding="utf-8")
+    print(rendered)
+    return summary
+
+
+def unchanged(before, after):
+    for name in ("source_fingerprint", "input", "rustc", "profile", "rustflags",
+                 "cargo_encoded_rustflags", "profile_overrides", "operating_system", "architecture"):
+        if before[name] != after[name]:
+            raise RuntimeError(f"{name} changed during the suite; saved evidence is retained, start a new suite")
+
+
+def artifact_fingerprint(artifact):
+    files = sorted(artifact.rglob("*")) if artifact.is_dir() else [artifact]
+    entries = []
+    for path in files:
+        if path.is_file():
+            digest = hashlib.sha256()
+            with path.open("rb") as source:
+                for chunk in iter(lambda: source.read(1024 * 1024), b""):
+                    digest.update(chunk)
+            entries.append({"path": str(path.relative_to(artifact)) if artifact.is_dir() else path.name,
+                            "sha256": digest.hexdigest(), "bytes": path.stat().st_size,
+                            "mode": path.stat().st_mode & 0o777})
+    if not entries:
+        raise RuntimeError(f"Built artifact is missing or empty: {artifact}")
+    return {"sha256": hashlib.sha256(json.dumps(entries, sort_keys=True).encode()).hexdigest(), "files": entries}
+
+
+def snapshot_artifact(artifact, destination):
+    """Freeze this build so another Cargo/web build cannot replace a later repeat."""
+    before = artifact_fingerprint(artifact)
+    if artifact.is_dir():
+        shutil.copytree(artifact, destination)
+        snapshot = destination
+    else:
+        destination.mkdir(parents=True)
+        snapshot = destination / artifact.name
+        shutil.copy2(artifact, snapshot)
+    if artifact_fingerprint(artifact) != before or artifact_fingerprint(snapshot) != before:
+        raise RuntimeError("Built artifact changed while copying; start a new suite")
+    return snapshot, before
+
+
+def run_suite(args, arguments):
+    if not 1 <= args.repeats <= 100:
+        arguments.error("repeats must be 1..100")
+    if len(set(args.hosts)) != len(args.hosts) or len(set(args.modes)) != len(args.modes):
+        arguments.error("hosts and modes must not contain duplicates")
+    args.input = args.input.expanduser().resolve()
+    args.output = args.output.expanduser().absolute()
+    plan = plan_cases(args)
+    # Validate every case before creating output, building, or opening a window.
+    for entry in plan:
+        session_arguments(args, entry, arguments)
+    if not args.input.is_file():
+        raise ValueError("Selected input must be an existing file")
+    if "web" in args.hosts:
+        measure.require_browser_tools()
+    args.output = new_output(args.output)
+    (args.output / "raw").mkdir()
+    (args.output / "logs").mkdir()
+    manifest = {"schema": "n3.viewport-suite-manifest.v1", "started_at_utc": timestamp(),
+                "status": "running", "seed": args.seed, "repeats": args.repeats,
+                "ordering": "serial seeded shuffle within each round",
+                "configuration": {key: str(value) if isinstance(value, Path) else value
+                                  for key, value in vars(args).items()},
+                "runs": plan, "builds": []}
+    completed = []
+    failure = None
+    exit_code = 0
+    save_manifest(args.output, manifest)
+    try:
+        base = measure.metadata(session_arguments(args, plan[0], arguments))
+        manifest["source"] = base
+        artifacts = {}
+        fingerprints = {}
+        for host in args.hosts:
+            print(f"Building {host} ({args.profile}); build time is excluded.", flush=True)
+            record = {"host": host, "log": f"logs/build-{host}.log", "status": "running"}
+            manifest["builds"].append(record)
+            save_manifest(args.output, manifest)
+            with (args.output / record["log"]).open("x", encoding="utf-8") as log:
+                with redirect_stdout(log), redirect_stderr(log):
+                    artifact = measure.build(host, args.profile, log=log)
+            artifacts[host], fingerprints[host] = snapshot_artifact(artifact, args.output / "artifacts" / host)
+            record.update(status="complete", artifact=str(artifacts[host].relative_to(args.output)),
+                          artifact_fingerprint=fingerprints[host])
+        unchanged(base, measure.metadata(session_arguments(args, plan[0], arguments)))
+        save_manifest(args.output, manifest)
+        for index, entry in enumerate(plan):
+            session = session_arguments(args, entry, arguments)
+            info = measure.metadata(session)
+            unchanged(base, info)
+            if artifact_fingerprint(artifacts[entry["host"]]) != fingerprints[entry["host"]]:
+                raise RuntimeError("Prepared artifact changed before a session; start a new suite")
+            info["artifact_fingerprint"] = fingerprints[entry["host"]]
+            info["suite"] = {"round": entry["repeat"], "position": index + 1, "seed": args.seed}
+            entry.update(status="running", started_at_utc=timestamp())
+            save_manifest(args.output, manifest)
+            print(f"[{index + 1}/{len(plan)}] {entry['host']} / {entry['mode']}, repeat {entry['repeat']}", flush=True)
+            with (args.output / entry["log"]).open("x", encoding="utf-8") as log:
+                with redirect_stdout(log), redirect_stderr(log):
+                    measure.run_prepared(session, artifacts[entry["host"]], info, log=log)
+            unchanged(base, measure.metadata(session))
+            if artifact_fingerprint(artifacts[entry["host"]]) != fingerprints[entry["host"]]:
+                raise RuntimeError("Prepared artifact changed during a session; start a new suite")
+            entry.update(status="complete", finished_at_utc=timestamp(),
+                         sha256=hashlib.sha256(session.output.read_bytes()).hexdigest())
+            completed.append(session.output)
+            save_manifest(args.output, manifest)
+    except (OSError, RuntimeError, ValueError, subprocess.SubprocessError, KeyboardInterrupt) as error:
+        failure = str(error) or type(error).__name__
+        exit_code = 130 if isinstance(error, KeyboardInterrupt) else 1
+        for entry in [*manifest["builds"], *plan]:
+            if entry["status"] == "running":
+                entry.update(status="failed", error=failure)
+        print(f"Benchmark stopped: {failure}. Evidence retained in {args.output}", file=sys.stderr)
+    finally:
+        manifest.update(status="failed" if exit_code else "aggregating", error=failure)
+        save_manifest(args.output, manifest)
+    # A failed process can still leave JSON (for example, browser teardown failure).
+    # Only sessions with successful completion and unchanged source are aggregated.
+    if completed:
+        try:
+            summary = summarize(completed, args.output)
+        except (OSError, ValueError) as error:
+            manifest.update(status="failed", finished_at_utc=timestamp(), error=f"Aggregation failed: {error}")
+            save_manifest(args.output, manifest)
+            raise
+        if summary["excluded_runs"]:
+            exit_code = exit_code or 1
+            manifest["status"] = "contaminated" if not failure else "failed"
+    if manifest["status"] == "aggregating":
+        manifest["status"] = "complete"
+    manifest["finished_at_utc"] = timestamp()
+    save_manifest(args.output, manifest)
+    return exit_code
+
+
+def main(argv=None):
+    arguments = parser()
+    args = arguments.parse_args(argv)
+    try:
+        if args.command == "run":
+            return run_suite(args, arguments)
+        if args.command == "report":
+            output = new_output(args.output)
+            summary = summarize(args.inputs, output)
+            return int(bool(summary["excluded_runs"]))
+        baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
+        candidate = json.loads(args.candidate.read_text(encoding="utf-8"))
+        comparison = reports.compare(baseline, candidate)
+        output = new_output(args.output)
+        write_json(output / "comparison.json", comparison)
+        rendered = reports.render_comparison(comparison)
+        (output / "comparison.txt").write_text(rendered, encoding="utf-8")
+        print(rendered)
+        return int(any(case["status"] != "comparable" for case in comparison["cases"]))
+    except KeyboardInterrupt:
+        return 130
+    except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
+        arguments.exit(1, f"Viewport benchmark failed: {error}\n")
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tools/benchmark/tests/__init__.py b/tools/benchmark/tests/__init__.py
new file mode 100644
index 0000000..223fe32
--- /dev/null
+++ b/tools/benchmark/tests/__init__.py
@@ -0,0 +1 @@
+"""Benchmark trust and lifecycle regressions; no installed browser required."""
diff --git a/tools/benchmark/tests/browser.test.mjs b/tools/benchmark/tests/browser.test.mjs
new file mode 100644
index 0000000..6557002
--- /dev/null
+++ b/tools/benchmark/tests/browser.test.mjs
@@ -0,0 +1,111 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+  clearExternalControl,
+  parseOptions,
+  recordedLaunchArguments,
+} from "../browser/policy.mjs";
+import { observeRun } from "../browser/observations.mjs";
+
+test("runner admits only explicit isolated local measurements and bounded dimensions", () => {
+  const valid = [
+    "http://127.0.0.1:1234/token/measure.html",
+    "headed",
+    "1280",
+    "800",
+    "2",
+    "600",
+  ];
+  const result = parseOptions(valid);
+  assert.equal(result.timeout, 600000);
+  assert.equal(result.width, 1280);
+  for (const [index, value] of [
+    [0, "https://127.0.0.1/page"],
+    [0, "http://example.com/page"],
+    [0, "http://user:password@127.0.0.1/page"],
+    [1, "manual"],
+    [2, "63"],
+    [2, "8193"],
+    [2, "1280.5"],
+    [3, "NaN"],
+    [4, "0"],
+    [4, "Infinity"],
+    [4, "4.1"],
+    [5, "7201"],
+  ]) {
+    const arguments_ = [...valid];
+    arguments_[index] = value;
+    assert.throws(() => parseOptions(arguments_));
+  }
+  assert.throws(() => parseOptions(valid.slice(1)));
+});
+
+test("ambient remote or debugger aliases cannot redirect the owned browser", () => {
+  const environment = {
+    PATH: "preserved",
+    SELENIUM_REMOTE_URL: "remote",
+    PWDEBUG: "1",
+    npm_config_selenium_remote_url: "remote",
+    npm_package_config_selenium_remote_url: "remote",
+    npm_config_pwdebug: "1",
+    npm_package_config_pwdebug: "1",
+  };
+  clearExternalControl(environment);
+  assert.deepEqual(environment, { PATH: "preserved" });
+  assert.deepEqual(
+    recordedLaunchArguments([
+      "--headless",
+      "--user-data-dir=/private/profile",
+      "--force-device-scale-factor=2",
+    ]),
+    [
+      "--headless",
+      "--user-data-dir=[temporary-profile]",
+      "--force-device-scale-factor=2",
+    ],
+  );
+});
+
+function environment() {
+  return {
+    window: Object.assign(new EventTarget(), { devicePixelRatio: 2 }),
+    document: new EventTarget(),
+    canvas: { width: 1280, height: 800 },
+    width: 1280,
+    height: 800,
+  };
+}
+
+test("focus, visibility, and transient size changes cannot be hidden by recovery", () => {
+  const env = environment();
+  const run = observeRun(env);
+  env.window.dispatchEvent(new Event("blur"));
+  env.document.dispatchEvent(new Event("visibilitychange"));
+  env.document.dispatchEvent(new Event("visibilitychange"));
+  env.canvas.width = 640;
+  run.sample();
+  env.canvas.width = 1280;
+  assert.deepEqual(run.finish(), {
+    visibility_changes: 2,
+    focus_losses: 1,
+    size_changed: true,
+    comparable: false,
+  });
+});
+
+test("clean runs stay comparable; DPR changes are sampled at finish and listeners are removed", () => {
+  const env = environment();
+  const first = observeRun(env);
+  const clean = first.finish();
+  assert.deepEqual(clean, {
+    visibility_changes: 0,
+    focus_losses: 0,
+    size_changed: false,
+    comparable: true,
+  });
+  env.window.dispatchEvent(new Event("blur"));
+  assert.deepEqual(first.finish(), clean);
+  const next = observeRun(env);
+  env.window.devicePixelRatio = 1;
+  assert.equal(next.finish().size_changed, true);
+});
diff --git a/tools/benchmark/tests/browser_lifecycle.test.mjs b/tools/benchmark/tests/browser_lifecycle.test.mjs
new file mode 100644
index 0000000..eb009cf
--- /dev/null
+++ b/tools/benchmark/tests/browser_lifecycle.test.mjs
@@ -0,0 +1,182 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+
+// Keep the real runner CLI and ownership path. Only Playwright is replaced;
+// an owned keepalive simulates Chromium remaining alive during a stalled API.
+const playwright = `
+let keepalive;
+let saved;
+async function stage(name) {
+  if (name === process.env.N3_TEST_STALL_STAGE) {
+    console.log("STALLED " + name);
+    await new Promise(() => {});
+  }
+}
+const page = {
+  setDefaultTimeout() {}, on() {},
+  async addInitScript() { await stage("addInitScript"); },
+  async exposeFunction(_name, callback) {
+    await stage("exposeFunction"); saved = callback;
+  },
+  async goto() {}, async waitForFunction() {},
+  getByRole() { return { async click() { saved(); } }; },
+};
+const browser = {
+  version() { return "test Chromium"; },
+  async newContext() {
+    await stage("newContext");
+    return { async newPage() { await stage("newPage"); return page; } };
+  },
+  async newBrowserCDPSession() {
+    await stage("cdpSession");
+    return {
+      async send() {
+        await stage("cdpSend");
+        return { gpu: { devices: [], featureStatus: {} } };
+      },
+      async detach() { await stage("cdpDetach"); },
+    };
+  },
+};
+export const chromium = {
+  async launchServer() {
+    keepalive = setInterval(() => {}, 1000);
+    return {
+      wsEndpoint() { return "ws://127.0.0.1/owned"; },
+      process() { return { spawnargs: ["test Chromium"] }; },
+      async close() { console.log("OWNED_CLOSED"); clearInterval(keepalive); },
+      async kill() { console.log("OWNED_KILLED"); clearInterval(keepalive); },
+    };
+  },
+  async connect() { return browser; },
+};
+`;
+
+async function runnerFixture(t) {
+  const root = await mkdtemp(join(tmpdir(), "n3-browser-lifecycle-"));
+  t.after(() => rm(root, { recursive: true, force: true }));
+  const runnerDirectory = join(root, "tools/benchmark/browser");
+  const installation = join(
+    root,
+    ".cache/viewport-tools/node_modules/playwright",
+  );
+  await mkdir(runnerDirectory, { recursive: true });
+  await mkdir(installation, { recursive: true });
+  for (const name of ["runner.mjs", "policy.mjs"]) {
+    await copyFile(
+      new URL(`../browser/${name}`, import.meta.url),
+      join(runnerDirectory, name),
+    );
+  }
+  await writeFile(
+    join(installation, "package.json"),
+    JSON.stringify({ version: "1.63.0" }),
+  );
+  await writeFile(join(installation, "index.mjs"), playwright);
+  return join(runnerDirectory, "runner.mjs");
+}
+
+async function bounded(promise, milliseconds) {
+  let timer;
+  try {
+    return await Promise.race([
+      promise,
+      new Promise((_, reject) => {
+        timer = setTimeout(
+          () => reject(new Error("Runner did not close promptly")),
+          milliseconds,
+        );
+      }),
+    ]);
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+function run(t, runner, phase, timeout = 30) {
+  const child = spawn(
+    process.execPath,
+    [
+      runner,
+      "http://127.0.0.1:1234/token/measure.html",
+      "headless",
+      "1280",
+      "800",
+      "2",
+      String(timeout),
+    ],
+    {
+      env: { ...process.env, N3_TEST_STALL_STAGE: phase },
+      stdio: ["ignore", "pipe", "pipe"],
+    },
+  );
+  let stdout = "";
+  let stderr = "";
+  const closed = new Promise((resolve, reject) => {
+    child.once("error", reject);
+    child.once("close", (code, signal) => resolve({ code, signal }));
+  });
+  const stalled = new Promise((resolve, reject) => {
+    child.stdout.on("data", (data) => {
+      stdout += data;
+      if (stdout.includes(`STALLED ${phase}`)) resolve();
+    });
+    closed.then(
+      () =>
+        reject(new Error(`Runner exited before the setup stall: ${stderr}`)),
+      reject,
+    );
+  });
+  child.stderr.on("data", (data) => {
+    stderr += data;
+  });
+  t.after(async () => {
+    if (child.exitCode === null && child.signalCode === null) {
+      child.kill("SIGKILL");
+      await closed;
+    }
+  });
+  return { child, closed, stalled, output: () => ({ stdout, stderr }) };
+}
+
+test("SIGTERM closes the owned browser during every setup await", async (t) => {
+  const runner = await runnerFixture(t);
+  for (const phase of [
+    "newContext",
+    "newPage",
+    "cdpSession",
+    "cdpSend",
+    "cdpDetach",
+    "addInitScript",
+    "exposeFunction",
+  ]) {
+    await t.test(phase, async (t) => {
+      const session = run(t, runner, phase);
+      await bounded(session.stalled, 4000);
+      session.child.kill("SIGTERM");
+      assert.deepEqual(await bounded(session.closed, 4000), {
+        code: 1,
+        signal: null,
+      });
+      assert.match(session.output().stdout, /OWNED_CLOSED/);
+      assert.doesNotMatch(session.output().stdout, /OWNED_KILLED/);
+      assert.match(session.output().stderr, /Browser measurement interrupted/);
+    });
+  }
+});
+
+test("watchdog closes the owned browser when setup never resolves", async (t) => {
+  const runner = await runnerFixture(t);
+  const session = run(t, runner, "newContext", 1);
+  await bounded(session.stalled, 4000);
+  assert.deepEqual(await bounded(session.closed, 4000), {
+    code: 1,
+    signal: null,
+  });
+  assert.match(session.output().stdout, /OWNED_CLOSED/);
+  assert.match(session.output().stderr, /Browser measurement timed out/);
+});
diff --git a/tools/benchmark/tests/fixtures/README.md b/tools/benchmark/tests/fixtures/README.md
new file mode 100644
index 0000000..87f3d45
--- /dev/null
+++ b/tools/benchmark/tests/fixtures/README.md
@@ -0,0 +1,17 @@
+# Measurement contract fixtures
+
+These are authored test data, not captured performance results.
+
+- `measurement-schema.json` lists the public v2 sample, stage, counter, and
+  validity field names. Rust checks actual serialization against it; Python
+  checks its independent admission schema and test records against it.
+- `editor-capture.json` is a small synthetic editor record with three CPU frame
+  times: 10, 20, and 90 ms. Its deliberately incorrect precomputed summary must
+  be ignored. The CLI test changes explicit raw values to establish known
+  medians, tails, and before/after deltas without using the production statistics
+  functions to derive expected answers. Metadata and camera matrices are
+  placeholders for schema/aggregation tests, not a claim about real hardware.
+
+Review changes to these fixtures with their assertions. Do not automatically
+update them after a failing test. Use real host captures separately to verify
+collection, platform behavior, and renderer execution.
diff --git a/tools/benchmark/tests/fixtures/editor-capture.json b/tools/benchmark/tests/fixtures/editor-capture.json
new file mode 100644
index 0000000..bd6fa86
--- /dev/null
+++ b/tools/benchmark/tests/fixtures/editor-capture.json
@@ -0,0 +1,249 @@
+{
+  "schema": "n3.viewport-measure.v2",
+  "metadata": {
+    "host": "native",
+    "adapter": {
+      "name": "test GPU",
+      "backend": "Metal",
+      "device_type": "IntegratedGpu",
+      "vendor": 0,
+      "device": 0,
+      "driver": "",
+      "driver_info": ""
+    },
+    "architecture": "arm64",
+    "browser_mode": null,
+    "build_command": ["cargo", "build", "--release"],
+    "cargo_encoded_rustflags": null,
+    "dirty": true,
+    "input": {
+      "name": "test.glb",
+      "bytes": 100,
+      "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "operating_system": "test OS",
+    "profile": "release",
+    "profile_overrides": {},
+    "recorded_at_utc": "2026-10-02T00:00:01Z",
+    "requested_device_scale_factor": null,
+    "requested_physical_size": [1280, 800],
+    "revision": "test revision",
+    "rustc": "test compiler",
+    "rustflags": null,
+    "source_fingerprint": {
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "paths": 10,
+      "scope": "source"
+    },
+    "surface": {
+      "width": 1280,
+      "height": 800,
+      "present_mode": "Fifo",
+      "scale_factor": 2,
+      "format": "Bgra8Unorm",
+      "alpha_mode": "Opaque",
+      "desired_maximum_frame_latency": 2
+    },
+    "loading": {
+      "duration": 100
+    }
+  },
+  "options": {
+    "mode": "editor",
+    "workload": "orbit",
+    "selected": false,
+    "instrument_stages": true,
+    "sample_frames": 3,
+    "warmup_frames": 20
+  },
+  "model": {
+    "vertices": 100,
+    "triangles": 150,
+    "faces": 0,
+    "objects": 1,
+    "linked_assets": 1
+  },
+  "render": {
+    "baseline_viewport_points": [240, 0, 400, 368.5],
+    "pixels_per_point": 2,
+    "initial_camera_view_projection": [
+      0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
+    ],
+    "shading": "solid",
+    "grid": true,
+    "edges": true,
+    "theme": "light",
+    "selected_objects": 0,
+    "hovered_object": null,
+    "baseline_viewport_ui_points": [240, 0, 400, 368.5]
+  },
+  "validity": {
+    "all_frames_focused": true,
+    "constant_surface": true,
+    "constant_viewport": true,
+    "mode_contract_satisfied": true,
+    "no_frame_errors": true,
+    "unchanged_mesh_revision": true,
+    "viewport_matches_baseline": true
+  },
+  "samples": [
+    {
+      "frame": 0,
+      "cpu_frame_ms": 10,
+      "frame_start_interval_ms": 11,
+      "camera_view_projection": [
+        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
+      ],
+      "counters": {
+        "mesh_uploads": 0,
+        "base_mesh_upload_bytes": 0,
+        "viewport_resizes": 0,
+        "ui_jobs": 1,
+        "egui_passes": 1,
+        "egui_tessellations": 1,
+        "egui_texture_updates": 0,
+        "egui_composites": 1,
+        "editor_prepares": 1,
+        "editor_feedback_updates": 1,
+        "scene_renders": 1,
+        "scene_composites": 0
+      },
+      "cpu_stage_ms": {
+        "surface_acquire": 0,
+        "host_prepare": 0,
+        "cache_sync": 0,
+        "scene_encode": 1.0,
+        "submit_api": 0,
+        "present_api": 0,
+        "host_tail": 0,
+        "ui": 5.0,
+        "commands_and_refresh": 0,
+        "tessellation_and_textures": 0,
+        "feedback": 0,
+        "ui_encode": 0,
+        "editor_prepare": null,
+        "scene_composite": null
+      },
+      "error": null,
+      "focused": true,
+      "mesh_revision": 1,
+      "pixels_per_point": 2,
+      "projection": {
+        "cpu_ms": 2.5,
+        "geometry_rebuilds": 0,
+        "rebuilds": 0,
+        "vertices_tested": 0
+      },
+      "surface_pixels": [1280, 800],
+      "viewport_pixels": [320, 737],
+      "viewport_points": [240, 0, 400, 368.5]
+    },
+    {
+      "frame": 1,
+      "cpu_frame_ms": 20,
+      "frame_start_interval_ms": 21,
+      "camera_view_projection": [
+        2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2
+      ],
+      "counters": {
+        "mesh_uploads": 0,
+        "base_mesh_upload_bytes": 0,
+        "viewport_resizes": 0,
+        "ui_jobs": 1,
+        "egui_passes": 1,
+        "egui_tessellations": 1,
+        "egui_texture_updates": 0,
+        "egui_composites": 1,
+        "editor_prepares": 1,
+        "editor_feedback_updates": 1,
+        "scene_renders": 1,
+        "scene_composites": 0
+      },
+      "cpu_stage_ms": {
+        "surface_acquire": 0,
+        "host_prepare": 0,
+        "cache_sync": 0,
+        "scene_encode": 2.0,
+        "submit_api": 0,
+        "present_api": 0,
+        "host_tail": 0,
+        "ui": 10.0,
+        "commands_and_refresh": 0,
+        "tessellation_and_textures": 0,
+        "feedback": 0,
+        "ui_encode": 0,
+        "editor_prepare": null,
+        "scene_composite": null
+      },
+      "error": null,
+      "focused": true,
+      "mesh_revision": 1,
+      "pixels_per_point": 2,
+      "projection": {
+        "cpu_ms": 5.0,
+        "geometry_rebuilds": 0,
+        "rebuilds": 0,
+        "vertices_tested": 0
+      },
+      "surface_pixels": [1280, 800],
+      "viewport_pixels": [320, 737],
+      "viewport_points": [240, 0, 400, 368.5]
+    },
+    {
+      "frame": 2,
+      "cpu_frame_ms": 90,
+      "frame_start_interval_ms": 91,
+      "camera_view_projection": [
+        3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3
+      ],
+      "counters": {
+        "mesh_uploads": 0,
+        "base_mesh_upload_bytes": 0,
+        "viewport_resizes": 0,
+        "ui_jobs": 1,
+        "egui_passes": 1,
+        "egui_tessellations": 1,
+        "egui_texture_updates": 0,
+        "egui_composites": 1,
+        "editor_prepares": 1,
+        "editor_feedback_updates": 1,
+        "scene_renders": 1,
+        "scene_composites": 0
+      },
+      "cpu_stage_ms": {
+        "surface_acquire": 0,
+        "host_prepare": 0,
+        "cache_sync": 0,
+        "scene_encode": 9.0,
+        "submit_api": 0,
+        "present_api": 0,
+        "host_tail": 0,
+        "ui": 45.0,
+        "commands_and_refresh": 0,
+        "tessellation_and_textures": 0,
+        "feedback": 0,
+        "ui_encode": 0,
+        "editor_prepare": null,
+        "scene_composite": null
+      },
+      "error": null,
+      "focused": true,
+      "mesh_revision": 1,
+      "pixels_per_point": 2,
+      "projection": {
+        "cpu_ms": 22.5,
+        "geometry_rebuilds": 0,
+        "rebuilds": 0,
+        "vertices_tested": 0
+      },
+      "surface_pixels": [1280, 800],
+      "viewport_pixels": [320, 737],
+      "viewport_points": [240, 0, 400, 368.5]
+    }
+  ],
+  "summary": {
+    "cpu_frame_ms": {
+      "median": 99999999
+    }
+  }
+}
diff --git a/tools/benchmark/tests/fixtures/measurement-schema.json b/tools/benchmark/tests/fixtures/measurement-schema.json
new file mode 100644
index 0000000..3834164
--- /dev/null
+++ b/tools/benchmark/tests/fixtures/measurement-schema.json
@@ -0,0 +1,60 @@
+{
+  "schema": "n3.viewport-measure.v2",
+  "stage_names": [
+    "cache_sync",
+    "commands_and_refresh",
+    "editor_prepare",
+    "feedback",
+    "host_prepare",
+    "host_tail",
+    "present_api",
+    "scene_composite",
+    "scene_encode",
+    "submit_api",
+    "surface_acquire",
+    "tessellation_and_textures",
+    "ui",
+    "ui_encode"
+  ],
+  "counter_names": [
+    "base_mesh_upload_bytes",
+    "editor_feedback_updates",
+    "editor_prepares",
+    "egui_composites",
+    "egui_passes",
+    "egui_tessellations",
+    "egui_texture_updates",
+    "mesh_uploads",
+    "scene_composites",
+    "scene_renders",
+    "ui_jobs",
+    "viewport_resizes"
+  ],
+  "sample_fields": [
+    "camera_view_projection",
+    "counters",
+    "cpu_frame_ms",
+    "cpu_stage_ms",
+    "error",
+    "focused",
+    "fps_meter_enabled",
+    "frame",
+    "frame_start_interval_ms",
+    "mesh_revision",
+    "pixels_per_point",
+    "projection",
+    "surface_pixels",
+    "viewport_pixels",
+    "viewport_points"
+  ],
+  "validity_fields": [
+    "all_frames_focused",
+    "constant_surface",
+    "constant_viewport",
+    "fps_meter_constant",
+    "mode_contract_satisfied",
+    "no_frame_errors",
+    "unchanged_mesh_revision",
+    "viewport_matches_baseline"
+  ]
+}
diff --git a/tools/benchmark/tests/support.py b/tools/benchmark/tests/support.py
new file mode 100644
index 0000000..47d630f
--- /dev/null
+++ b/tools/benchmark/tests/support.py
@@ -0,0 +1,10 @@
+"""Transport-only test payloads; full semantic fixtures live in fixtures/."""
+
+def capture_envelope(options):
+    """Minimal envelope for transport tests; not an admissible performance record."""
+    return {
+        "schema": "n3.viewport-measure.v2",
+        "options": options,
+        "samples": [{"frame": index, "cpu_frame_ms": 1} for index in range(options["sample_frames"])],
+        "validity": {"mode_contract_satisfied": True},
+    }
diff --git a/tools/benchmark/tests/test_cli.py b/tools/benchmark/tests/test_cli.py
new file mode 100644
index 0000000..8c26c9d
--- /dev/null
+++ b/tools/benchmark/tests/test_cli.py
@@ -0,0 +1,85 @@
+"""Exercise the public offline CLI with reviewed synthetic evidence, without mocks."""
+
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+FIXTURE = Path(__file__).parent / "fixtures/editor-capture.json"
+
+
+class BenchmarkCliTests(unittest.TestCase):
+    def setUp(self):
+        self.temporary = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temporary.cleanup)
+        self.directory = Path(self.temporary.name)
+
+    def invoke(self, *arguments):
+        return subprocess.run([sys.executable, "-m", "tools.benchmark", *map(str, arguments)],
+                              cwd=ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
+                              text=True, capture_output=True, check=False, timeout=30)
+
+    def capture(self, name, values, source="a"):
+        raw = json.loads(FIXTURE.read_text())
+        raw["metadata"]["recorded_at_utc"] = name
+        raw["metadata"]["source_fingerprint"]["sha256"] = source * 64
+        for frame, value in zip(raw["samples"], values):
+            frame["cpu_frame_ms"] = value
+            frame["frame_start_interval_ms"] = value + 1
+        path = self.directory / f"{name}.json"
+        path.write_text(json.dumps(raw))
+        return path
+
+    def test_report_and_compare_use_raw_samples_and_independent_run_estimates(self):
+        baseline = [self.capture(f"before-{i}", values) for i, values in enumerate(
+            ([10, 20, 90], [11, 21, 91], [12, 22, 92]))]
+        candidate = [self.capture(f"after-{i}", values, "b") for i, values in enumerate(
+            ([20, 40, 180], [22, 42, 182], [24, 44, 184]))]
+        before, after, delta = (self.directory / name for name in ("before", "after", "delta"))
+        for paths, output in ((baseline, before), (candidate, after)):
+            result = self.invoke("report", *paths, "--output", output)
+            self.assertEqual(result.returncode, 0, result.stderr)
+            self.assertIn("3 run(s)", result.stdout)
+        summary = json.loads((before / "summary.json").read_text())
+        cpu = summary["cases"][0]["metrics"]["cpu_frame_ms"]
+        self.assertEqual(cpu["run_medians"]["median"], 21)
+        self.assertEqual(cpu["run_medians"]["mad"], 1)
+        self.assertEqual(cpu["run_max"]["max"], 92)
+        self.assertIn("Standalone reports", (before / "summary.txt").read_text())
+        result = self.invoke("compare", before / "summary.json", after / "summary.json", "--output", delta)
+        self.assertEqual(result.returncode, 0, result.stderr)
+        metrics = json.loads((delta / "comparison.json").read_text())["cases"][0]["metrics"]
+        self.assertEqual(metrics["cpu_frame_ms"]["delta_ms"], 21)
+        self.assertEqual(metrics["cpu_frame_ms"]["delta_percent"], 100)
+        self.assertIn("+100.00%", (delta / "comparison.txt").read_text())
+
+    def test_claimed_validity_cannot_admit_impossible_execution(self):
+        source = self.capture("false-mode", [10, 20, 90])
+        raw = json.loads(source.read_text())
+        raw["samples"][0]["counters"]["scene_renders"] = 0
+        source.write_text(json.dumps(raw))
+        output = self.directory / "rejected"
+        result = self.invoke("report", source, "--output", output)
+        self.assertNotEqual(result.returncode, 0)
+        summary = json.loads((output / "summary.json").read_text())
+        self.assertEqual(summary["accepted_run_count"], 0)
+        self.assertEqual(summary["excluded_run_count"], 1)
+        self.assertIn("EXCLUDED", result.stdout)
+
+    def test_duplicate_copies_and_existing_outputs_cannot_inflate_or_replace_evidence(self):
+        source = self.capture("first", [10, 20, 90])
+        copy = self.directory / "copy.json"
+        copy.write_text(json.dumps(json.loads(source.read_text()), separators=(",", ":")))
+        result = self.invoke("report", source, copy, "--output", self.directory / "duplicates")
+        self.assertNotEqual(result.returncode, 0)
+        output = self.directory / "existing"
+        output.mkdir()
+        marker = output / "summary.txt"
+        marker.write_text("preserved evidence")
+        result = self.invoke("report", source, "--output", output)
+        self.assertNotEqual(result.returncode, 0)
+        self.assertEqual(marker.read_text(), "preserved evidence")
diff --git a/tools/benchmark/tests/test_fixtures.py b/tools/benchmark/tests/test_fixtures.py
new file mode 100644
index 0000000..86b57b3
--- /dev/null
+++ b/tools/benchmark/tests/test_fixtures.py
@@ -0,0 +1,36 @@
+"""Verify the maintained benchmark asset's pinned bytes without regenerating it."""
+
+import hashlib
+import os
+from pathlib import Path
+import subprocess
+import sys
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[3]
+CHESS_SET = ROOT / "fixtures/benchmarks/chess-set"
+
+
+def fixture_snapshot():
+    return {
+        path.relative_to(CHESS_SET).as_posix(): (
+            hashlib.sha256(path.read_bytes()).hexdigest(), path.stat().st_mtime_ns,
+        )
+        for path in CHESS_SET.rglob("*") if path.is_file()
+    }
+
+
+class BenchmarkFixtureTests(unittest.TestCase):
+    def test_chess_source_hashes_and_exact_glb_reproduction_are_read_only(self):
+        before = fixture_snapshot()
+        # No --write: the recipe checks every source against provenance.json,
+        # then compares the packed bytes with both the pinned hash and stored GLB.
+        result = subprocess.run(
+            [sys.executable, str(CHESS_SET / "pack.py")],
+            cwd=ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
+            stdin=subprocess.DEVNULL, capture_output=True, text=True,
+            check=False, timeout=30,
+        )
+        self.assertEqual(fixture_snapshot(), before, "Fixture verification must not write files")
+        self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
diff --git a/tools/benchmark/tests/test_measure.py b/tools/benchmark/tests/test_measure.py
new file mode 100644
index 0000000..42767d3
--- /dev/null
+++ b/tools/benchmark/tests/test_measure.py
@@ -0,0 +1,382 @@
+import hashlib
+import json
+import os
+from pathlib import Path
+import tempfile
+import threading
+import unittest
+from unittest.mock import MagicMock, patch
+
+from tools.benchmark import measure, transport
+from .support import capture_envelope
+
+
+
+
+class MeasurementBuildTests(unittest.TestCase):
+    def test_modes_default_and_forward_into_both_host_configurations(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            source = root / "input.glb"
+            source.write_bytes(b"fixture")
+            for host in ("native", "web"):
+                for mode in ("editor", "viewport", "renderer"):
+                    output = root / f"{host}-{mode}.json"
+                    arguments = ["--host", host, "--input", str(source), "--output", str(output), "--frames", "2"]
+                    if mode != "editor":
+                        arguments += ["--mode", mode]
+                    expected = measure.options(measure.parser().parse_args(arguments))
+                    self.assertEqual(expected["mode"], mode)
+
+                    def native(command, **kwargs):
+                        received = json.loads(kwargs["env"]["N3_VIEWPORT_MEASURE"])
+                        self.assertEqual(received, expected)
+                        output.write_text(json.dumps(capture_envelope(received)))
+
+                    def browser(args, artifact, info):
+                        received = measure.options(args)
+                        self.assertEqual(received, expected)
+                        output.write_text(json.dumps(capture_envelope(received)))
+
+                    with self.subTest(host=host, mode=mode), patch.object(measure, "metadata", return_value={}), patch.object(
+                        measure, "build", return_value=root / "artifact",
+                    ), patch.object(measure, "require_browser_tools"), patch.object(measure.subprocess, "run", side_effect=native), patch.object(
+                        measure, "run_browser", side_effect=browser,
+                    ), patch("builtins.print"):
+                        self.assertEqual(measure.main(arguments), 0)
+
+    def test_renderer_selection_and_unknown_modes_fail_before_building(self):
+        base = ["--host", "native", "--input", "input.glb", "--output", "report.json"]
+        for extra in (["--mode", "renderer", "--selected"], ["--mode", "hidden"]):
+            with self.subTest(extra=extra), patch.object(measure, "build") as build, self.assertRaises(SystemExit) as failed:
+                measure.main(base + extra)
+            self.assertEqual(failed.exception.code, 2)
+            build.assert_not_called()
+        for mode in ("editor", "viewport"):
+            self.assertTrue(measure.options(measure.parser().parse_args(base + ["--mode", mode, "--selected"]))["selected"])
+
+    def test_profiles_compile_real_host_with_feature_and_matching_artifact(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            for relative in ("tools/benchmark/browser/measure.html", "tools/benchmark/browser/measure.js", "tools/benchmark/browser/observations.mjs", "assets/fonts/inter/LICENSE.txt", "assets/fonts/lucide/LICENSE"):
+                path = root / relative
+                path.parent.mkdir(parents=True, exist_ok=True)
+                path.write_text(relative)
+            generator = root / "wasm-bindgen"
+
+            def run(command, **kwargs):
+                if command[0] == str(generator):
+                    output = Path(command[-1])
+                    output.mkdir()
+                    (output / "n3.js").write_text("glue")
+                    (output / "n3_bg.wasm").write_bytes(b"wasm")
+
+            with patch.dict(os.environ, {"CARGO_TARGET_DIR": "custom-target"}, clear=True), patch.object(
+                measure.web, "require_bindgen", return_value=generator,
+            ), patch.object(measure.subprocess, "run", side_effect=run) as process:
+                for profile, directory in (("dev", "debug"), ("release", "release")):
+                    executable = measure.build("native", profile, root)
+                    self.assertEqual(executable, root / "custom-target" / directory / "n3")
+                    native_command = process.call_args.args[0]
+                    self.assertIn("viewport-measure", native_command)
+                    self.assertEqual(native_command[-2:], ["--bin", "n3"])
+                    self.assertEqual(native_command[native_command.index("--profile") + 1], profile)
+                    site = measure.build("web", profile, root)
+                    compile_call, glue_call = process.call_args_list[-2:]
+                    self.assertEqual(compile_call.args[0][-3:], ["--target", measure.web.TARGET, "--lib"])
+                    self.assertEqual(glue_call.args[0][1], str(root / "custom-target" / measure.web.TARGET / directory / "n3.wasm"))
+                    self.assertEqual(site, root / "build/measure-web" / profile)
+                    self.assertTrue((site / "pkg/n3_bg.wasm").is_file())
+                    self.assertTrue((site / "licenses/Inter.txt").is_file())
+                    self.assertTrue((site / "licenses/Lucide.txt").is_file())
+                    self.assertFalse((root / "build/web").exists())
+
+    def test_failed_glue_preserves_previous_measurement_site(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            for relative in ("tools/benchmark/browser/measure.html", "tools/benchmark/browser/measure.js", "tools/benchmark/browser/observations.mjs", "assets/fonts/inter/LICENSE.txt", "assets/fonts/lucide/LICENSE", "build/measure-web/release/measure.html"):
+                path = root / relative
+                path.parent.mkdir(parents=True, exist_ok=True)
+                path.write_text("previous")
+            with patch.object(measure.web, "require_bindgen", return_value="generator"), patch.object(
+                measure.subprocess, "run", side_effect=[None, OSError("bindgen failed")],
+            ), self.assertRaisesRegex(OSError, "bindgen failed"):
+                measure.build("web", "release", root)
+            self.assertEqual((root / "build/measure-web/release/measure.html").read_text(), "previous")
+            self.assertEqual(list((root / "build/measure-web").glob("measure-stage-*")), [])
+
+    def test_metadata_records_actual_input_build_flags_and_unknowns(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            source = Path(temporary) / "fixture.obj"
+            source.write_bytes(b"one selected model")
+            args = measure.parser().parse_args([
+                "--host", "web", "--profile", "release", "--input", str(source),
+                "--output", str(Path(temporary) / "report.json"),
+            ])
+
+            def version(command, **kwargs):
+                if command == ["git", "rev-parse", "HEAD"]:
+                    return "test-revision\n"
+                if command[:2] == ["git", "ls-files"]:
+                    return b"src/missing-file.rs\0"
+                if command[0] == "git":
+                    return " M src/render.rs\n"
+                return "rustc test-version\n"
+
+            with patch.object(measure.platform, "platform", return_value="test-os"), patch.object(measure.subprocess, "check_output", side_effect=version), patch.dict(
+                os.environ, {"RUSTFLAGS": "-C target-cpu=native", "CARGO_PROFILE_RELEASE_LTO": "true"}, clear=True,
+            ):
+                info = measure.metadata(args)
+            self.assertTrue(info["dirty"])
+            self.assertEqual(info["revision"], "test-revision")
+            self.assertEqual(info["input"], {"name": "fixture.obj", "bytes": 18, "sha256": hashlib.sha256(source.read_bytes()).hexdigest()})
+            self.assertEqual(info["profile"], "release")
+            self.assertEqual(info["rustflags"], "-C target-cpu=native")
+            self.assertEqual(info["profile_overrides"], {"CARGO_PROFILE_RELEASE_LTO": "true"})
+            self.assertIsNone(info["display_refresh_hz"])
+            self.assertIsNone(info["observed_presentation_timestamps"])
+            self.assertIsNone(info["gpu_timestamps"])
+            self.assertNotIn(str(source), json.dumps(info))
+
+
+    def test_source_fingerprint_tracks_bytes_modes_symlinks_and_deletions(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            first = root / "a.rs"
+            first.write_text("first")
+            second = root / "b.rs"
+            second.write_text("second")
+            link = root / "alias"
+            link.symlink_to("a.rs")
+            with patch.object(measure.subprocess, "check_output", return_value=b"b.rs\0a.rs\0alias\0"):
+                baseline = measure.source_fingerprint(root)
+            with patch.object(measure.subprocess, "check_output", return_value=b"a.rs\0alias\0b.rs\0a.rs\0"):
+                self.assertEqual(baseline, measure.source_fingerprint(root))
+                first.write_text("changed")
+                changed = measure.source_fingerprint(root)
+                self.assertNotEqual(baseline["sha256"], changed["sha256"])
+                second.unlink()
+                deleted = measure.source_fingerprint(root)
+                self.assertNotEqual(changed["sha256"], deleted["sha256"])
+                link.unlink()
+                link.symlink_to("missing")
+                self.assertNotEqual(deleted["sha256"], measure.source_fingerprint(root)["sha256"])
+
+    def test_native_success_requires_complete_report_not_only_exit_code(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            source = root / "input.obj"
+            source.write_bytes(b"fixture")
+            output = root / "report.json"
+            arguments = ["--host", "native", "--input", str(source), "--output", str(output), "--frames", "2"]
+            with patch.object(measure, "metadata", return_value={}), patch.object(measure, "build", return_value=root / "n3"), patch.object(
+                measure.subprocess, "run", return_value=None,
+            ), self.assertRaises(SystemExit) as missing:
+                measure.main(arguments)
+            self.assertEqual(missing.exception.code, 1)
+
+            def incomplete(command, **kwargs):
+                expected = json.loads(kwargs["env"]["N3_VIEWPORT_MEASURE"])
+                report = capture_envelope(expected)
+                report["samples"].pop()
+                output.write_text(json.dumps(report))
+
+            with patch.object(measure, "metadata", return_value={}), patch.object(measure, "build", return_value=root / "n3"), patch.object(
+                measure.subprocess, "run", side_effect=incomplete,
+            ), self.assertRaises(SystemExit) as partial:
+                measure.main(arguments)
+            self.assertEqual(partial.exception.code, 1)
+            self.assertTrue(output.exists())
+
+    def test_report_validation_rejects_bad_schema_counts_order_and_durations(self):
+        options = {"sample_frames": 2, "mode": "editor"}
+        valid = capture_envelope(options)
+        self.assertIs(transport.validate_envelope(valid, options), valid)
+        for field, value in (("schema", "unknown"), ("options", {"sample_frames": 1}), ("samples", [])):
+            with self.subTest(field=field), self.assertRaises(ValueError):
+                transport.validate_envelope({**valid, field: value}, options)
+        for sample in ({"frame": 2, "cpu_frame_ms": 1}, {"frame": 1, "cpu_frame_ms": -1}, {"frame": 1, "cpu_frame_ms": True}, {"frame": 1, "cpu_frame_ms": float("inf")}):
+            with self.subTest(sample=sample), self.assertRaises(ValueError):
+                transport.validate_envelope({**valid, "samples": [valid["samples"][0], sample]}, options)
+
+    def test_report_requires_mode_contract_but_preserves_comparison_contamination(self):
+        options = {"sample_frames": 1, "mode": "viewport"}
+        report = capture_envelope(options)
+        for validity in (None, {}, {"mode_contract_satisfied": False}, {"mode_contract_satisfied": 1}):
+            with self.subTest(validity=validity), self.assertRaisesRegex(ValueError, "mode contract"):
+                transport.validate_envelope({**report, "validity": validity}, options)
+        report["validity"].update({"all_frames_focused": False, "constant_viewport": False})
+        self.assertIs(transport.validate_envelope(report, options), report)
+        self.assertFalse(report["validity"]["all_frames_focused"])
+
+
+class MeasurementBrowserTests(unittest.TestCase):
+    def arguments(self, root, *extra):
+        return measure.parser().parse_args([
+            "--host", "web", "--input", str(root / "input.obj"),
+            "--output", str(root / "report.json"), *extra,
+        ])
+
+    def server(self):
+        server = MagicMock()
+        server.__enter__.return_value = server
+        server.url = "http://127.0.0.1:1234/test-token/measure.html"
+        server.completed = threading.Event()
+        return server
+
+    def test_ambient_selenium_and_debug_aliases_cannot_redirect_local_browser(self):
+        root = Path("/test-workspace")
+        inherited = {
+            "SELENIUM_REMOTE_URL": "https://remote.example/session",
+            "npm_config_selenium_remote_url": "https://other.example/session",
+            "npm_package_config_selenium_remote_url": "https://package.example/session",
+            "PWDEBUG": "1",
+            "npm_config_pwdebug": "console",
+            "npm_package_config_pwdebug": "1",
+            "PLAYWRIGHT_BROWSERS_PATH": "/personal-browser-cache",
+            "HTTPS_PROXY": "http://proxy.example:8080",
+        }
+        with patch.dict(os.environ, inherited, clear=True):
+            environment = measure.browser_environment(root)
+            self.assertEqual(dict(os.environ), inherited)
+        for key in inherited:
+            if "selenium" in key.lower() or "pwdebug" in key.lower():
+                self.assertNotIn(key, environment)
+        self.assertEqual(environment["PLAYWRIGHT_BROWSERS_PATH"], str(root / ".cache/viewport-browsers"))
+        self.assertEqual(environment["HTTPS_PROXY"], inherited["HTTPS_PROXY"])
+
+    def test_optional_setup_pins_package_and_keeps_browsers_in_repository_cache(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            with patch.object(measure.subprocess, "run") as process, patch.object(
+                measure, "require_browser_tools",
+            ) as require, patch("builtins.print"), patch.dict(os.environ, {}, clear=True):
+                measure.setup_browser(root)
+            install, browser = process.call_args_list
+            self.assertEqual(install.args[0], [
+                "npm", "install", "--prefix", str(root / ".cache/viewport-tools"),
+                "--ignore-scripts", "--no-audit", "--no-fund", "--save-exact",
+                "playwright@1.63.0",
+            ])
+            self.assertEqual(browser.args[0], [
+                "node", str(root / ".cache/viewport-tools/node_modules/playwright/cli.js"),
+                "install", "chromium", "--no-shell",
+            ])
+            for call in process.call_args_list:
+                self.assertEqual(call.kwargs["env"]["PLAYWRIGHT_BROWSERS_PATH"], str(root / ".cache/viewport-browsers"))
+                self.assertEqual(call.kwargs["cwd"], root)
+            require.assert_called_once_with(root)
+
+    def test_missing_mismatched_or_external_browser_fails_before_compilation(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            with self.assertRaisesRegex(RuntimeError, "setup-browser"):
+                measure.require_browser_tools(root)
+            package = root / ".cache/viewport-tools/node_modules/playwright"
+            package.mkdir(parents=True)
+            (package / "cli.js").write_text("cli")
+            (package / "package.json").write_text('{"version":"wrong"}')
+            with self.assertRaisesRegex(RuntimeError, "mismatched"):
+                measure.require_browser_tools(root)
+            (package / "package.json").write_text(json.dumps({"version": measure.PLAYWRIGHT_VERSION}))
+            external = root / "personal-browser"
+            external.write_text("not an owned executable")
+            with patch.object(measure.subprocess, "check_output", return_value=str(external)), self.assertRaisesRegex(RuntimeError, "setup-browser"):
+                measure.require_browser_tools(root)
+            installed = root / ".cache/viewport-browsers/chromium/browser"
+            installed.parent.mkdir(parents=True)
+            installed.write_text("owned executable")
+            with patch.object(measure.subprocess, "check_output", return_value=str(installed)):
+                measure.require_browser_tools(root)
+            (root / "input.obj").write_text("fixture")
+            arguments = ["--host", "web", "--input", str(root / "input.obj"), "--output", str(root / "report.json")]
+            with patch.object(measure, "require_browser_tools", side_effect=RuntimeError("missing setup-browser")), patch.object(
+                measure, "build",
+            ) as build, self.assertRaises(SystemExit) as failed:
+                measure.main(arguments)
+            self.assertEqual(failed.exception.code, 1)
+            build.assert_not_called()
+
+    def test_runner_success_waits_for_report_response_and_browser_exit(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            args = self.arguments(root, "--mode", "viewport")
+            server = self.server()
+            server.handle_request.side_effect = lambda: server.completed.set()
+            process = MagicMock()
+            process.poll.side_effect = [None, None, 0, 0]
+            with patch.object(measure, "MeasurementServer", return_value=server) as server_factory, patch.object(
+                measure.subprocess, "Popen", return_value=process,
+            ) as launch, patch("builtins.print"):
+                measure.run_browser(args, root / "site", {}, root)
+            self.assertEqual(server.handle_request.call_count, 2)
+            self.assertEqual(server_factory.call_args.args[3]["options"], measure.options(args))
+            self.assertEqual(server_factory.call_args.args[3]["options"]["mode"], "viewport")
+            self.assertEqual(launch.call_args.args[0], [
+                "node", str(root / "tools/benchmark/browser/runner.mjs"), server.url,
+                "headless", "1280", "800", "2.0", "600",
+            ])
+            self.assertEqual(launch.call_args.kwargs["env"]["PLAYWRIGHT_BROWSERS_PATH"], str(root / ".cache/viewport-browsers"))
+            process.terminate.assert_not_called()
+
+    def test_child_failure_and_zero_exit_without_report_fail_immediately(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            for code, expected in ((7, "status 7"), (0, "without a complete report")):
+                server = self.server()
+                process = MagicMock()
+                process.poll.return_value = code
+                with self.subTest(code=code), patch.object(measure, "MeasurementServer", return_value=server), patch.object(
+                    measure.subprocess, "Popen", return_value=process,
+                ), patch("builtins.print"), self.assertRaisesRegex(RuntimeError, expected):
+                    measure.run_browser(self.arguments(root), root / "site", {}, root)
+                server.handle_request.assert_not_called()
+
+    def test_timeout_and_interrupt_close_only_the_owned_runner(self):
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            server = self.server()
+            process = MagicMock()
+            process.poll.return_value = None
+            with patch.object(measure, "MeasurementServer", return_value=server), patch.object(
+                measure.subprocess, "Popen", return_value=process,
+            ), patch.object(measure.time, "monotonic", side_effect=[0, 2]), patch("builtins.print"), self.assertRaisesRegex(RuntimeError, "timed out"):
+                measure.run_browser(self.arguments(root, "--timeout", "1"), root / "site", {}, root)
+            process.terminate.assert_called_once()
+            process.wait.assert_called_once_with(timeout=45)
+            process.kill.assert_not_called()
+            process.reset_mock()
+            server.handle_request.side_effect = KeyboardInterrupt
+            with patch.object(measure, "MeasurementServer", return_value=server), patch.object(
+                measure.subprocess, "Popen", return_value=process,
+            ), patch("builtins.print"), self.assertRaises(KeyboardInterrupt):
+                measure.run_browser(self.arguments(root), root / "site", {}, root)
+            process.terminate.assert_called_once()
+            process.wait.assert_called_once_with(timeout=45)
+
+    def test_stuck_runner_cleanup_is_bounded(self):
+        process = MagicMock()
+        process.poll.return_value = None
+        process.wait.side_effect = [measure.subprocess.TimeoutExpired("node", 45), 0]
+        measure.stop_browser_runner(process)
+        process.terminate.assert_called_once()
+        process.kill.assert_called_once()
+        self.assertEqual([call.kwargs["timeout"] for call in process.wait.call_args_list], [45, 5])
+
+    def test_invalid_browser_options_fail_without_build_or_launch(self):
+        base = ["--host", "web", "--input", "fixture.obj", "--output", "report.json"]
+        for extra in (["--open"], ["--device-scale-factor", "0"], ["--device-scale-factor", "nan"], ["--device-scale-factor", "5"]):
+            with self.subTest(extra=extra), patch.object(measure, "build") as build, patch.object(
+                measure.subprocess, "Popen",
+            ) as launch, self.assertRaises(SystemExit) as failed:
+                measure.main(base + extra)
+            self.assertEqual(failed.exception.code, 2)
+            build.assert_not_called()
+            launch.assert_not_called()
+        self.assertEqual(self.arguments(Path("."), "--browser", "manual", "--open").browser, "manual")
+
+
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/benchmark/tests/test_reports.py b/tools/benchmark/tests/test_reports.py
new file mode 100644
index 0000000..f809c8d
--- /dev/null
+++ b/tools/benchmark/tests/test_reports.py
@@ -0,0 +1,671 @@
+from copy import deepcopy
+import hashlib
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+from tools.benchmark import reports as report
+from tools.benchmark import contracts
+
+
+def raw_report(values, *, run=1, host="native", mode="editor", stages=True, fps_meter=False):
+    """Small raw fixture, with deliberately wrong precomputed summaries."""
+    result = {
+        "schema": "n3.viewport-measure.v2",
+        "metadata": {
+            "host": host, "adapter": {"name": "test GPU", "backend": "Metal", "device_type": "IntegratedGpu",
+                                       "vendor": 0, "device": 0, "driver": "", "driver_info": ""},
+            "architecture": "arm64", "browser_mode": None, "build_command": ["cargo", "build", "--release"],
+            "cargo_encoded_rustflags": None, "dirty": True,
+            "input": {"name": "test.glb", "bytes": 100, "sha256": "f" * 64},
+            "operating_system": "test OS", "profile": "release", "profile_overrides": {},
+            "recorded_at_utc": f"2026-10-02T00:00:{run:02d}Z", "requested_device_scale_factor": None,
+            "requested_physical_size": [1280, 800], "revision": "test revision", "rustc": "test compiler",
+            "rustflags": None, "source_fingerprint": {"sha256": "a" * 64, "paths": 10, "scope": "source"},
+            "surface": {"width": 1280, "height": 800, "present_mode": "Fifo", "scale_factor": 2,
+                        "format": "Bgra8Unorm", "alpha_mode": "Opaque", "desired_maximum_frame_latency": 2},
+            "loading": {"duration": run * 100},
+            "suite": {"round": run, "position": run, "seed": 17},
+        },
+        "options": {"mode": mode, "workload": "orbit", "selected": False, "instrument_stages": stages,
+                    "sample_frames": len(values), "warmup_frames": 20},
+        "model": {"vertices": 100, "triangles": 150, "faces": 0, "objects": 1, "linked_assets": 1},
+        "render": {"baseline_viewport_points": [240, 0, 400, 368.5], "pixels_per_point": 2,
+                   "initial_camera_view_projection": [0] * 16, "shading": "solid", "grid": True, "edges": True,
+                   "fps_meter_enabled": fps_meter, "theme": "light", "selected_objects": 0, "hovered_object": None,
+                   "baseline_viewport_ui_points": [240, 0, 400, 368.5]},
+        "validity": {
+            "all_frames_focused": True, "constant_surface": True, "constant_viewport": True,
+            "mode_contract_satisfied": True, "no_frame_errors": True, "unchanged_mesh_revision": True,
+            "viewport_matches_baseline": True,
+            "fps_meter_constant": True,
+        },
+        "samples": [], "summary": {"cpu_frame_ms": {"median": 99999999}},
+    }
+    if host == "web":
+        result["metadata"].update(browser_mode="headed", requested_device_scale_factor=2, browser={
+            "user_agent": "test browser", "device_pixel_ratio": 2, "physical_canvas": [1280, 800],
+            "css_canvas": [640, 400], "visibility": "visible", "automation": {
+                "browser_version": "test version", "mode": "headed", "tool": "playwright", "version": "pinned",
+                "launch_arguments": ["--user-data-dir=[temporary-profile]"],
+                "device_scale_factor": 2,
+                "gpu_process": {"software_renderer_detected": False},
+            },
+        })
+        result["browser_observations"] = {"comparable": True, "focus_losses": 0, "visibility_changes": 0, "size_changed": False}
+    for index, value in enumerate(values):
+        stage_values = {
+            "surface_acquire": 0, "host_prepare": 0, "cache_sync": 0,
+            "scene_encode": 0, "submit_api": 0, "present_api": 0, "host_tail": 0,
+            "ui": None, "commands_and_refresh": None, "tessellation_and_textures": None,
+            "feedback": None, "ui_encode": None, "editor_prepare": None, "scene_composite": None,
+        }
+        counters = {
+            "mesh_uploads": 0, "base_mesh_upload_bytes": 0, "viewport_resizes": 0,
+            "ui_jobs": 0, "egui_passes": 0, "egui_tessellations": 0, "egui_texture_updates": 0,
+            "egui_composites": 0, "editor_prepares": 0, "editor_feedback_updates": 0,
+            "scene_renders": 1, "scene_composites": 0,
+        }
+        stage_values["scene_encode"] = value / 10
+        if mode == "editor":
+            stage_values.update(ui=value / 2, commands_and_refresh=0, tessellation_and_textures=0, feedback=0, ui_encode=0)
+            counters.update(ui_jobs=1, egui_passes=1, egui_tessellations=1, egui_composites=1,
+                            editor_prepares=1, editor_feedback_updates=1)
+        elif mode == "viewport":
+            stage_values.update(editor_prepare=value / 2, feedback=0, scene_composite=0)
+            counters.update(editor_prepares=1, editor_feedback_updates=1, scene_composites=1)
+        else:
+            stage_values["scene_composite"] = 0
+            counters["scene_composites"] = 1
+        result["samples"].append({
+            "frame": index, "cpu_frame_ms": value, "frame_start_interval_ms": value + 1,
+            "camera_view_projection": [index + 1] * 16,
+            "counters": counters, "cpu_stage_ms": stage_values if stages else None,
+            "error": None, "focused": True, "mesh_revision": 1, "pixels_per_point": 2,
+            "fps_meter_enabled": fps_meter,
+            "projection": {"cpu_ms": value / 4 if stages and mode != "renderer" else None,
+                           "geometry_rebuilds": 0, "rebuilds": 0, "vertices_tested": 0},
+            "surface_pixels": [1280, 800], "viewport_pixels": [320, 737], "viewport_points": [240, 0, 400, 368.5],
+        })
+    return result
+
+
+class ViewportReportTests(unittest.TestCase):
+    def setUp(self):
+        self.temporary = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temporary.cleanup)
+        self.directory = Path(self.temporary.name)
+        self.counter = 0
+
+    def save(self, value):
+        self.counter += 1
+        raw_directory = self.directory / "raw"
+        raw_directory.mkdir(exist_ok=True)
+        path = raw_directory / f"raw-{self.counter}.json"
+        path.write_text(json.dumps(value), encoding="utf-8")
+        if "suite" in value.get("metadata", {}):
+            manifest_path = self.directory / "manifest.json"
+            manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {
+                "schema": "n3.viewport-suite-manifest.v1", "runs": [],
+            }
+            manifest["runs"].append({"report": path.relative_to(self.directory).as_posix(), "status": "complete",
+                                     "sha256": hashlib.sha256(path.read_bytes()).hexdigest()})
+            manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
+        return path
+
+    def analyze(self, *values):
+        return report.analyze([self.save(value) for value in values])
+
+    def test_shared_wire_fixture_matches_admitted_schema_and_independent_raw_fixture(self):
+        # Rust checks these literal names against its actual serialized output.
+        # This consumer check catches either language silently changing the wire.
+        wire = json.loads((Path(__file__).parent / "fixtures/measurement-schema.json").read_text())
+        value = raw_report([1, 2])
+        sample = value["samples"][0]
+        self.assertEqual(value["schema"], wire["schema"])
+        self.assertEqual(sorted(sample), wire["sample_fields"])
+        self.assertEqual(sorted(sample["counters"]), wire["counter_names"])
+        self.assertEqual(sorted(sample["cpu_stage_ms"]), wire["stage_names"])
+        self.assertEqual(sorted(value["validity"]), wire["validity_fields"])
+        self.assertEqual(sorted(contracts.COUNTERS), wire["counter_names"])
+        self.assertEqual(sorted(set(contracts.STAGES) - {"editor_projection"}), wire["stage_names"])
+        self.assertEqual(sorted(contracts.VALIDITY_FIELDS), wire["validity_fields"])
+        contracts.validate_raw(value)
+
+    def test_each_mode_requires_actual_execution_even_with_successful_summary(self):
+        corruptions = {
+            "editor": [("scene_renders", 0), ("egui_passes", 0), ("egui_tessellations", 0),
+                       ("egui_composites", 0), ("editor_prepares", 0), ("editor_feedback_updates", 0),
+                       ("scene_composites", 1)],
+            "viewport": [("scene_renders", 0), ("scene_composites", 0), ("editor_prepares", 0),
+                         ("editor_feedback_updates", 0), ("egui_passes", 1), ("egui_tessellations", 1),
+                         ("egui_texture_updates", 1), ("egui_composites", 1), ("ui_jobs", 1)],
+            "renderer": [("scene_renders", 2), ("scene_composites", 0), ("editor_prepares", 1),
+                         ("editor_feedback_updates", 1), ("egui_passes", 1), ("egui_tessellations", 1),
+                         ("egui_texture_updates", 1), ("egui_composites", 1), ("ui_jobs", 1)],
+        }
+        for mode, cases in corruptions.items():
+            for name, value in cases:
+                for stages in (True, False):
+                    with self.subTest(mode=mode, counter=name, stages=stages):
+                        raw = raw_report([1, 2], mode=mode, stages=stages)
+                        raw["samples"][1]["counters"][name] = value
+                        summary = self.analyze(raw)
+                        self.assertEqual(summary["accepted_run_count"], 0)
+                        self.assertTrue(any("execution counters" in reason for reason in summary["excluded_runs"][0]["reasons"]))
+
+    def test_incomplete_or_unknown_counter_schema_is_malformed(self):
+        for counter in ("scene_renders", "egui_passes", "ui_jobs"):
+            with self.subTest(counter=counter):
+                value = raw_report([1, 2])
+                del value["samples"][0]["counters"][counter]
+                with self.assertRaisesRegex(ValueError, "execution counters"):
+                    self.analyze(value)
+        value = raw_report([1, 2])
+        value["samples"][0]["counters"]["future_counter"] = 0
+        with self.assertRaisesRegex(ValueError, "execution counters"):
+            self.analyze(value)
+
+    def test_mode_requires_honest_stage_availability_and_renderer_excludes_projection(self):
+        cases = (("renderer", "ui", 0), ("editor", "scene_composite", 0), ("editor", "ui", None),
+                 ("viewport", "editor_prepare", None), ("renderer", "scene_encode", None))
+        for mode, stage, duration in cases:
+            with self.subTest(mode=mode, stage=stage):
+                value = raw_report([1, 2], mode=mode)
+                value["samples"][0]["cpu_stage_ms"][stage] = duration
+                summary = self.analyze(value)
+                self.assertEqual(summary["accepted_run_count"], 0)
+                self.assertTrue(any("stage availability" in reason for reason in summary["excluded_runs"][0]["reasons"]))
+        for name in ("rebuilds", "geometry_rebuilds", "vertices_tested", "cpu_ms"):
+            with self.subTest(projection=name):
+                value = raw_report([1, 2], mode="renderer")
+                value["samples"][0]["projection"][name] = 1
+                self.assertEqual(self.analyze(value)["accepted_run_count"], 0)
+
+    def test_browser_observation_facts_override_comparable_flag(self):
+        for name, observed in (("focus_losses", 1), ("visibility_changes", 1), ("size_changed", True)):
+            with self.subTest(name=name):
+                value = raw_report([1, 2], host="web")
+                value["browser_observations"][name] = observed
+                self.assertTrue(value["browser_observations"]["comparable"])
+                summary = self.analyze(value)
+                self.assertEqual(summary["accepted_run_count"], 0)
+                self.assertTrue(any("raw browser observations" in reason for reason in summary["excluded_runs"][0]["reasons"]))
+        for name, observed in (("focus_losses", -1), ("visibility_changes", True), ("size_changed", 0)):
+            with self.subTest(malformed=name):
+                value = raw_report([1, 2], host="web")
+                value["browser_observations"][name] = observed
+                with self.assertRaisesRegex(ValueError, "malformed browser_observations"):
+                    self.analyze(value)
+
+    def test_requested_and_observed_environment_must_agree(self):
+        mutations = [
+            lambda value: value["metadata"].update(requested_physical_size=[1920, 1080]),
+            lambda value: value["metadata"]["surface"].update(width=1920),
+            lambda value: value["render"].update(pixels_per_point=1),
+            lambda value: value["metadata"]["browser"].update(physical_canvas=[1920, 1080]),
+            lambda value: value["metadata"]["browser"]["automation"].update(mode="headless"),
+        ]
+        for mutation in mutations:
+            with self.subTest(mutation=mutation):
+                value = raw_report([1, 2], host="web")
+                mutation(value)
+                self.assertEqual(self.analyze(value)["accepted_run_count"], 0)
+
+    def test_hardware_and_workload_fields_cannot_be_missing_or_malformed(self):
+        fields = [
+            ("adapter", ("metadata", "adapter"), "backend", ""),
+            ("adapter name", ("metadata", "adapter"), "name", None),
+            ("adapter vendor", ("metadata", "adapter"), "vendor", True),
+            ("surface format", ("metadata", "surface"), "format", ""),
+            ("surface scale", ("metadata", "surface"), "scale_factor", 0),
+            ("model objects", ("model",), "objects", 0),
+            ("model vertices", ("model",), "vertices", None),
+            ("render shading", ("render",), "shading", ""),
+            ("render grid", ("render",), "grid", 1),
+            ("render selection", ("render",), "selected_objects", -1),
+            ("browser version", ("metadata", "browser", "automation"), "browser_version", ""),
+            ("requested scale", ("metadata",), "requested_device_scale_factor", None),
+        ]
+        for label, parents, field, invalid in fields:
+            for missing in (True, False):
+                with self.subTest(field=label, missing=missing):
+                    value = raw_report([1, 2], host="web")
+                    container = value
+                    for parent in parents:
+                        container = container[parent]
+                    if missing:
+                        container.pop(field)
+                    else:
+                        container[field] = invalid
+                    with self.assertRaises(ValueError):
+                        self.analyze(value)
+
+    def test_observed_geometry_and_selection_override_successful_validity_flags(self):
+        mutations = [
+            ("viewport pixels", lambda value: [sample.update(viewport_pixels=[8192, 8192]) for sample in value["samples"]]),
+            ("logical viewport", lambda value: [sample.update(viewport_pixels=[321, 737]) for sample in value["samples"]]),
+            ("CSS canvas", lambda value: value["metadata"]["browser"].update(css_canvas=[320, 200])),
+            ("overflowing CSS canvas", lambda value: value["metadata"]["browser"].update(css_canvas=[1e308, 1e308])),
+            ("device pixel ratio", lambda value: value["metadata"]["browser"].update(device_pixel_ratio=4)),
+            ("requested scale", lambda value: value["metadata"].update(requested_device_scale_factor=1)),
+            ("automation scale", lambda value: value["metadata"]["browser"]["automation"].update(device_scale_factor=1)),
+            ("surface scale", lambda value: value["metadata"]["surface"].update(scale_factor=1)),
+            ("selected workload", lambda value: value["render"].update(selected_objects=1)),
+            ("document objects", lambda value: value["model"].update(linked_assets=2)),
+        ]
+        for label, mutation in mutations:
+            with self.subTest(observation=label):
+                value = raw_report([1, 2], host="web")
+                mutation(value)
+                self.assertTrue(all(value["validity"].values()))
+                self.assertEqual(self.analyze(value)["accepted_run_count"], 0)
+
+    def test_scene_extent_uses_f32_rounding_and_allows_device_clamping(self):
+        value = raw_report([1, 2])
+        # This decimal rounds to 100.25 in f32, then 200.5 physical pixels
+        # rounds up to 201 in Rust. Python's round-to-even would be wrong.
+        bounds = [0, 0, 100.249999, 100]
+        value["render"]["baseline_viewport_points"] = bounds
+        value["render"]["baseline_viewport_ui_points"] = bounds
+        for sample in value["samples"]:
+            sample.update(viewport_points=bounds, viewport_pixels=[201, 200])
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 1)
+        for sample in value["samples"]:
+            sample["viewport_pixels"] = [128, 128]
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 1)
+        for sample in value["samples"]:
+            sample["viewport_pixels"] = [202, 200]
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 0)
+
+    def test_fractional_css_pixels_and_f32_scale_remain_valid(self):
+        value = raw_report([1, 2], host="web")
+        scale = 1.2999999523162842
+        value["metadata"]["requested_device_scale_factor"] = 1.3
+        value["metadata"]["surface"]["scale_factor"] = scale
+        value["metadata"]["browser"].update(device_pixel_ratio=1.3, css_canvas=[984.609375, 615.375])
+        value["metadata"]["browser"]["automation"]["device_scale_factor"] = 1.3
+        value["render"]["pixels_per_point"] = scale
+        for sample in value["samples"]:
+            sample.update(pixels_per_point=scale, viewport_pixels=[208, 479])
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 1)
+
+    def test_browser_adapter_can_explicitly_withhold_identifying_strings(self):
+        value = raw_report([1, 2], host="web")
+        value["metadata"]["adapter"].update(name="", backend="BrowserWebGpu", device_type="Other")
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 1)
+
+    def test_camera_workload_claim_must_match_observed_changes(self):
+        value = raw_report([1, 2])
+        value["options"]["workload"] = "stationary"
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 0)
+        for sample in value["samples"]:
+            sample["camera_view_projection"] = value["render"]["initial_camera_view_projection"]
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 1)
+        value["options"]["workload"] = "orbit"
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 0)
+
+    def test_manual_browser_without_automation_has_no_false_hardware_claim(self):
+        value = raw_report([1, 2], host="web")
+        value["metadata"]["browser_mode"] = "manual"
+        value["metadata"]["requested_device_scale_factor"] = None
+        value["metadata"]["browser"]["automation"] = None
+        self.assertEqual(self.analyze(value)["accepted_run_count"], 1)
+
+    def test_malformed_browser_diagnostics_cannot_bypass_admission(self):
+        for diagnostics in ("unknown", 0, {"software_renderer_detected": "false"}):
+            with self.subTest(diagnostics=diagnostics):
+                value = raw_report([1, 2], host="web")
+                value["metadata"]["browser"]["automation"]["gpu_process"] = diagnostics
+                with self.assertRaisesRegex(ValueError, "gpu_process"):
+                    self.analyze(value)
+        value = raw_report([1, 2], host="web")
+        value["metadata"]["browser_mode"] = "manual"
+        value["metadata"]["requested_device_scale_factor"] = None
+        value["metadata"]["browser"]["automation"] = 42
+        with self.assertRaisesRegex(ValueError, "automation"):
+            self.analyze(value)
+
+    def test_independent_run_medians_recomputed_without_pooling_or_outlier_removal(self):
+        summary = self.analyze(*[raw_report([index, index, 100], run=index) for index in (1, 2, 3)])
+        case = summary["cases"][0]
+        metric = case["metrics"]["cpu_frame_ms"]
+        self.assertEqual(metric["run_medians"], {"median": 2, "min": 1, "max": 3, "mad": 1, "relative_mad_percent": 50})
+        # Pooling these nine frames would report 3 rather than the independent-run median 2.
+        self.assertEqual(metric["run_p95"], {"median": 100, "min": 100, "max": 100})
+        self.assertEqual(metric["run_max"]["max"], 100)
+        self.assertEqual(case["run_count"], 3)
+        self.assertEqual(case["frame_count"], 9)
+        self.assertEqual(case["warnings"], [])
+        self.assertEqual(case["runs"][1]["suite"]["round"], 2)
+        self.assertNotIn("suite", case["configuration"]["metadata"])
+        self.assertNotIn("loading", case["configuration"]["metadata"])
+        self.assertIn("100.000", report.render_text(summary))
+
+    def test_even_median_and_nearest_rank_p95_are_not_interpolated(self):
+        summary = self.analyze(raw_report(list(range(1, 21))))
+        metric = summary["cases"][0]["runs"][0]["metrics"]["cpu_frame_ms"]
+        self.assertEqual(metric, {"count": 20, "min": 1, "median": 10.5, "p95": 19, "max": 20})
+        self.assertIn("Insufficient repeats", summary["cases"][0]["warnings"][0])
+
+    def test_host_and_mode_cases_remain_separate(self):
+        summary = self.analyze(raw_report([1, 2]), raw_report([3, 4], host="web"), raw_report([5, 6], mode="viewport"))
+        self.assertEqual(len(summary["cases"]), 3)
+        self.assertEqual(summary["accepted_run_count"], 3)
+
+    def test_fps_meter_observations_cannot_be_overridden_by_true_constant_flag(self):
+        for initial in (False, True):
+            for observed_frames in ((0,), (0, 1)):
+                with self.subTest(initial=initial, observed_frames=observed_frames):
+                    value = raw_report([1, 2], fps_meter=initial)
+                    for index in observed_frames:
+                        value["samples"][index]["fps_meter_enabled"] = not initial
+                    summary = self.analyze(value)
+                    self.assertEqual(summary["accepted_run_count"], 0)
+                    self.assertIn("raw FPS meter state differs from initial render configuration",
+                                  summary["excluded_runs"][0]["reasons"])
+
+    def test_fps_meter_warmup_contamination_is_retained_even_with_constant_samples(self):
+        value = raw_report([1, 2], fps_meter=True)
+        value["validity"]["fps_meter_constant"] = False
+        summary = self.analyze(value)
+        self.assertEqual(summary["accepted_run_count"], 0)
+        self.assertIn("validity.fps_meter_constant=false", summary["excluded_runs"][0]["reasons"])
+
+    def test_fps_meter_extension_requires_complete_boolean_observations(self):
+        mutations = (
+            lambda value: value["render"].pop("fps_meter_enabled"),
+            lambda value: value["validity"].pop("fps_meter_constant"),
+            lambda value: value["samples"][0].pop("fps_meter_enabled"),
+            lambda value: value["render"].update(fps_meter_enabled=None),
+            lambda value: value["render"].update(fps_meter_enabled=0),
+            lambda value: value["validity"].update(fps_meter_constant=1),
+            lambda value: value["samples"][0].update(fps_meter_enabled="false"),
+        )
+        for mutation in mutations:
+            with self.subTest(mutation=mutation):
+                value = raw_report([1, 2])
+                mutation(value)
+                with self.assertRaisesRegex(ValueError, "fps_meter"):
+                    self.analyze(value)
+
+    def test_legacy_fps_meter_state_remains_unknown_and_cannot_pool_with_observed_state(self):
+        legacy = raw_report([1, 2])
+        del legacy["render"]["fps_meter_enabled"]
+        del legacy["validity"]["fps_meter_constant"]
+        for sample in legacy["samples"]:
+            del sample["fps_meter_enabled"]
+        summary = self.analyze(legacy)
+        self.assertEqual(summary["accepted_run_count"], 1)
+        self.assertNotIn("fps_meter_enabled", summary["cases"][0]["configuration"]["render"])
+        partial_extensions = (
+            lambda value: value["render"].update(fps_meter_enabled=False),
+            lambda value: value["validity"].update(fps_meter_constant=True),
+            lambda value: value["samples"][1].update(fps_meter_enabled=False),
+        )
+        for extension in partial_extensions:
+            with self.subTest(extension=extension):
+                partial = deepcopy(legacy)
+                extension(partial)
+                with self.assertRaisesRegex(ValueError, "fps_meter"):
+                    self.analyze(partial)
+        for enabled in (False, True):
+            with self.subTest(enabled=enabled):
+                with self.assertRaisesRegex(ValueError, "incompatible repeats.*fps_meter_enabled"):
+                    self.analyze(legacy, raw_report([2, 3], run=2, fps_meter=enabled))
+
+    def test_fps_meter_on_and_off_are_valid_but_incompatible_repetitions(self):
+        off = raw_report([1, 2])
+        on = raw_report([2, 3], run=2, fps_meter=True)
+        self.assertEqual(self.analyze(off)["accepted_run_count"], 1)
+        self.assertEqual(self.analyze(on)["accepted_run_count"], 1)
+        with self.assertRaisesRegex(ValueError, "incompatible repeats.*fps_meter_enabled"):
+            self.analyze(off, on)
+        comparison = report.compare(self.analyze(off), self.analyze(on))
+        self.assertTrue(any("fps_meter_enabled" in reason for reason in comparison["cases"][0]["reasons"]))
+
+    def test_contamination_is_visible_and_excluded_without_dropping_slow_samples(self):
+        bad = raw_report([999, 999], run=2)
+        bad["validity"]["all_frames_focused"] = False
+        bad["samples"][0]["focused"] = False
+        summary = self.analyze(raw_report([1, 10000]), bad)
+        self.assertEqual(summary["accepted_run_count"], 1)
+        self.assertEqual(summary["excluded_run_count"], 1)
+        self.assertEqual(summary["cases"][0]["metrics"]["cpu_frame_ms"]["run_max"]["max"], 10000)
+        self.assertIn("validity.all_frames_focused=false", summary["excluded_runs"][0]["reasons"])
+        self.assertTrue(Path(summary["excluded_runs"][0]["path"]).exists())
+        self.assertIn("EXCLUDED:", report.render_text(summary))
+
+    def test_raw_contamination_cannot_be_hidden_by_true_validity_flags(self):
+        mutations = [
+            lambda value: value["samples"][0].update(focused=False),
+            lambda value: value["samples"][0].update(error="lost surface"),
+            lambda value: value["samples"][1].update(mesh_revision=2),
+            lambda value: value["samples"][1].update(viewport_pixels=[640, 737]),
+            lambda value: value["samples"][0].update(surface_pixels=None),
+        ]
+        for mutation in mutations:
+            with self.subTest(mutation=mutation):
+                value = raw_report([1, 2])
+                mutation(value)
+                summary = self.analyze(value)
+                self.assertEqual(summary["accepted_run_count"], 0)
+                self.assertEqual(summary["excluded_run_count"], 1)
+                self.assertTrue(summary["excluded_runs"][0]["reasons"])
+
+    def test_web_contamination_and_missing_observation_receipt(self):
+        bad = raw_report([1, 2], host="web")
+        bad["browser_observations"]["comparable"] = False
+        summary = self.analyze(bad)
+        self.assertEqual(summary["excluded_run_count"], 1)
+        old = raw_report([1, 2], host="web")
+        old.pop("browser_observations")
+        self.assertIn("lack browser", self.analyze(old)["cases"][0]["warnings"][1])
+        software = raw_report([1, 2], host="web")
+        software["metadata"]["browser"]["automation"]["gpu_process"]["software_renderer_detected"] = True
+        self.assertEqual(self.analyze(software)["excluded_run_count"], 1)
+
+    def test_skipped_and_disabled_stages_stay_null_and_projection_is_named_separately(self):
+        renderer = self.analyze(raw_report([2, 4], mode="renderer"))["cases"][0]
+        self.assertIsNone(renderer["metrics"]["cpu_stage_ms.editor_projection"])
+        self.assertIsNone(renderer["metrics"]["cpu_stage_ms.ui"])
+        self.assertIsNotNone(renderer["metrics"]["cpu_stage_ms.scene_encode"])
+        untimed = self.analyze(raw_report([2, 4], stages=False))["cases"][0]
+        self.assertTrue(all(value is None for name, value in untimed["metrics"].items() if name.startswith("cpu_stage_ms.")))
+        editor = self.analyze(raw_report([2, 4]))["cases"][0]
+        self.assertEqual(editor["metrics"]["cpu_stage_ms.editor_projection"]["run_medians"]["median"], 0.75)
+        self.assertEqual(editor["metrics"]["cpu_frame_ms"]["run_medians"]["median"], 3)
+
+    def test_initial_missing_interval_is_not_zero_and_zero_durations_do_not_divide(self):
+        value = raw_report([0, 0])
+        value["samples"][0]["frame_start_interval_ms"] = None
+        case = self.analyze(value)["cases"][0]
+        self.assertEqual(case["runs"][0]["metrics"]["frame_start_interval_ms"]["count"], 1)
+        self.assertIsNone(case["metrics"]["cpu_frame_ms"]["run_medians"]["relative_mad_percent"])
+        compared = report.compare(self.analyze(value), self.analyze(raw_report([1, 1])))
+        metric = compared["cases"][0]["metrics"]["cpu_frame_ms"]
+        self.assertEqual(metric["delta_ms"], 1)
+        self.assertIsNone(metric["delta_percent"])
+        self.assertIn("undefined from zero", report.render_comparison(compared))
+
+    def test_missing_validity_nonfinite_negative_and_malformed_samples_fail(self):
+        mutations = [
+            lambda value: value.pop("validity"),
+            lambda value: value["validity"].pop("no_frame_errors"),
+            lambda value: value["validity"].update(no_frame_errors=1),
+            lambda value: value["samples"][0].update(cpu_frame_ms=float("nan")),
+            lambda value: value["metadata"].update(rustflags=float("inf")),
+            lambda value: value["samples"][0].update(cpu_frame_ms=-1),
+            lambda value: value["samples"][0].update(cpu_frame_ms=True),
+            lambda value: value["samples"][0].update(frame=2),
+            lambda value: value["samples"].pop(),
+            lambda value: value["samples"][1].update(frame_start_interval_ms=None),
+            lambda value: value["samples"][0]["cpu_stage_ms"].pop("ui"),
+        ]
+        for mutation in mutations:
+            with self.subTest(mutation=mutation):
+                value = raw_report([1, 2])
+                mutation(value)
+                with self.assertRaises(ValueError):
+                    self.analyze(value)
+
+    def test_identical_paths_or_copied_reports_are_not_independent_repetitions(self):
+        first = self.save(raw_report([1, 2]))
+        with self.assertRaisesRegex(ValueError, "duplicate report path"):
+            report.analyze([first, first])
+        copied = self.save(raw_report([1, 2]))
+        with self.assertRaisesRegex(ValueError, "copies are not independent"):
+            report.analyze([first, copied])
+
+    def test_reformatting_raw_json_does_not_create_an_independent_run(self):
+        value = raw_report([1, 2])
+        value["metadata"].pop("suite")
+        first = self.save(value)
+        copied = self.directory / "reformatted.json"
+        copied.write_text(json.dumps(value, indent=4, sort_keys=True))
+        self.assertNotEqual(first.read_bytes(), copied.read_bytes())
+        with self.assertRaisesRegex(ValueError, "reformatted copies are not independent"):
+            report.analyze([first, copied])
+
+    def test_repeat_incompatibility_names_source_fixture_environment_or_camera_field(self):
+        mutations = [
+            ("source", lambda value: value["metadata"]["source_fingerprint"].update(sha256="b" * 64)),
+            ("input", lambda value: value["metadata"]["input"].update(sha256="b" * 64)),
+            ("rustc", lambda value: value["metadata"].update(rustc="different compiler")),
+            ("profile", lambda value: value["metadata"].update(profile="dev")),
+            ("operating_system", lambda value: value["metadata"].update(operating_system="other OS")),
+            ("adapter", lambda value: value["metadata"]["adapter"].update(name="other GPU")),
+            ("shading", lambda value: value["render"].update(shading="material")),
+            ("camera_sequence", lambda value: value["samples"][1].update(camera_view_projection=[42] * 16)),
+        ]
+        for expected, mutation in mutations:
+            with self.subTest(expected=expected):
+                other = raw_report([1, 2], run=2)
+                mutation(other)
+                with self.assertRaisesRegex(ValueError, expected):
+                    self.analyze(raw_report([1, 2]), other)
+
+    def test_browser_version_and_headless_mode_are_incompatible_repeats(self):
+        for field, new in (("browser_version", "other version"), ("mode", "headless")):
+            with self.subTest(field=field):
+                other = raw_report([1, 2], run=2, host="web")
+                other["metadata"]["browser"]["automation"][field] = new
+                if field == "mode":
+                    other["metadata"]["browser_mode"] = new
+                with self.assertRaisesRegex(ValueError, field):
+                    self.analyze(raw_report([1, 2], host="web"), other)
+
+    def test_compare_allows_source_change_and_reports_percentage_and_both_run_ranges(self):
+        baseline = self.analyze(raw_report([10, 10], run=1), raw_report([12, 12], run=2), raw_report([14, 14], run=3))
+        candidate_runs = [raw_report([value, value], run=index) for index, value in enumerate((8, 9, 10), 1)]
+        for value in candidate_runs:
+            value["metadata"]["revision"] = "candidate revision"
+            value["metadata"]["source_fingerprint"]["sha256"] = "b" * 64
+        comparison = report.compare(baseline, self.analyze(*candidate_runs))
+        row = comparison["cases"][0]
+        self.assertEqual(row["status"], "comparable")
+        metric = row["metrics"]["cpu_frame_ms"]
+        self.assertEqual(metric["delta_percent"], -25)
+        self.assertEqual(metric["delta_ms"], -3)
+        text = report.render_comparison(comparison)
+        self.assertIn("[10.000..14.000]", text)
+        self.assertIn("[8.000..10.000]", text)
+        self.assertIn("-25.00%", text)
+
+    def test_compare_fixture_dimensions_and_missing_case_are_explicitly_unsupported(self):
+        baseline = self.analyze(raw_report([1, 2]))
+        changed = raw_report([1, 2])
+        changed["metadata"]["requested_physical_size"] = [1920, 1080]
+        changed["metadata"]["surface"].update(width=1920, height=1080)
+        for sample in changed["samples"]:
+            sample["surface_pixels"] = [1920, 1080]
+        candidate = self.analyze(changed, raw_report([1, 2], mode="renderer"))
+        comparison = report.compare(baseline, candidate)
+        self.assertEqual([case["status"] for case in comparison["cases"]], ["unsupported", "unsupported"])
+        self.assertTrue(any("requested_physical_size" in reason for reason in comparison["cases"][0]["reasons"]))
+        self.assertIn("missing", comparison["cases"][1]["reasons"][0])
+        changed["metadata"]["requested_physical_size"] = [1280, 800]
+        changed["metadata"]["surface"].update(width=1280, height=800)
+        for sample in changed["samples"]:
+            sample["surface_pixels"] = [1280, 800]
+        changed["metadata"]["input"]["sha256"] = "c" * 64
+        self.assertIn("input.sha256", report.compare(baseline, self.analyze(changed))["cases"][0]["reasons"][0])
+
+    def test_suite_raw_cannot_be_reaccepted_after_failed_teardown_or_missing_receipt(self):
+        for corruption in ("failed", "missing", "hash", "schema"):
+            with self.subTest(corruption=corruption):
+                path = self.save(raw_report([1, 2], run=self.counter + 1))
+                manifest_path = self.directory / "manifest.json"
+                manifest = json.loads(manifest_path.read_text())
+                if corruption == "failed":
+                    manifest["runs"][-1]["status"] = "failed"
+                elif corruption == "hash":
+                    manifest["runs"][-1]["sha256"] = "0" * 64
+                elif corruption == "schema":
+                    manifest["schema"] = "unknown"
+                manifest_path.write_text(json.dumps(manifest))
+                if corruption == "missing":
+                    manifest_path.unlink()
+                summary = report.analyze([path])
+                self.assertEqual(summary["accepted_run_count"], 0)
+                self.assertIn("suite receipt", summary["excluded_runs"][0]["reasons"][0])
+                manifest_path.unlink(missing_ok=True)
+
+    def test_standalone_legacy_raw_is_accepted_with_evidence_limitation(self):
+        value = raw_report([1, 2])
+        value["metadata"].pop("suite")
+        summary = self.analyze(value)
+        self.assertEqual(summary["accepted_run_count"], 1)
+        self.assertTrue(any("Standalone" in warning for warning in summary["cases"][0]["warnings"]))
+
+    def test_artifact_identity_must_match_repeats_but_can_change_in_comparison(self):
+        before = raw_report([1, 2])
+        before["metadata"]["artifact_fingerprint"] = {"sha256": "a" * 64, "files": []}
+        after = raw_report([1, 2], run=2)
+        after["metadata"]["artifact_fingerprint"] = {"sha256": "b" * 64, "files": []}
+        with self.assertRaisesRegex(ValueError, "source.artifact_fingerprint"):
+            self.analyze(before, after)
+        comparison = report.compare(self.analyze(before), self.analyze(after))
+        self.assertEqual(comparison["cases"][0]["status"], "comparable")
+
+    def test_saved_summary_validation_rejects_malformed_or_inconsistent_deltas(self):
+        valid = self.analyze(raw_report([1, 2]))
+        invalid = [None, [], {}, {"schema": report.SCHEMA}]
+        missing = deepcopy(valid)
+        missing["cases"][0].pop("configuration")
+        invalid.append(missing)
+        duplicate = deepcopy(valid)
+        duplicate["cases"] *= 2
+        invalid.append(duplicate)
+        nan = deepcopy(valid)
+        nan["cases"][0]["metrics"]["cpu_frame_ms"]["run_medians"]["median"] = float("nan")
+        invalid.append(nan)
+        forged = deepcopy(valid)
+        forged["cases"][0]["metrics"]["cpu_frame_ms"]["run_medians"]["median"] = 0.001
+        invalid.append(forged)
+        invented_frames = deepcopy(valid)
+        invented_frames["cases"][0]["frame_count"] = 1000
+        invalid.append(invented_frames)
+        dropped_frames = deepcopy(valid)
+        dropped_frames["cases"][0]["runs"][0]["metrics"]["cpu_frame_ms"]["count"] = 1
+        invalid.append(dropped_frames)
+        for value in invalid:
+            with self.subTest(value=value), self.assertRaises(ValueError):
+                report.compare(valid, value)
+
+    def test_all_contaminated_receipt_and_empty_comparison_never_claim_performance(self):
+        bad = raw_report([1, 2])
+        bad["validity"]["no_frame_errors"] = False
+        summary = self.analyze(bad)
+        self.assertEqual(summary["cases"], [])
+        self.assertIn("No accepted runs", report.render_text(summary))
+        comparison = report.compare(summary, summary)
+        self.assertEqual(comparison["cases"][0]["status"], "unsupported")
+        self.assertIn("no accepted", report.render_comparison(comparison))
+        with self.assertRaisesRegex(ValueError, "at least one"):
+            report.analyze([])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/benchmark/tests/test_statistics.py b/tools/benchmark/tests/test_statistics.py
new file mode 100644
index 0000000..3e11146
--- /dev/null
+++ b/tools/benchmark/tests/test_statistics.py
@@ -0,0 +1,62 @@
+"""Small arithmetic oracles and metamorphic checks, independent of report code."""
+
+import random
+import unittest
+
+from tools.benchmark.statistics import aggregate, distribution
+
+
+class StatisticsTests(unittest.TestCase):
+    def test_literal_distribution_oracles(self):
+        cases = [
+            ([], None),
+            ([None, None], None),
+            ([0, None, 0], {"count": 2, "median": 0, "p95": 0, "min": 0, "max": 0}),
+            ([9, 1, 4], {"count": 3, "median": 4, "p95": 9, "min": 1, "max": 9}),
+            ([9, 1, None, 7, 3], {"count": 4, "median": 5, "p95": 9, "min": 1, "max": 9}),
+            (list(range(1, 21)), {"count": 20, "median": 10.5, "p95": 19, "min": 1, "max": 20}),
+            ([1] * 19 + [10000], {"count": 20, "median": 1, "p95": 1, "min": 1, "max": 10000}),
+        ]
+        for values, expected in cases:
+            with self.subTest(values=values):
+                self.assertEqual(distribution(iter(values)), expected)
+
+    def test_unequal_run_lengths_get_equal_weight_and_keep_tail_ranges(self):
+        # These literal per-run summaries have radically different frame counts.
+        # Pooling/weighting by frame count would privilege the slow third run.
+        runs = [
+            {"count": 2, "median": 1, "p95": 2, "min": 0, "max": 2},
+            {"count": 3, "median": 3, "p95": 8, "min": 1, "max": 8},
+            {"count": 1000, "median": 9, "p95": 90, "min": 2, "max": 100},
+        ]
+        self.assertEqual(aggregate(runs), {
+            "unit": "ms", "run_count": 3,
+            "run_medians": {"median": 3, "min": 1, "max": 9, "mad": 2, "relative_mad_percent": 200 / 3},
+            "run_p95": {"median": 8, "min": 2, "max": 90},
+            "run_max": {"median": 8, "min": 2, "max": 100},
+        })
+
+    def test_zero_baseline_and_missing_stages_do_not_invent_measurements(self):
+        self.assertIsNone(aggregate([None, None]))
+        zero = {"count": 2, "median": 0, "p95": 0, "min": 0, "max": 0}
+        result = aggregate([zero, None, zero])
+        self.assertEqual(result["run_count"], 2)
+        self.assertEqual(result["run_medians"]["mad"], 0)
+        self.assertIsNone(result["run_medians"]["relative_mad_percent"])
+
+    def test_permutation_and_positive_scaling_preserve_statistics(self):
+        generator = random.Random(517)
+        for count in (1, 2, 3, 19, 20, 21, 100):
+            values = [generator.randrange(10000) for _ in range(count)]
+            expected = distribution(values)
+            shuffled = values.copy()
+            generator.shuffle(shuffled)
+            self.assertEqual(distribution(shuffled), expected)
+            scaled = distribution([value * 4 for value in shuffled])
+            self.assertEqual(scaled["count"], count)
+            for name in ("median", "p95", "min", "max"):
+                self.assertEqual(scaled[name], expected[name] * 4)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/benchmark/tests/test_suite.py b/tools/benchmark/tests/test_suite.py
new file mode 100644
index 0000000..5d33f01
--- /dev/null
+++ b/tools/benchmark/tests/test_suite.py
@@ -0,0 +1,191 @@
+import copy
+import io
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from contextlib import ExitStack, redirect_stderr, redirect_stdout
+from unittest.mock import patch
+
+from tools.benchmark import suite as bench
+
+
+def source_info():
+    return {"source_fingerprint": {"sha256": "source"}, "input": {"sha256": "fixture"},
+            "rustc": "compiler", "profile": "release", "rustflags": None,
+            "cargo_encoded_rustflags": None, "profile_overrides": {},
+            "operating_system": "test-os", "architecture": "arm64"}
+
+
+class BenchmarkSuiteTests(unittest.TestCase):
+    def setUp(self):
+        self.temporary = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temporary.cleanup)
+        self.root = Path(self.temporary.name)
+        self.input = self.root / "chess.glb"
+        self.input.write_bytes(b"test scene")
+        self.output = self.root / "suite"
+        self.arguments = ["run", "--input", str(self.input), "--output", str(self.output),
+                          "--repeats", "3", "--frames", "2", "--warmup", "1"]
+
+    def mocked_runner(self, stack):
+        stack.enter_context(redirect_stdout(io.StringIO()))
+        stack.enter_context(redirect_stderr(io.StringIO()))
+        metadata = stack.enter_context(patch.object(bench.measure, "metadata", side_effect=lambda _: source_info()))
+        def build_artifact(host, *unused, **kwargs):
+            artifact = self.root / host
+            artifact.write_bytes(host.encode())
+            return artifact
+
+        build = stack.enter_context(patch.object(bench.measure, "build", side_effect=build_artifact))
+        browser = stack.enter_context(patch.object(bench.measure, "require_browser_tools"))
+
+        def run(args, artifact, info, **kwargs):
+            self.assertEqual(artifact.name, args.host)
+            args.output.write_text(json.dumps({"options": bench.measure.options(args)}))
+
+        prepared = stack.enter_context(patch.object(bench.measure, "run_prepared", side_effect=run))
+        analyze = stack.enter_context(patch.object(bench.reports, "analyze", return_value={"excluded_runs": []}))
+        stack.enter_context(patch.object(bench.reports, "render_text", return_value="summary\n"))
+        return metadata, build, browser, prepared, analyze
+
+    def test_serial_rounds_build_each_host_once_and_retain_evidence(self):
+        with ExitStack() as stack:
+            _, build, browser, run, analyze = self.mocked_runner(stack)
+            self.assertEqual(bench.main(self.arguments), 0)
+        self.assertEqual(build.call_count, 2)
+        browser.assert_called_once()
+        self.assertEqual(run.call_count, 18)
+        self.assertEqual(len(set(call.args[0].output for call in run.call_args_list)), 18)
+        for call in run.call_args_list:
+            args, _, info = call.args
+            self.assertEqual(args.browser, "headed" if args.host == "web" else "headless")
+            self.assertEqual(args.frames, 2)
+            self.assertEqual(args.warmup, 1)
+            self.assertIn("suite", info)
+        manifest = json.loads((self.output / "manifest.json").read_text())
+        self.assertEqual(manifest["status"], "complete")
+        for repeat in range(1, 4):
+            cases = [(entry["host"], entry["mode"]) for entry in manifest["runs"] if entry["repeat"] == repeat]
+            self.assertEqual(len(cases), 6)
+            self.assertEqual(len(set(cases)), 6)
+        self.assertTrue(all(entry["status"] == "complete" for entry in manifest["runs"]))
+        self.assertEqual(len(analyze.call_args.args[0]), 18)
+        self.assertTrue((self.output / "summary.txt").is_file())
+        self.assertTrue((self.output / "summary.json").is_file())
+
+    def test_case_order_is_seeded_and_not_grouped_by_host_across_repeats(self):
+        args = bench.parser().parse_args(self.arguments)
+        plan = bench.plan_cases(args)
+        self.assertEqual(plan, bench.plan_cases(args))
+        args.seed += 1
+        self.assertNotEqual(plan, bench.plan_cases(args))
+        self.assertEqual([entry["repeat"] for entry in plan], [1] * 6 + [2] * 6 + [3] * 6)
+
+    def test_invalid_case_and_existing_output_fail_before_build(self):
+        for extra in (["--selected"], ["--repeats", "0"], ["--hosts", "native", "native"], ["--frames", "0"]):
+            with ExitStack() as stack:
+                _, build, _, run, _ = self.mocked_runner(stack)
+                with self.assertRaises(SystemExit) as failed:
+                    bench.main(self.arguments + extra)
+                self.assertEqual(failed.exception.code, 2)
+                build.assert_not_called()
+                run.assert_not_called()
+                self.assertFalse(self.output.exists())
+        self.output.mkdir()
+        sentinel = self.output / "keep.txt"
+        sentinel.write_text("old evidence")
+        with ExitStack() as stack:
+            _, build, _, run, _ = self.mocked_runner(stack)
+            with self.assertRaises(SystemExit) as failed:
+                bench.main(self.arguments)
+            self.assertEqual(failed.exception.code, 1)
+            build.assert_not_called()
+            run.assert_not_called()
+        self.assertEqual(sentinel.read_text(), "old evidence")
+
+    def test_failed_shutdown_does_not_aggregate_leftover_report_or_retry(self):
+        with ExitStack() as stack:
+            _, _, _, run, analyze = self.mocked_runner(stack)
+            count = 0
+
+            def incomplete(args, *unused, **kwargs):
+                nonlocal count
+                count += 1
+                args.output.write_text("{}")
+                if count == 2:
+                    raise RuntimeError("browser teardown failed")
+
+            run.side_effect = incomplete
+            self.assertEqual(bench.main(self.arguments), 1)
+        self.assertEqual(run.call_count, 2)
+        self.assertEqual(len(analyze.call_args.args[0]), 1)
+        manifest = json.loads((self.output / "manifest.json").read_text())
+        self.assertEqual(manifest["status"], "failed")
+        self.assertEqual([entry["status"] for entry in manifest["runs"]][:3], ["complete", "failed", "pending"])
+        self.assertTrue((self.output / manifest["runs"][1]["report"]).exists())
+
+    def test_source_change_during_session_stops_and_does_not_accept_that_run(self):
+        with ExitStack() as stack:
+            metadata, _, _, run, analyze = self.mocked_runner(stack)
+            changed = copy.deepcopy(source_info())
+            changed["source_fingerprint"]["sha256"] = "edited during measurement"
+            metadata.side_effect = [source_info(), source_info(), source_info(), changed]
+            self.assertEqual(bench.main(self.arguments), 1)
+        self.assertEqual(run.call_count, 1)
+        analyze.assert_not_called()
+        manifest = json.loads((self.output / "manifest.json").read_text())
+        self.assertEqual(manifest["runs"][0]["status"], "failed")
+        self.assertIn("source_fingerprint", manifest["error"])
+
+    def test_contamination_is_reported_without_silent_retries(self):
+        with ExitStack() as stack:
+            _, _, _, run, analyze = self.mocked_runner(stack)
+            analyze.return_value = {"excluded_runs": [{"reason": "focus lost"}]}
+            self.assertEqual(bench.main(self.arguments), 1)
+        self.assertEqual(run.call_count, 18)
+        self.assertEqual(json.loads((self.output / "manifest.json").read_text())["status"], "contaminated")
+
+    def test_aggregation_failure_is_not_a_complete_suite(self):
+        with ExitStack() as stack:
+            _, _, _, _, analyze = self.mocked_runner(stack)
+            analyze.side_effect = ValueError("incompatible environment")
+            with self.assertRaises(SystemExit) as failed:
+                bench.main(self.arguments)
+            self.assertEqual(failed.exception.code, 1)
+        manifest = json.loads((self.output / "manifest.json").read_text())
+        self.assertEqual(manifest["status"], "failed")
+        self.assertIn("Aggregation failed", manifest["error"])
+        self.assertTrue(all(entry["status"] == "complete" for entry in manifest["runs"]))
+
+    def test_build_artifacts_are_frozen_and_tampering_rejects_the_run(self):
+        with ExitStack() as stack:
+            _, _, _, run, analyze = self.mocked_runner(stack)
+
+            def tamper(args, artifact, *unused, **kwargs):
+                self.assertTrue(artifact.is_relative_to(self.output / "artifacts"))
+                self.assertNotEqual(artifact, self.root / args.host)
+                args.output.write_text("{}")
+                artifact.write_bytes(b"changed executable")
+
+            run.side_effect = tamper
+            self.assertEqual(bench.main(self.arguments), 1)
+        analyze.assert_not_called()
+        manifest = json.loads((self.output / "manifest.json").read_text())
+        self.assertIn("Prepared artifact changed", manifest["error"])
+        self.assertEqual(manifest["runs"][0]["status"], "failed")
+
+    def test_web_snapshot_preserves_full_site_and_detects_modification(self):
+        site = self.root / "site"
+        (site / "pkg").mkdir(parents=True)
+        (site / "pkg/n3.wasm").write_bytes(b"wasm")
+        (site / "measure.html").write_text("html")
+        frozen, fingerprint = bench.snapshot_artifact(site, self.root / "frozen")
+        self.assertEqual(bench.artifact_fingerprint(frozen), fingerprint)
+        (site / "pkg/n3.wasm").write_bytes(b"new build")
+        self.assertEqual((frozen / "pkg/n3.wasm").read_bytes(), b"wasm")
+        self.assertNotEqual(bench.artifact_fingerprint(site), fingerprint)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/benchmark/tests/test_transport.py b/tools/benchmark/tests/test_transport.py
new file mode 100644
index 0000000..cfad8b8
--- /dev/null
+++ b/tools/benchmark/tests/test_transport.py
@@ -0,0 +1,111 @@
+from http.client import HTTPConnection
+import json
+from pathlib import Path
+import tempfile
+import threading
+import unittest
+
+from tools.benchmark import transport
+from .support import capture_envelope
+
+
+class MeasurementServerTests(unittest.TestCase):
+    def setUp(self):
+        self.temporary = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temporary.cleanup)
+        self.root = Path(self.temporary.name)
+        self.site = self.root / "site"
+        self.site.mkdir()
+        (self.site / "measure.html").write_text("measurement page")
+        (self.site / "measure.js").write_text("import './pkg/n3.js'")
+        (self.site / "pkg").mkdir()
+        (self.site / "pkg/n3.js").write_text("wasm glue")
+        (self.site / "pkg/n3_bg.wasm").write_bytes(b"wasm")
+        self.source = self.root / "user model.obj"
+        self.source.write_bytes(b"private selected bytes")
+        self.output = self.root / "report.json"
+        self.server = transport.MeasurementServer(self.site, self.source, self.output, {"options": {"sample_frames": 1, "mode": "editor"}}, token="test-token")
+        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+        self.thread.start()
+        self.addCleanup(self.stop)
+
+    def stop(self):
+        self.server.shutdown()
+        self.server.server_close()
+        self.thread.join()
+
+    def request(self, endpoint, method="GET", body=None, headers=None, raw=False):
+        connection = HTTPConnection("127.0.0.1", self.server.server_port, timeout=5)
+        connection.request(method, endpoint if raw else f"/test-token/{endpoint}", body, headers or {})
+        response = connection.getresponse()
+        status, response_headers, content = response.status, dict(response.getheaders()), response.read()
+        connection.close()
+        return status, response_headers, content
+
+    def valid_report(self):
+        return capture_envelope(self.server.config["options"])
+
+    def post(self, body, headers=None):
+        return self.request("report", "POST", body, headers or {
+            "Origin": self.server.origin, "Content-Type": "application/json",
+        })[0]
+
+    def test_only_selected_input_and_explicit_site_allowlist_are_readable(self):
+        for endpoint in ("measure.html", "measure.js", "pkg/n3.js", "pkg/n3_bg.wasm", "config.json", "input"):
+            with self.subTest(endpoint=endpoint):
+                status, headers, body = self.request(endpoint)
+                self.assertEqual(status, 200)
+                self.assertEqual(headers["Cache-Control"], "no-store")
+                if endpoint == "input":
+                    self.assertEqual(body, b"private selected bytes")
+        (self.site / "other.txt").write_text("not public")
+        for endpoint in ("../user%20model.obj", "%2e%2e/user%20model.obj", "other.txt", "", "pkg/../measure.html", "input/other"):
+            with self.subTest(endpoint=endpoint):
+                self.assertEqual(self.request(endpoint)[0], 404)
+        self.assertEqual(self.request("/wrong-token/input", raw=True)[0], 404)
+        self.assertEqual(self.request("/test-token-other/input", raw=True)[0], 404)
+        (self.site / "measure.js").unlink()
+        (self.site / "measure.js").symlink_to(self.source)
+        self.assertEqual(self.request("measure.js")[0], 404)
+
+    def test_host_and_origin_prevent_cross_origin_access(self):
+        self.assertEqual(self.request("input", headers={"Host": "attacker.example"})[0], 403)
+        self.assertEqual(self.request("input", headers={"Origin": "https://attacker.example"})[0], 403)
+        self.assertEqual(self.post('{"samples":[{}]}', {"Content-Type": "application/json"}), 403)
+        self.assertEqual(self.post('{"samples":[{}]}', {"Content-Type": "application/json", "Origin": "http://localhost:1"}), 403)
+        self.assertFalse(self.output.exists())
+
+    def test_one_bounded_json_report_writes_only_preselected_output(self):
+        for body in ("not json", "[]", "{}", '{"samples":[]}', '{"samples":[{"elapsed":NaN}]}', '{"samples":[{"elapsed":1e999}]}'):
+            with self.subTest(body=body):
+                self.assertEqual(self.post(body), 400)
+                self.assertFalse(self.output.exists())
+        self.assertEqual(self.post("{}", {"Origin": self.server.origin, "Content-Type": "text/plain"}), 415)
+        self.assertEqual(self.post("", {"Origin": self.server.origin, "Content-Type": "application/json", "Content-Length": str(transport.MAX_REPORT_BYTES + 1)}), 413)
+        report = self.valid_report()
+        report["output"] = str(self.root / "unselected.json")
+        self.assertEqual(self.post(json.dumps(report)), 200)
+        self.assertTrue(self.server.completed.wait(2))
+        self.assertEqual(json.loads(self.output.read_text()), report)
+        self.assertFalse((self.root / "unselected.json").exists())
+        self.assertEqual(self.post(json.dumps(report)), 409)
+
+    def test_invalid_mode_report_is_rejected_without_consuming_output(self):
+        report = self.valid_report()
+        report["validity"]["mode_contract_satisfied"] = False
+        self.assertEqual(self.post(json.dumps(report)), 400)
+        self.assertFalse(self.output.exists())
+        self.assertFalse(self.server.completed.is_set())
+        report["validity"] = {"mode_contract_satisfied": True, "all_frames_focused": False}
+        self.assertEqual(self.post(json.dumps(report)), 200)
+        self.assertFalse(json.loads(self.output.read_text())["validity"]["all_frames_focused"])
+
+    def test_existing_file_and_symlink_are_not_overwritten(self):
+        self.output.write_text("earlier evidence")
+        self.assertEqual(self.post(json.dumps(self.valid_report())), 409)
+        self.assertEqual(self.output.read_text(), "earlier evidence")
+        self.output.unlink()
+        self.output.symlink_to(self.source)
+        self.assertEqual(self.post(json.dumps(self.valid_report())), 409)
+        self.assertEqual(self.source.read_bytes(), b"private selected bytes")
+        self.assertFalse(self.server.completed.is_set())
diff --git a/tools/benchmark/transport.py b/tools/benchmark/transport.py
new file mode 100644
index 0000000..5a86a66
--- /dev/null
+++ b/tools/benchmark/transport.py
@@ -0,0 +1,190 @@
+"""Bounded local transport and capture envelopes, not performance-data admission.
+
+The collector preserves raw reports. contracts.py independently checks the complete
+record and observed facts before any saved sample enters statistical analysis.
+"""
+
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+import os
+import secrets
+import shutil
+import threading
+from urllib.parse import urlsplit
+
+MAX_REPORT_BYTES = 32 * 1024 * 1024
+STATIC_FILES = {
+    "measure.html": "text/html; charset=utf-8",
+    "measure.js": "text/javascript; charset=utf-8",
+    "observations.mjs": "text/javascript; charset=utf-8",
+    "pkg/n3.js": "text/javascript; charset=utf-8",
+    "pkg/n3_bg.wasm": "application/wasm",
+    "licenses/Inter.txt": "text/plain; charset=utf-8",
+    "licenses/Lucide.txt": "text/plain; charset=utf-8",
+}
+
+
+
+def validate_envelope(report, expected_options):
+    if not isinstance(report, dict) or report.get("schema") != "n3.viewport-measure.v2":
+        raise ValueError("Expected an n3.viewport-measure.v2 report")
+    if report.get("options") != expected_options:
+        raise ValueError("Report options do not match this run")
+    samples = report.get("samples")
+    if not isinstance(samples, list) or len(samples) != expected_options["sample_frames"]:
+        raise ValueError("Report does not contain the requested number of samples")
+    for index, sample in enumerate(samples):
+        if not isinstance(sample, dict) or sample.get("frame") != index:
+            raise ValueError("Report sample indices must be consecutive")
+        elapsed = sample.get("cpu_frame_ms")
+        if type(elapsed) not in (int, float) or elapsed < 0:
+            raise ValueError("Report contains an invalid CPU frame duration")
+    validity = report.get("validity")
+    if not isinstance(validity, dict) or validity.get("mode_contract_satisfied") is not True:
+        raise ValueError("Report does not satisfy the requested measurement mode contract")
+    # Reject JSON's permissive NaN/Infinity inputs, including overflowing floats.
+    json.dumps(report, allow_nan=False)
+    return report
+
+
+def write_report(output, report):
+    # Exclusive creation protects earlier evidence and prevents a late symlink
+    # replacement from redirecting this one explicitly selected output file.
+    with output.open("x", encoding="utf-8") as destination:
+        json.dump(report, destination, indent=2, allow_nan=False)
+        destination.write("\n")
+
+
+class MeasurementServer(ThreadingHTTPServer):
+    daemon_threads = True
+
+    def __init__(self, root, selected_input, output, config, port=0, token=None):
+        self.root = root.resolve()
+        self.selected_input = selected_input
+        self.output = output
+        self.config = config
+        self.token = token or secrets.token_urlsafe(24)
+        self.completed = threading.Event()
+        self.report_lock = threading.Lock()
+        super().__init__(("127.0.0.1", port), MeasurementHandler)
+        self.origin = f"http://127.0.0.1:{self.server_port}"
+        self.url = f"{self.origin}/{self.token}/measure.html"
+
+
+class MeasurementHandler(BaseHTTPRequestHandler):
+    def setup(self):
+        super().setup()
+        self.connection.settimeout(15)
+
+    def endpoint(self):
+        expected_host = f"127.0.0.1:{self.server.server_port}"
+        if self.headers.get("Host") != expected_host:
+            self.send_error(403)
+            return None
+        origin = self.headers.get("Origin")
+        if origin is not None and origin != self.server.origin:
+            self.send_error(403)
+            return None
+        parsed = urlsplit(self.path)
+        prefix = f"/{self.server.token}/"
+        if parsed.scheme or parsed.netloc or not parsed.path.startswith(prefix):
+            self.send_error(404)
+            return None
+        return parsed.path[len(prefix):]
+
+    def do_GET(self):
+        endpoint = self.endpoint()
+        if endpoint is None:
+            return
+        if endpoint == "config.json":
+            self.send_bytes(json.dumps(self.server.config).encode(), "application/json")
+        elif endpoint == "input":
+            self.send_file(self.server.selected_input, "application/octet-stream")
+        elif endpoint in STATIC_FILES:
+            path = self.server.root / endpoint
+            # Do not let a symlink in generated output expose another directory.
+            try:
+                path.resolve(strict=True).relative_to(self.server.root)
+            except (OSError, RuntimeError, ValueError):
+                self.send_error(404)
+                return
+            self.send_file(path, STATIC_FILES[endpoint])
+        else:
+            self.send_error(404)
+
+    def send_file(self, path, content_type):
+        try:
+            source = path.open("rb")
+        except OSError:
+            self.send_error(404)
+            return
+        with source:
+            self.send_response(200)
+            self.send_header("Content-Type", content_type)
+            self.send_header("Content-Length", str(os.fstat(source.fileno()).st_size))
+            self.end_headers()
+            shutil.copyfileobj(source, self.wfile)
+
+    def send_bytes(self, body, content_type, status=200):
+        self.send_response(status)
+        self.send_header("Content-Type", content_type)
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    def do_POST(self):
+        endpoint = self.endpoint()
+        if endpoint is None:
+            return
+        if endpoint != "report":
+            self.send_error(404)
+            return
+        # Reports require same-origin JS, not a cross-origin HTML form. The
+        # capability token also guards selected model bytes and configuration.
+        if self.headers.get("Origin") != self.server.origin:
+            self.send_error(403)
+            return
+        if self.headers.get_content_type() != "application/json" or self.headers.get("Transfer-Encoding"):
+            self.send_error(415)
+            return
+        try:
+            length = int(self.headers.get("Content-Length", "0"))
+        except ValueError:
+            self.send_error(400)
+            return
+        if not 0 < length <= MAX_REPORT_BYTES:
+            self.send_error(413)
+            return
+        try:
+            body = self.rfile.read(length)
+            if len(body) != length:
+                raise ValueError("Incomplete report")
+            report = json.loads(body)
+            validate_envelope(report, self.server.config["options"])
+        except (ValueError, UnicodeError, RecursionError):
+            self.send_error(400)
+            return
+        with self.server.report_lock:
+            if self.server.completed.is_set():
+                self.send_error(409)
+                return
+            try:
+                write_report(self.server.output, report)
+            except OSError:
+                self.send_error(409)
+                return
+            try:
+                self.send_bytes(b'{"saved":true}', "application/json")
+            finally:
+                self.server.completed.set()
+
+    def end_headers(self):
+        self.send_header("Cache-Control", "no-store")
+        self.send_header("X-Content-Type-Options", "nosniff")
+        self.send_header("Referrer-Policy", "no-referrer")
+        self.send_header("Cross-Origin-Resource-Policy", "same-origin")
+        super().end_headers()
+
+    def log_message(self, format, *args):
+        # Never print the capability token or paths to a user's selected model.
+        pass
diff --git a/tools/ci_runner.py b/tools/ci_runner.py
index 26dd0db..c9f596c 100644
--- a/tools/ci_runner.py
+++ b/tools/ci_runner.py
@@ -47,13 +47,17 @@
 cargo clippy --locked --all-targets -- -D warnings
 python3 -m unittest discover -s tools/tests -p 'test_*.py'
 node --test tools/tests/web_wrapper.test.mjs
+python3 -m unittest discover -s tools/benchmark/tests -t . -p 'test_*.py'
+node --test tools/benchmark/tests/*.test.mjs
 """
-CI_SCRIPT = CI_CHECKS_PREFIX + "exec cargo test --locked\n"
+BENCHMARK_RUST_CHECKS = """\
+cargo test --locked --features viewport-measure --lib measurement::
+cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize
+"""
+CI_SCRIPT = CI_CHECKS_PREFIX + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n"
 CI_PARTITION_SCRIPTS = {
-    "checks": CI_CHECKS_PREFIX + """\
-python3 tools/ci_test_inventory.py
-exec cargo test --locked -- --skip documentation::tests::generated_documentation_is_current --exact
-""",
+    "checks": CI_CHECKS_PREFIX + "python3 tools/ci_test_inventory.py\n" + BENCHMARK_RUST_CHECKS
+    + "exec cargo test --locked -- --skip documentation::tests::generated_documentation_is_current --exact\n",
     "guide": """\
 python3 tools/ci_test_inventory.py
 exec cargo test --locked documentation::tests::generated_documentation_is_current -- --exact --nocapture
diff --git a/tools/tests/test_ci_runner.py b/tools/tests/test_ci_runner.py
index fabf4e2..977d537 100644
--- a/tools/tests/test_ci_runner.py
+++ b/tools/tests/test_ci_runner.py
@@ -105,6 +105,10 @@ def test_ci_runs_full_checks_without_recursing_through_just_or_runner(self):
             "cargo clippy --locked --all-targets -- -D warnings",
             "python3 -m unittest discover -s tools/tests -p 'test_*.py'",
             "node --test tools/tests/web_wrapper.test.mjs",
+            "python3 -m unittest discover -s tools/benchmark/tests -t . -p 'test_*.py'",
+            "node --test tools/benchmark/tests/*.test.mjs",
+            "cargo test --locked --features viewport-measure --lib measurement::",
+            "cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize",
             "exec cargo test --locked",
         ):
             self.assertIn(command, script)
diff --git a/tools/tests/test_web.py b/tools/tests/test_web.py
index e4012f3..e6ef767 100644
--- a/tools/tests/test_web.py
+++ b/tools/tests/test_web.py
@@ -127,6 +127,10 @@ def run(command, **kwargs):
     def test_successful_build_honors_cargo_overrides_and_ships_font_notices(self):
         self.install_generator()
         self.prepare_site()
+        benchmark = self.root / "tools/benchmark/browser"
+        benchmark.mkdir(parents=True)
+        (benchmark / "measure.html").write_text("contributor harness")
+        (benchmark / "measure.js").write_text("contributor driver")
         cargo_home = str(self.root / "custom cargo")
         target_dir = str(self.root / "custom target")
 
@@ -154,6 +158,8 @@ def run(command, **kwargs):
         self.assertEqual((site / "pkg/n3_bg.wasm").read_bytes(), b"new WASM")
         self.assertEqual((site / "licenses/Inter.txt").read_text(), "Inter notice")
         self.assertEqual((site / "licenses/Lucide.txt").read_text(), "Lucide notice")
+        self.assertFalse((site / "measure.html").exists())
+        self.assertFalse((site / "measure.js").exists())
         self.assertEqual(list((self.root / "build").glob("web-stage-*")), [])