# STOOP Lab v0 Decisions with rejected alternatives (runtime version pin, stills vs. video, the lazy-load payload split, why the recorded-fallback GIF is fetched only on failure): `docs/adr/ADR-0006-lab-v0-browser-runtime.md`. `site/lab/index.html` — peregrine's real FP32 detector vs. its own INT8 quantization (the nanostoop path), running side by side in the visitor's browser via ONNX Runtime Web, on a bundled deterministic clip. Self-contained: no CDN, no external requests, every dependency vendored under `vendor/`. This is the **v0 slice** of TZ §3 Phase 3's protective spec. It is real and working — not a mockup — but it is deliberately narrower than the Phase 3 spec's full scope. This file is the honest accounting of both halves: what v0 actually does, verified, and what v1 still owes. ## What works today (v0, verified 2026-08-20) - **Real FP32 vs. real INT8, live, in-browser.** ONNX Runtime Web 1.18.0 (wasm EP) runs `peregrine-fp32.onnx` (peregrine's committed `models/best.onnx`, byte-identical, sha256-verified) and `peregrine-int8.onnx` (statically quantized by `scripts/quantize_lab_int8.py`, calibrated on 40 real frames from peregrine's own 200-image calibration set) against the same input tensor, per frame. - **Nanostoop's zeroing-bug lesson, actually applied, not just cited.** Static quantization at defaults silently zeroes every class score on this graph (its final `Concat` node forces the box branch and the class-score branch onto one shared int8 scale — see `nanostoop/nanostoop.py`'s `quantize_to_int8` docstring and `scripts/quantize_lab_int8.py`'s own docstring, which credits it). The fix (`op_types_to_quantize=["Conv"]`) is applied, and `tests/test_lab_page.py:: test_lab_int8_model_used_the_zeroing_bug_fix` reads the quantizer's own provenance manifest to make sure a future edit can't silently drop it. - **Accuracy-divergence only, latency from the matrix.** Per TZ §14.2: ORT-Web's WebGPU EP has incomplete INT8 coverage, so an in-tab FP32-vs-INT8 speed race is structurally invalid. This page never renders a browser-measured millisecond as a performance number — the "Latency" panel embeds real rows, verbatim, from `artifacts/matrix/target-matrix-2026-08-19.html`, each carrying its own hardware label and RECORDED badge. - **Live agreement %, lost/gained boxes, mean IoU drift** — per-frame and pooled across a full 90-frame run. Decode/NMS/match logic (`lab-core.mjs`) is a direct port of `nanostoop.py`'s `decode`/`match_detections` and `scripts/fragility_derisk.py`'s `decode_yolov8`/`_class_aware_nms` — same constants (conf=0.25, NMS IoU=0.45, match IoU=0.50), not reinvented. - **Default path is the bundled 90-frame clip**, not a webcam (TZ §3-Phase3-1): real, sequential warehouse stills (`IMG_0265_mp4`, frames 0–89, CC BY 4.0), resized and re-encoded — see `assets/clip/manifest.json` for full disclosure. Webcam is an explicit upgrade button (`getUserMedia`), gracefully declines with a visible message if denied or unavailable (tested: `NotAllowedError`/`NotSupportedError` both handled). - **JPEG-quality degradation slider**, severity 0–5, the exact quality ladder from `src/stoop/degrade/params.py` (`90, 75, 60, 40, 25`), applied via canvas re-encode to the source frame **before** letterbox/preprocess, identically to both panels. - **Fallback: WASM-only + hard styled failure screen.** If `ort.InferenceSession.create` throws for any reason (verified by blocking the model request in a real Playwright run), the page hides the live UI and shows a styled "your browser can't run this" screen with a real Playwright-recorded GIF of a working run — never an empty panel. The GIF (`assets/recorded-fallback.gif`, 2.8 MB) is fetched lazily, only on that failure path, so a normal successful visit never pays for its bytes. - **Device strip** (LIVE badge): execution provider, WebGPU presence (detected via `navigator.gpu`, labeled "not used, v0" — see below), wasm thread count, wasm SIMD flag, user agent — all read from the actual running session, this tab, this load. - **Gate Lab — the TZ's stated central interaction, built.** A device-budget slider tied to REAL measured numbers (`assets/gate-data.json`, copied verbatim from `artifacts/trt/`, `artifacts/coreml/`, and `artifacts/fragility/derisk-2026-08-19-nms070.json`'s clean condition) — drag the latency budget below the selected device's RECORDED p95 (or the quality budget below the clean-condition INT8 mAP50:95 drop) and the verdict flips **PASS → REFUSED**, naming the exact failed budget, the device, and an evidence link, mirroring `peregrine/src/peregrine/gates.py`'s `evaluate_release_gates()` semantics by hand (`lab-core.mjs`'s `evaluateLatencyGate`/`evaluateQualityGate`/`evaluateGateVerdict`). Lives **outside** `#lab-live` — it has no ONNX Runtime dependency, so it still renders on the `#lab-fallback` path. - **Loupe v0 — failure gallery, built.** 6 of the 24 stratified overlays from the golden-v1 collapse investigation (`artifacts/golden/golden-v1-collapse-investigation-2026-08-20.md`), re-encoded to WebP (`site/lab/assets/loupe/`, ~248KB total vs. ~5.3MB at source), captions copied verbatim from that investigation's own visual audit, RECORDED badge. - **"Copy my numbers", built.** Visitor's device strip + this frame's agreement numbers + the current Gate Lab config, as a markdown table to the clipboard (falls back to a selected `