diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml
new file mode 100644
index 000000000..ad38024b2
--- /dev/null
+++ b/.github/workflows/verification.yml
@@ -0,0 +1,42 @@
+name: Verification
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "verification/**"
+ - ".github/workflows/verification.yml"
+ pull_request:
+ paths:
+ - "verification/**"
+ - ".github/workflows/verification.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: verification-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ verification-checks:
+ name: Verification checks
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: verification
+ steps:
+ # The default depth of 1 is enough: tests/test_gitref.py only reads HEAD, and a detached
+ # worktree of HEAD works in a shallow clone.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ # Reads tool.uv.required-version from verification/pyproject.toml to pick the uv version.
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
+ with:
+ working-directory: verification
+ - run: uv sync --locked
+ - run: uv run ruff check .
+ - run: uv run ruff format --check .
+ - run: uv run pytest -q
diff --git a/verification/Makefile b/verification/Makefile
new file mode 100644
index 000000000..de79d452a
--- /dev/null
+++ b/verification/Makefile
@@ -0,0 +1,46 @@
+# Independent verification. Every target runs from this folder; reports go to
+# ~/house-scanning-data/reports/ (outside git) because runs can show dataset frames.
+
+REF ?= origin/t3/ios-mvf
+SERVER_REF ?= origin/t3/server
+# Set SERVER_URL to use a running server (the hosted API) instead of starting one from SERVER_REF.
+SERVER_URL ?=
+SERVER = $(if $(SERVER_URL),--server-url $(SERVER_URL),--server-ref $(SERVER_REF))
+REPLAY ?= $(HOME)/house-scanning-data/replays/advio-20-0040-0075
+ARGS ?=
+
+.PHONY: test sim sim-app sim-probe e2e scoreboard
+
+test:
+ uv run ruff check .
+ uv run ruff format --check .
+ uv run pytest -q
+
+# Build REF for the Simulator, run it with ARGS (e.g. ARGS="--replay
--autopilot"),
+# screenshot every STATE.
+sim:
+ uv run python -m hsverify.simrun --ref $(REF) $(ARGS)
+
+# The app at REF on the real ADVIO replay (C3), driven by its autopilot, uploading to the server
+# started from SERVER_REF (or at SERVER_URL): a screenshot per STATE in ~/house-scanning-data/reports/sim/. Fails
+# unless the run reaches the result state; the scan bundle is copied into the report as scan.zip.
+sim-app:
+ uv run python -m hsverify.simrun --ref $(REF) --replay $(REPLAY) --autopilot \
+ $(SERVER) --extra-arg=-autopilotHold --extra-arg=3 \
+ --idle 60 --timeout 900 --require result --require-export --label replay $(ARGS)
+
+# Every case in e2e/cases/ plus the ETH3D facade scene through the server's API at SERVER_REF;
+# each result is validated against server/schemas/result.schema.json at the same ref.
+e2e:
+ uv run python -m hsverify.e2e $(SERVER) --scene scenes/eth3d-facade/case.json $(ARGS)
+
+# Self-test of the runner against a fixture app that logs a known STATE sequence.
+sim-probe:
+ uv run python -m hsverify.simrun --ref HEAD --project verification/fixtures/state-probe/StateProbe.xcodeproj \
+ --scheme StateProbe --until probe_done --extra-arg=-probeArg --label probe
+
+# Every open PR with its CI, and every overnight-plan metric with its evidence or gap. Writes
+# SCOREBOARD.md and scoreboard/scoreboard.json. ARGS=--slow also runs the slow test probes;
+# ARGS="--slow S2-1" runs only that one.
+scoreboard:
+ uv run python -m hsverify.scoreboard $(ARGS)
diff --git a/verification/README.md b/verification/README.md
new file mode 100644
index 000000000..b20d4031c
--- /dev/null
+++ b/verification/README.md
@@ -0,0 +1,36 @@
+# Verification (S4)
+
+**Question.** Do the app and server branches do what the plan says? Pass: the app at
+`t3/ios-mvf`'s head reaches a result from the real ADVIO replay and keeps its scan; every server
+answer at `t3/server`'s head validates against the result schema and never passes without the
+coverage its checks need; hostile inputs are refused, or answered with a valid result, within
+10 s and 500 MB of server growth; each plan metric has evidence at the current head.
+
+**Run**, from `verification/`, with uv (and Xcode 26 with an iOS Simulator for `sim-app`):
+
+```
+make sim-app # REF=, SERVER_REF= or SERVER_URL=
+make e2e # ARGS="--app-export "
+make scoreboard # writes SCOREBOARD.md
+make test
+```
+
+Reports go to `~/house-scanning-data/reports/`, outside git, because replay frames come from
+non-commercial datasets. What each check asserts is in the docstrings of `hsverify/e2e.py`,
+`resultcheck.py` and `simrun.py`.
+
+**Result.** At `t3/server` `3baa338`, with the app's upload from `c820cf1` (#10's freeze, synthetic
+replay, hosted server), 49 of 50 scenes pass (45 answered and checked; slowest real scene 0.48 s).
+The ETH3D scene fails: after its one requested capture the server leaves pool unsure with no
+request or reason.
+
+**What it changed.** S2 fixed seven defects it found:
+- unseen ground passing the pool check;
+- a short reach cutoff;
+- unbounded input cost;
+- incomplete photo requests;
+- repeated requests past a wall end;
+- ground past a real end ignored;
+- a facing gap called too close to call.
+
+The friction audit (#61) became team decision 8.
diff --git a/verification/SCOREBOARD.md b/verification/SCOREBOARD.md
new file mode 100644
index 000000000..3f5050b69
--- /dev/null
+++ b/verification/SCOREBOARD.md
@@ -0,0 +1,102 @@
+# Scoreboard
+
+Generated 2026-09-27 09:49 CDT by `make scoreboard` (`uv run python -m hsverify.scoreboard`) in `verification/`; slow probes ran for S2-1, S2-7 only. Probes are defined in `verification/scoreboard/metrics.yaml`.
+
+## Open pull requests
+
+| # | Branch | Head | Draft | CI | Updated |
+| --- | --- | --- | --- | --- | --- |
+| [#172](https://github.com/SamGu-NRX/house-scanning/pull/172) | `t3/ci-flake-fixes-beta-2` | `c3aeabea` | no | 5 success, 1 skipped | 2026-09-27 08:28 CDT |
+| [#165](https://github.com/SamGu-NRX/house-scanning/pull/165) | `t3/ci-flake-fixes` | `4925f37f` | no | 3 success, 1 failure, 1 skipped; failing: iOS build | 2026-09-27 08:58 CDT |
+| [#158](https://github.com/SamGu-NRX/house-scanning/pull/158) | `t3/live-fog` | `f57310ca` | no | 5 success, 1 failure, 1 skipped; failing: iOS build | 2026-09-27 06:07 CDT |
+| [#143](https://github.com/SamGu-NRX/house-scanning/pull/143) | `t3/practice-meter` | `dd219a05` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 09:45 CDT |
+| [#134](https://github.com/SamGu-NRX/house-scanning/pull/134) | `shrey/readme` | `9ceeece3` | no | 4 success | 2026-09-27 09:45 CDT |
+| [#124](https://github.com/SamGu-NRX/house-scanning/pull/124) | `t3/integration-viewer` | `9c0680b6` | no | 5 success | 2026-09-27 05:47 CDT |
+| [#123](https://github.com/SamGu-NRX/house-scanning/pull/123) | `t3/hunter-integration` | `17b53baa` | yes | 2 success, 1 skipped | 2026-09-27 07:17 CDT |
+| [#122](https://github.com/SamGu-NRX/house-scanning/pull/122) | `t3/41-server-requests-text` | `7046c93b` | no | 4 success, 1 skipped | 2026-09-27 08:10 CDT |
+| [#120](https://github.com/SamGu-NRX/house-scanning/pull/120) | `t3/41-walk-task-hold` | `30ba9b39` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 08:24 CDT |
+| [#119](https://github.com/SamGu-NRX/house-scanning/pull/119) | `t3/41-anchored-marks` | `f03430fe` | no | 4 success, 1 skipped | 2026-09-27 07:49 CDT |
+| [#109](https://github.com/SamGu-NRX/house-scanning/pull/109) | `t3/41-gap-loop-result` | `ecc7a576` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 09:21 CDT |
+| [#108](https://github.com/SamGu-NRX/house-scanning/pull/108) | `t3/41-ground-and-meter-tap` | `92c8f92f` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 07:49 CDT |
+| [#104](https://github.com/SamGu-NRX/house-scanning/pull/104) | `t3/41-gate-coaching` | `5f8f8777` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 08:53 CDT |
+| [#102](https://github.com/SamGu-NRX/house-scanning/pull/102) | `t3/41-card-replies` | `4183f61b` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 08:52 CDT |
+| [#100](https://github.com/SamGu-NRX/house-scanning/pull/100) | `t3/41-ar-canvas-fallback` | `0ada11b9` | no | 4 success, 1 pending | 2026-09-27 08:29 CDT |
+| [#99](https://github.com/SamGu-NRX/house-scanning/pull/99) | `t3/41-end-preview` | `f5a27c64` | no | 5 success | 2026-09-27 09:33 CDT |
+| [#90](https://github.com/SamGu-NRX/house-scanning/pull/90) | `t3/check-experiments` | `9d66b31c` | no | 6 success | 2026-09-27 09:28 CDT |
+| [#88](https://github.com/SamGu-NRX/house-scanning/pull/88) | `t3/autodetect-lab` | `538f60e1` | yes | 3 success | 2026-09-27 05:49 CDT |
+| [#87](https://github.com/SamGu-NRX/house-scanning/pull/87) | `t3/41-zip-deflate` | `aa5999e0` | no | 4 success, 1 pending | 2026-09-27 08:24 CDT |
+| [#86](https://github.com/SamGu-NRX/house-scanning/pull/86) | `t3/experience` | `b442f0a0` | yes | 3 success, 1 skipped | 2026-09-27 05:48 CDT |
+| [#61](https://github.com/SamGu-NRX/house-scanning/pull/61) | `t3/app-review-docs` | `c0b628b6` | no | 5 success | 2026-09-27 05:48 CDT |
+| [#60](https://github.com/SamGu-NRX/house-scanning/pull/60) | `t3/device-test-4.1-analysis` | `c566490c` | no | 3 success | 2026-09-27 05:55 CDT |
+| [#59](https://github.com/SamGu-NRX/house-scanning/pull/59) | `t3/signals-lab` | `0cde05e3` | yes | 3 success | 2026-09-27 05:50 CDT |
+| [#58](https://github.com/SamGu-NRX/house-scanning/pull/58) | `t3/ios-ci-no-preboot` | `7f1037e0` | no | 4 success, 1 skipped | 2026-09-27 07:02 CDT |
+| [#56](https://github.com/SamGu-NRX/house-scanning/pull/56) | `t3/walk-guidance-gaps` | `16b1c249` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 08:24 CDT |
+| [#55](https://github.com/SamGu-NRX/house-scanning/pull/55) | `t3/motion-permission` | `b289c7df` | no | 4 success, 1 skipped | 2026-09-27 09:46 CDT |
+| [#52](https://github.com/SamGu-NRX/house-scanning/pull/52) | `t3/battery-mark` | `086ad94b` | no | 3 success, 1 pending, 1 skipped | 2026-09-27 09:31 CDT |
+| [#49](https://github.com/SamGu-NRX/house-scanning/pull/49) | `t3/gap-skip-continues` | `6c3ac625` | no | 4 success, 1 skipped | 2026-09-27 08:47 CDT |
+| [#46](https://github.com/SamGu-NRX/house-scanning/pull/46) | `t3/field-test-run2` | `afd02c82` | no | 3 success | 2026-09-27 05:48 CDT |
+| [#23](https://github.com/SamGu-NRX/house-scanning/pull/23) | `t3/device-field-test` | `321ea7e2` | no | 3 success | 2026-09-27 05:49 CDT |
+
+## S1 Evals on real data
+
+`origin/t3/evals` at [`b4601ca5`](https://github.com/SamGu-NRX/house-scanning/tree/b4601ca59f2122a54d91c6981cdc74507248d6b1). 1 met, 2 partial, 1 gap, 1 not run.
+
+| Metric | Text | Status | Evidence |
+| --- | --- | --- | --- |
+| S1-1 | One command regenerates every number from a clean checkout | partial | make drift at b5ca7af from a clean worktree reproduced advio_drift.md and advio_drift.json byte for byte (85 MB peak). ETH3D recon not rerun: it loads the laser scans, whose memory has not been bounded on this shared Mac. |
+| S1-2 | Dataset URLs and sha256 recorded | gap | none found in 1 files; missing ADVIO, ETH3D |
+| S1-3 | Metric code tested on hand-computed cases | not run | slow; run with --slow |
+| S1-4 | ADVIO drift report, median and p90 inches after 3, 10, 20 and 30 ft | met | all 5 found [experiments/evals/METHODS.md](https://github.com/SamGu-NRX/house-scanning/blob/b4601ca59f2122a54d91c6981cdc74507248d6b1/experiments/evals/METHODS.md), [experiments/evals/field/FIELD_SHEET.md](https://github.com/SamGu-NRX/house-scanning/blob/b4601ca59f2122a54d91c6981cdc74507248d6b1/experiments/evals/field/FIELD_SHEET.md), [experiments/evals/results/advio_drift.md](https://github.com/SamGu-NRX/house-scanning/blob/b4601ca59f2122a54d91c6981cdc74507248d6b1/experiments/evals/results/advio_drift.md) |
+| S1-5 | ETH3D errors per model (median and p90 inches for 1-3 m and 3-10 m pairs, scale error %) with a verdict per question | partial | found 1-3 m row, 3-10 m row, scale error; missing verdict [experiments/evals/METHODS.md](https://github.com/SamGu-NRX/house-scanning/blob/b4601ca59f2122a54d91c6981cdc74507248d6b1/experiments/evals/METHODS.md), [experiments/evals/results/eth3d_recon.md](https://github.com/SamGu-NRX/house-scanning/blob/b4601ca59f2122a54d91c6981cdc74507248d6b1/experiments/evals/results/eth3d_recon.md), [experiments/evals/results/eth3d_visibility_sensitivity.md](https://github.com/SamGu-NRX/house-scanning/blob/b4601ca59f2122a54d91c6981cdc74507248d6b1/experiments/evals/results/eth3d_visibility_sensitivity.md) |
+
+## S2 Server and solver
+
+`origin/t3/server` at [`6c7bea12`](https://github.com/SamGu-NRX/house-scanning/tree/6c7bea122371711284d5672410b6e1afde22b7bb). 6 met, 2 stale.
+
+| Metric | Text | Status | Evidence |
+| --- | --- | --- | --- |
+| S2-1 | pytest green, the property tests under CI's fixed Hypothesis examples | met | exit 0: from starlette.testclient import TestClient as TestClient # noqa / -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html / 388 passed, 1 xfailed, 1 warning in 121.47s (0:02:01) |
+| S2-2 | Every public golden test 01-14 of docs/research/t3-lane-c-review.md present | met | all 14 found [server/tests/test_golden_public.py](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_golden_public.py) |
+| S2-3 | Property tests: missing coverage never passes, equality is unsure, left/right mirror, larger clearance never turns pass into fail | met | all 4 found [server/tests/test_coderabbit_review.py](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_coderabbit_review.py), [server/tests/test_properties.py](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_properties.py), [server/tests/test_final_review.py](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_final_review.py) |
+| S2-4 | Schemas published | met | present at 6c7bea12 [server/schemas/scene.schema.json](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/schemas/scene.schema.json), [server/schemas/result.schema.json](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/schemas/result.schema.json) |
+| S2-5 | Schemas validated in tests | met | all 2 found [server/tests/test_existing_battery.py](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_existing_battery.py), [server/tests/test_golden_public.py](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_golden_public.py), [server/tests/test_schemas.py](https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_schemas.py) |
+| S2-6 | A real-derived scene returns a result in under 1 s | stale | report for sha 3baa3387, ref at 6c7bea12: ~/house-scanning-data/reports/e2e/20260927-094443-3baa3387/report.json |
+| S2-7 | ruff clean | met | exit 0: Installed 33 packages in 40ms / All checks passed! / 37 files already formatted |
+| S2-8 | Black-box (verification e2e at this SHA): results valid under C2, consistent with the decision and C5 margins, coverage to each check's required radius, missing evidence complete, ordering properties, hostile inputs refused in time | stale | report for sha 3baa3387, ref at 6c7bea12: ~/house-scanning-data/reports/e2e/20260927-094443-3baa3387/report.json |
+
+## S3 iOS capture app
+
+`origin/t3/ios-mvf` at [`c820cf15`](https://github.com/SamGu-NRX/house-scanning/tree/c820cf150a73cb97527428d580c330f631253f9a). 3 met, 3 partial, 1 gap, 1 stale, 2 no evidence yet.
+
+| Metric | Text | Status | Evidence |
+| --- | --- | --- | --- |
+| S3-1 | CI build green | no evidence yet | no open PR for t3/ios-mvf |
+| S3-2 | Built as Swift 6 with strict concurrency | met | all 2 found [ios/project.yml](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/project.yml) |
+| S3-3 | Zero build warnings | met | build.warnings = length 0 (want 0) at sha c820cf15: ~/house-scanning-data/reports/sim/20260927-093459-t3-ios-mvf-c820cf1-c820cf15-replay/report.json |
+| S3-4 | Unit tests for coverage, auto-capture and guidance | partial | found coverage, guidance; missing auto-capture [ios/HouseScanKit/Tests/HouseScanKitTests/AutoCaptureTests.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanKit/Tests/HouseScanKitTests/AutoCaptureTests.swift), [ios/HouseScanKit/Tests/HouseScanKitTests/GroundPatchTests.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanKit/Tests/HouseScanKitTests/GroundPatchTests.swift), [ios/HouseScanKit/Tests/HouseScanKitTests/JSONSchemaValidatorTests.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanKit/Tests/HouseScanKitTests/JSONSchemaValidatorTests.swift) |
+| S3-5 | UI tests run the whole flow from a real replay with no manual input, including one gap-instruction-recapture loop | partial | all 3 found (the tests exist; passing is S3-1) [ios/HouseScanUITests/FullFlowUITests.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanUITests/FullFlowUITests.swift), [ios/HouseScanUITests/SourceRestartUITests.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanUITests/SourceRestartUITests.swift), [ios/HouseScanUITests/ScreenStatesUITests.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanUITests/ScreenStatesUITests.swift) |
+| S3-6 | Accessibility audit on every screen | partial | 3 matches in 2 files (an audit exists; which screens it covers needs a read) [ios/HouseScanUITests/AccessibilityAudit.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanUITests/AccessibilityAudit.swift), [ios/HouseScanUITests/ScreenStatesUITests.swift](https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/HouseScanUITests/ScreenStatesUITests.swift) |
+| S3-6b | Independent accessibility audit (verification a11yaudit: Apple's audit on each screen the run reached, at least nine, plus symbol-name button labels) finds no issues | stale | report for sha a39d0a50, ref at c820cf15: ~/house-scanning-data/reports/a11y/20260926-071248-t3-ios-mvf-a39d0a50-head-sample/report.json |
+| S3-7 | Screenshots of each state in the PR | no evidence yet | no open PR for t3/ios-mvf |
+| S3-8 | Exported scene.json validates against C1 | met | app_export_scene_valid = true (want True) at app_sha c820cf15: ~/house-scanning-data/reports/e2e/20260927-094443-3baa3387/report.json |
+| S3-9 | Design self-review against frontend-design, emil-design-eng, holistic-ux, apple-design | gap | not yet reviewed |
+
+## S4 Verification
+
+`origin/t3/ios-mvf` at [`c820cf15`](https://github.com/SamGu-NRX/house-scanning/tree/c820cf150a73cb97527428d580c330f631253f9a), `origin/t3/verification` at [`b9f1601c`](https://github.com/SamGu-NRX/house-scanning/tree/b9f1601c0d0ea161eb346791c36157b231cfa309). 3 met, 1 partial.
+
+| Metric | Text | Status | Evidence |
+| --- | --- | --- | --- |
+| S4-1 | End to end from a real replay to a placement result passes | partial | app_export_passed = true (want True) at app_sha c820cf15: ~/house-scanning-data/reports/e2e/20260927-094443-3baa3387/report.json (met on the app's synthetic replay against the hosted server (17 states, a server gap request, three spot checks, the result and AR); the real ADVIO replay is blocked by its data (the walk never covers the ground by the meter), last real run 194f2eb) |
+| S4-2 | Screenshot report per app state | met | states = length 17 (want at least 9) at sha c820cf15: ~/house-scanning-data/reports/sim/20260927-093459-t3-ios-mvf-c820cf1-c820cf15-replay/report.json |
+| S4-3 | Product description written from the code and checked against screenshots | met | docs/app-review/product (#61) covers every screen of t3/ios-mvf at a39d0a5, checked in the Simulator there with the real server's result; its bug triage has 16 entries, 3 resolved; docs/app-review/ux/review.md judges every state. |
+| S4-4 | Scoreboard of every PR | met | present at b9f1601c [verification/SCOREBOARD.md](https://github.com/SamGu-NRX/house-scanning/blob/b9f1601c0d0ea161eb346791c36157b231cfa309/verification/SCOREBOARD.md) |
+
+## M Maintenance
+
+`origin/t3/docs-cleanup` at [`3c0dd56c`](https://github.com/SamGu-NRX/house-scanning/tree/3c0dd56c4e74ec872de7a1904c7d9a9a5b860705). 2 gap.
+
+| Metric | Text | Status | Evidence |
+| --- | --- | --- | --- |
+| M-1 | AGENTS.md shorter than on main | gap | exit 1: AGENTS.md 59 lines, main 59 |
+| M-2 | AGENTS.md accurate, every path and command real | gap | not yet reviewed |
diff --git a/verification/e2e/cases/README.md b/verification/e2e/cases/README.md
new file mode 100644
index 000000000..698333c6f
--- /dev/null
+++ b/verification/e2e/cases/README.md
@@ -0,0 +1,266 @@
+# Placement cases for the server's HTTP API
+
+Black-box cases for the placement server. Each `.json` holds a scene and what the result must
+show. The scenes were written from `server/schemas/scene.schema.json` and `result.schema.json` at
+origin/t3/server 69c6c3c and the golden tests in `docs/research/t3-lane-c-review.md` at
+origin/t3/research a7d91f1. After the first run they were corrected against `server/rules.yaml`
+at origin/t3/server 2c9348f, which is public configuration. Nobody read the server's code, tests
+or fixtures to write them, so a passing server has been checked against the spec, not against its
+own tests.
+
+`generate.py` writes the files marked `"generated_by": "e2e/cases/generate.py"`. Edit it, not
+those files, then run from `verification/`. A hand-written case without that marker survives
+regeneration, but it must not use a generated case's id:
+
+```sh
+uv run python e2e/cases/generate.py
+```
+
+## Why the assertions avoid the decision
+
+Whether a would-be pass or reject is decided or sent to a person depends on the policy, which
+changes: `rules.yaml` set `policy.auto_approve: false` when the cases were written, and S2's
+`cd0da8b` made the public policy `demo`, which decides automatically. The cases therefore assert
+on the sweep, on single checks, on `spot` and on `missing_evidence`. They assert a decision only
+where the scene alone forces it, under any policy:
+
+- An unsure spot or an unobserved area rules out `pass`.
+- An unexplored end rules out `reject`. So does a missing `coverage`, since its ends then default
+ to unexplored.
+
+## Expectation keys
+
+The keys follow the lead's case format. The cases use two additions and one convention:
+
+- `checks[].measured_ft` and `checks[].plus_minus_ft` (added): the gap and error the check must
+ report, within 1e-6. The g10 and g13b outcomes mean something only if the server measured the
+ gap the case built. With a different battery depth it measures a different gap, and these keys
+ catch that before the outcome is compared.
+- `missing_evidence_empty: false` means the list must be non-empty.
+- `start_outcomes` (d cases): `{"wall_id", "start_ft", "outcome", "why"}`. The sweep run that
+ contains that battery start must have that outcome.
+- `rules_assumed` maps a check-id substring to the `threshold_ft` the case depends on: `gas` 3 ft
+ (`clearances.gas_ft`), `opening` 3 ft (`clearances.opening_ft`), `headroom` 6.5 ft
+ (`headroom.min_ft`) and `route_length` 20 ft (`route.max_ft`). `route.confident_reach_ft` (15)
+ is not reported as any check's `threshold_ft`, so g09 can't declare it there. A change to it
+ shows up as g09-reach-14 or g09-reach-15 failing.
+
+## The rules these cases depend on
+
+From `rules.yaml` at 2c9348f, rechecked at f2705dd:
+
+- Battery 31/12 × 11/6 ft (W ≈ 2.5833, D ≈ 1.8333), matching the review's table.
+- `meter_working_space` 2.5 ft wide, centred on the meter. A battery overlapping s = (−1.25, 1.25)
+ fails, so starts in (−1.25 − W, 1.25) = (−3.833, 1.25) fail. The starts −3.833 and 1.25 only
+ touch the space: gap 0 against threshold 0, which is unsure.
+- `pool_ft` 10 and `drive_ft` 5, both placeholders. With no pool or driveway in the scene these
+ checks pass only if the ground was seen 10 ft around the footprint. Otherwise they are unsure
+ and ask for a photo.
+- `openings.types` is `[door, garage_door, window]` since f2705dd (it was `[door, window]` at
+ 2c9348f). A garage door therefore has the 3 ft opening clearance, and it also fails the route
+ (`route.crossing.garage_door: fail`). g01 and g08a hold under either version.
+- `route.crossing.window: detour`, `gas_meter: detour` and `elec_box: detour`.
+ `route.corner_allowance_ft` adds 0.5 ft per corner the cable turns. `route.height_ft` 1: the
+ cable runs 1 ft above the ground. The route length has no vertical run from the meter down to
+ that height (at 2c9348f a spot at s = 1.583 reports route 1.583). A detour over an object adds
+ 2 × (top − 1): up to its top and back down. Going under is impossible when the object sits on
+ the ground. A window whose bottom is above 1 ft is crossed with no extra (g12a: route 7.042 for
+ start 7.042).
+- `route.confident_reach_ft` 15: a longer route is unsure. Since f2705dd the `route_length`
+ check reports it as `review_threshold_ft`: pass needs length + error < 15. `route.max_ft` 20 (`route_length`,
+ `at_most`): pass needs length + error < 20, fail needs length − error > 20. The route's error
+ is the meter's and the wall's errors summed (at 2c9348f a wall at ±0.3 with an exact meter gives
+ a route at ±0.3).
+- `headroom.min_ft` 6.5 (`at_least`): pass needs clearance − error > 6.5, fail needs
+ clearance + error < 6.5.
+- `ground.allowed` is concrete, gravel, lawn and mulch. A deck under the footprint fails
+ `ground_surface`.
+- `facing.measured_from: battery_front`, `min_ft` 3: a 9 ft facing gap gives 9 − 11/6 ≈ 7.17.
+
+## Fixture conventions
+
+- Units are feet. Walls are exact (`plus_minus_ft: 0`) unless a case says otherwise. So are the
+ meter and every object: `source: "tape"` with an explicit error, so no default error applies.
+- A straight wall along +x has outward +z (the schema's clockwise rule). s = x − meter x.
+- Cases g10, g11a, g11c and g13 place the wall at z = −11/6. A flush battery's front edge then
+ sits at z = 0.0 exactly in floating point, so a gas strip at z = d measures exactly d. The
+ equality rows need this: 3.3 − 0.3 and 2.7 + 0.3 both evaluate to exactly 3.0, so a server
+ that forgets equality lands on the wrong side and fails the case.
+- "Fully observed" means wall, overhead, facing and ground bands from 15 ft past the left end to
+ 15 ft past the right end, ground seen 15 ft out, both ends `limit`, facing depth 9, no overheads,
+ and concrete ground over the chain. 15 ft clears the 10 ft pool distance measured from a
+ footprint whose front is 11/6 ft out. g05, g08b and g11a–c keep coverage ending at the chain,
+ since they passed with it and g11 depends on exact gaps.
+- A gas "strip" is a long, thin gas footprint parallel to the wall. It spans past both ends of
+ the stretch it covers, so every battery beside it has the same perpendicular gap. It stands in
+ for the review's "every start has the same gas gap".
+
+## Cases
+
+| Case | Golden | Asserts | Arithmetic |
+| --- | --- | --- | --- |
+| g01-unseen-beyond-garage | 01 | `missing_evidence` empty; starts in (−19.9, −8.2) fail with `route` | Wall s = [−20, 10], garage [−5.5, −1], nothing observed left of s = −9. A battery with right edge b < −1 needs a cable across the garage (b ≤ −5.5 means a ≤ −8.083). A battery with b > −1 overlaps the working space. So every start left of the meter fails. Right of the meter a start must clear the working space (a ≥ 1.25) and, since f2705dd, the garage's 3 ft opening clearance (a ≥ −1 + 3 = 2). Pool then needs ground from 2 − 10 = −8 (−8.75 at 2c9348f), both ≥ −9. |
+| g03-negative-s-near-edge | 03 | spot on w1 within [−9, −6]; starts in (−8.4, 6.9) fail; no photo request | Pads are straight 3 ft segments at s = [−9, −6] and [7, 10]. Between them sit 7 segments of 13/7 ≈ 1.857 ft, each shorter than the battery, with corners at s = −4.143, −2.286, −0.429, 1.429, 3.286, 5.143. Headings run 32°, 24°, …, −24°, −32°, so every corner is an outside corner. Left route = 6 + 3 corners × 0.5 = 7.5; right = 7 + 3 × 0.5 = 8.5; left wins. Measuring to the far edge gives 103/12 + 1.5 ≈ 10.08 > 8.5 and would pick the right pad. Pad starts are (−9, −8.583] and [7, 7.417]; every other start crosses a corner. |
+| g05-inside-corner-gas | 05 | starts on w1 in (6, 7.4) fail with `gas`; never pass | w1 is z = 0 from x = −7 to 4 (w1 extended 1 ft left of the golden, so the meter at x = −6 sits inside the wall rather than on its end); s = x + 6. w2 is x = 4 going +z; gas point [4, 4] at s = 14. For start s the battery is x ∈ [s − 6, s − 6 + W], z ∈ [0, 11/6]. At s = 6 the nearest corner is (31/12, 11/6): √((17/12)² + (13/6)²) = √965/12 ≈ 2.589 < 3. Fail needs the right edge past 4 − √(9 − (13/6)²) = 4 − √155/6 ≈ 1.925, so s > 5.342. The last start on w1 is 89/12 ≈ 7.417 (2.167). Unrolled gap 14 − 103/12 = 65/12 ≈ 5.417 would wrongly pass. |
+| g06-regulator-footprint | 06 | starts in (3, 9) fail with `gas`; never pass | Gas footprint: body x ∈ [6.5, 7.5], z ∈ [9.5, 10.5], with a spike reaching [7, 23/6]. Battery front at z = 11/6, so the gap to the tip is 23/6 − 11/6 = 2 whenever the battery covers x = 7, i.e. starts in [7 − W, 7] ≈ [4.417, 7]. Outside that the gap is √(dx² + 4) < 3 while dx < √5 ≈ 2.236, so starts in (2.18, 9.24) fail. The body alone gives 10 − 11/6 ≈ 8.17 and would pass. Facing depth is 12 here so the body stands inside it. |
+| g08a-garage-blocks-route | 08 | spot null; starts in (5, 9.4) fail with `route`; no photo request; never pass | Wall s = [−0.5, 12], garage [1, 5]. Starts in [−0.5, 1.25) overlap the working space. From 1.25 on, the route to the near edge covers [0, a] and crosses the garage, which starts at 1. The golden's garage [3, 7] and pad [10, 13] were shifted so the meter side holds no start. |
+| g08b-missing-wall-blocks-route | 08 | spot null; starts on w2 in (2, 9.4) fail with `route`; no photo request; never pass | w1 s = [−0.5, 1] (1.5 ft, shorter than the battery), no wall for s = (1, 2), w2 s = [2, 12]. The schema says no battery backs onto and no cable crosses a no-wall stretch, so every start fails by geometry. |
+| g10-e03-{pass, upper-edge, lower-edge, fail} | 10 | gas check pass / unsure (margin) / unsure (margin) / fail, with measured d and ±0.3; no photo request; the fail row also needs spot null and every start in (−5, 12.4) failing with `gas` | T = 3, e = 0.3. d = 3.31: 3.31 − 0.3 = 3.01 > 3, pass. d = 3.3: 3.3 − 0.3 = 3.0, not > 3; 3.6 not < 3, so unsure. d = 2.7: 2.7 + 0.3 = 3.0, not < 3, so unsure. d = 2.69: 2.99 < 3, fail. |
+| g10-e05-* | 10 | as above with ±0.5 | d = 3.51, 3.5, 2.5, 2.49 → 3.01 > 3; 3.0; 3.0; 2.99 < 3. |
+| g10-e15-* | 10 | as above with ±1.5 | d = 4.51, 4.5, 1.5, 1.49 → 3.01 > 3; 3.0; 3.0; 2.99 < 3. |
+| g10-derived-compound-error | 10 | gas unsure (margin), measured 3.5, ±0.6; never pass | Wall ±0.3 plus gas ±0.3 = ±0.6 (linear, as the review specifies). 3.5 − 0.6 = 2.9 is not > 3, and 3.5 + 0.6 = 4.1 is not < 3. Root-sum-square would give ±0.424 and 3.076 > 3, a wrong pass. Every g10 gas object also carries `conf: 0.6`; treating 1 − conf as an error would shift every row. |
+| g11a-unexplored-corner | 11 (a) | decision manual_review; spot null; `missing_evidence` non-empty; starts in (−3, 5.4) fail with `gas` | Strip at gap 1: 1 + 0 < 3 fails every start. The right end at s = 8 is `unexplored`, so reject is ruled out and the wall past the corner must be requested. The end sits 8 ft out, inside `route.max_ft` 20. |
+| g11b-both-ends-real-all-fail | 11 (b) | decision manual_review or reject; spot null; no photo request; w1 starts in (−3, 5.4) and w2 starts in (8, 11.4) fail with `gas` | w1 s = [−3, 8], outside corner, w2 runs x = 8 toward −z, s = [8, 14], outward +x. Strips sit 1 ft beyond each wall's battery front: z = 11/6 + 1 and x = 8 + 11/6 + 1. |
+| g11c-unobserved-ground | 11 (c) | decision manual_review; spot within [2.328, 10]; `missing_evidence` non-empty; starts in (−9.9, 2.2) fail with `gas`; starts in (2.5, 7.3) unsure | Strip x ∈ [−12, −0.5] at gap 1. A start a > −0.5 has gap √((a + 0.5)² + 1), under 3 while a < 2√2 − 0.5 ≈ 2.328. Ground was observed only for s ≤ 1, so starts past 2.328 pass gas on unseen ground: unsure. The last start is 10 − W ≈ 7.417. |
+| g12a-subgrid-start | 12 | spot within [7, 7.1 + W]; starts in (−1, 6.95) and (7.2, 13.4) fail with `opening` | Windows [3, 4] and [10.1 + W, 11.1 + W] ≈ [12.683, 13.683]. Legal start: a − 4 > 3 and (10.1 + W) − (a + W) > 3, so 7 < a < 7.1. Both ends give a gap of exactly 3, which is unsure, not pass. The 2 in grid from 0 samples 7 and 43/6 ≈ 7.167, both outside. The wall starts at −1, so any battery left of window 1 would need its right edge < 0 but has it ≥ −1 + W > 0. Past window 2 a start needs a > 16.683, beyond the wall end at 16. The cable detours under window 1. |
+| g12b-exact-fit-last-start | 12 | spot within [6, 6 + W]; no photo request | Five 1.8 ft segments (headings 40° to 8°) for s = [−3, 6], then one segment of exactly W at heading 0° ending the wall. Its only start, 6, is also its last. Measured in floating point, the segment is 4.4e-16 longer than W. Route 6 + 3 corners × 0.5 = 7.5. |
+| g13a-unsure-never-outranks-pass | 13 | spot within [−8, −1.25]; gas check at the spot passes; starts in (−3.8, 1.2) fail with `meter_working_space`; starts in (1.3, 5) unsure; no photo request | Strip x ∈ [0.3, 12] at gap 3.2, ±0.3: every battery overlapping x ≥ 0.3 gets 3.2 − 0.3 = 2.9, unsure. A battery with right edge b < 0.3 gets √((0.3 − b)² + 3.2²), which passes when > 3.3: (0.3 − b)² > 0.65, b < 0.3 − √0.65 ≈ −0.506. The working space also needs b ≤ −1.25, so passing spots have b ≤ −1.25 and route ≥ 1.25. Unsure spots start at 1.25 (route 1.25), and the passing spot must still win. |
+| g13b-unsure-alone | 13 | decision manual_review; spot on w1; gas unsure (margin), measured 3.2, ±0.3; starts in (−0.4, 1.2) fail with `meter_working_space`; starts in (1.3, 5.3) unsure; no photo request | The wall starts at −0.5, so every battery ends at ≥ 2.08 and overlaps the strip: gap 3.2 for all. The spot is fully observed and unsure only by margin, so no photos. |
+| g07-headroom-fail | 07 | headroom fail, measured 5.5, ±0.5; spot null; every start in (−5, 12.4) fails with `headroom`; no photo request | Overhead over the whole wall at 6.5 − 1 = 5.5 ± 0.5: 5.5 + 0.5 = 6.0 < 6.5. |
+| g07-headroom-pass | 07 | headroom pass, measured 7.2, ±0.5; starts in (−5, −3.9) and (1.3, 12.4) pass; no photo request | 6.5 + 0.7 = 7.2: 7.2 − 0.5 = 6.7 > 6.5. The gap between the two intervals is the meter working space. |
+| g07-headroom-margin | 07 | headroom unsure (margin), measured 6.8, ±0.5; same intervals unsure; no photo request | 6.5 + 0.3 = 6.8: 6.8 − 0.5 = 6.3 is not > 6.5 and 7.3 is not < 6.5. |
+| g07-headroom-unobserved | 07 | headroom unsure (unobserved); same intervals unsure; `missing_evidence` non-empty | No overheads, and the overhead band is left out of coverage. |
+| g07-headroom-partial-span | 07 | starts in (5.5, 8.4) fail with `headroom`; starts in (1.3, 5.3) and (8.6, 12.4) pass | Overhead 5.5 ± 0.5 over s = [8, 8.5] only. A battery [a, a + W] overlaps it when 8 − W < a < 8.5, i.e. 5.417 < a < 8.5, even if it covers only a few inches of the battery. The schema's overheads have no depth out from the wall, so the golden's "front strip only" is tested along the wall instead. |
+| g09-reach-14, -15, -16, -20, -21 | 09 | `route_length` measured L, ±0: pass at 14, unsure at 15, 16 and 20, fail at 21. For pass and unsure the spot is [L, L + W] and the sweep at start L has that outcome. For fail the spot is null and the sweep at L fails with `route_length`. No photo request | The wall runs s = [−1, L + W], all ground before L is deck and [L, L + W] is concrete. Every start before L fails `ground_surface`, starts left of the meter also fail the working space, and L is the last start. So the route is L with no corner. 14 < 15, pass. 15 is not inside 15. 16 is past 15 but below 20. For 20: 20 + 0 is not < 20 and 20 − 0 is not > 20, so unsure. For 21: 21 − 0 > 20, fail. |
+| g09-reach-20p2-pm03 | 09 | `route_length` unsure, measured 20.05, ±0.15; spot at L − 0.3; the start at L fails | Meter at ±0.3, wall exact. The meter's error lengthens the run but can't shorten it while the meter stands on its wall's line (server README at 3baa338), so a start s routes over [s, s + 0.3]. The battery moves with the meter against the ground patches, so the first start that may stand on the concrete is 19.9: route 19.9 to 20.2, unsure. The start at 20.2 routes 20.2 to 20.5, above 20, so it fails. |
+| g09-reach-20p4-pm03 | 09 | spot null; the sweep at L fails with `route_length` | 20.4 − 0.3 = 20.1 > 20. |
+| g09-vertical-run | 09 | `route_length` unsure, measured 16, ±0; `route_path` pass; spot at [11, 11 + W] | Pad at 15 − 4 = 11. An `elec_box` at s = [4, 5], 0 to 3.5 ft tall, sits across the 1 ft cable run, and elec boxes detour. Over: 2 × (3.5 − 1) = 5. Routed 11 + 5 = 16 = 15 + 1, past the confident reach. Measuring along the wall alone (11) would pass. |
+| c5-no-coverage | C5, scene schema | decision manual_review; `missing_evidence` non-empty; starts in (−6, −3.9) and (1.3, 7.4) unsure, starts in (−3.8, 1.2) fail with `meter_working_space` | Clean wall s = [−6, 10] with ground and facing data but no `coverage`. "Absent means nothing is known to be observed" makes every start unsure except where the working space, which needs no observation, fails it. Absent ends default to unexplored, which rules out reject. |
+
+## Drift cases
+
+The cases above set every error explicitly, so they never exercise the error model's growth with
+distance. The `d-` cases leave `plus_minus_ft` off the walls, the meter and the objects, and give
+objects `source: "tap"`. Ground and facing keep explicit errors so only the checks under test
+move.
+
+### The error model, from rules.yaml and server/README.md at f2705dd
+
+- `errors.meter_ft` 0.3, `errors.wall_ft` 0.3, `errors.tap_ft` 0.3.
+- `errors.drift_per_ft` 0.16 is added per foot walked along the walls from the meter to the
+ default error of walls, tap and vlm objects and ground patches. It is not added to tape, the
+ meter itself or an explicit `plus_minus_ft`.
+- A battery position comes from the wall and carries the drift of its edge further from the
+ meter: 0.3 + 0.16x, where x is that edge's walked distance. S2's 70ab0b0 states this ("checks
+ take it at the far edge"), and 26d2870's reach cutoff, which took the near edge instead, is the
+ defect these cases were written to catch.
+- A gap or a route sums the errors of its two ends (linear, as the C5 margin rule and g10's
+ derived row use).
+- A route runs from the meter to the battery's near edge. A gas gap runs from the gas meter to
+ the battery edge nearest it.
+
+### d-reach-drift-right: cable reach, `at_most` 20 with review line 15
+
+Wall s = [−1, 29], meter at 0. For a start a > 0 the route is a (straight wall, no corners). The
+wall's error at the far edge, e_w = 0.3 + 0.16(a + W), moves it either way; the meter's 0.3 only
+lengthens it, since the meter stands on its wall's line (server README, `route_length`, at
+3baa338). So the route runs from a − e_w to a + 0.3 + e_w.
+
+| Line | Condition | Start |
+| --- | --- | --- |
+| pass / unsure | a + 0.6 + 0.16(a + W) < 15 | a < (14.4 − 0.16W) / 1.16 = 12.0575 |
+| unsure / fail | a − 0.3 − 0.16(a + W) > 20 | a > (20.3 + 0.16W) / 0.84 = 24.6587 |
+
+Asserted starts: pass at 5 and 11.8575. Unsure at 12.2575, 18 and 24.4587. Fail at 24.8587 and
+25.5. Each boundary is bracketed 0.2 ft inside on both sides, which is more than one 2 in sweep
+step, so each point falls inside a run rather than between two. A cutoff that takes drift at the
+near edge sits 0.16W / 0.84 = 0.492 ft lower, at 24.1667, and fails the start at 24.4587.
+
+### d-reach-drift-left: the same, left of the meter
+
+Wall s = [−29, 1]. Left of the meter the near edge is the battery's right edge b = a + W and the
+far edge is a. The table above holds for −b, so each asserted start is a = −(right-case start) −
+W: pass at −7.5833 and −14.4408, unsure at −14.8408, −20.5833 and −27.3992, fail at −27.7992 and
+−28.0833. The lines sit at a = −12.0575 − W = −14.6408 and a = −25.0159 − W = −27.5992.
+
+### d-gas-drift: gas clearance, `at_least` 3
+
+Wall s = [−1, 20]. A tapped gas meter on the wall line at s = 4 (span [4, 4], no footprint)
+carries 0.3 + 0.16 × 4 = 0.94. For a start a > 4 the gap is d = a − 4 and its error is 0.94 +
+0.3 + 0.16(a + W) = 1.24 + 0.16(a + W).
+
+| Line | Condition | Start |
+| --- | --- | --- |
+| fail / unsure | (a − 4) + 1.24 + 0.16(a + W) < 3 | a < (5.76 − 0.16W) / 1.16 = 4.6092 |
+| unsure / pass | (a − 4) − 1.24 − 0.16(a + W) > 3 | a > (8.24 + 0.16W) / 0.84 = 10.3016 |
+
+Asserted starts:
+
+- **Fail:** 3, where the battery [3, 5.583] covers the gas meter: gap 0, and 0 + 2.13 < 3. Also
+ 4.4092.
+- **Unsure:** 4.8092, 7.5 and 10.1016.
+- **Pass:** 10.5016 and 11. Both routes also pass: 11 + 0.6 + 2.17 = 13.77 < 15.
+
+All starts sit right of the working space. The route to them passes under the gas meter, which
+sits 2.5 to 4 ft up, above the 1 ft cable run; without heights the server (from 6c7ca23) cannot
+tell the detour and leaves every route past it unsure.
+
+### What rules.yaml and the README leave open
+
+- **Walked distance for an object with a span.** For a tapped object spanning [s0, s1], it could
+ be s0, s1 or the nearest end. d-gas-drift uses a zero-length span to avoid the question.
+- **Whether the meter's error enters the route.** The README says drift is not added to the
+ meter. Until 3baa338 its 0.3 entered the route error both ways (inferred from 2c9348f, where
+ both defaults gave a route at ±0.6); from 3baa338 the README states it lengthens the run only.
+- **How errors combine.** Linear sum is assumed, as elsewhere in this suite. Root-sum-square
+ would move every line.
+- **Whether drift enters checks with explicit inputs.** Facing gaps and the working space involve
+ the wall's drifting error. These cases give facing 20 ft (18.17 from the battery front, far
+ above 3 + any error here) and keep every asserted start well clear of the working space.
+
+## Assumptions a failure should be checked against
+
+- A battery must sit flush on one straight segment (review item 4, golden 04; the result's
+ `spot.segment`). g03 and g12b use short segments to rule out starts, and g08b uses a 1.5 ft wall.
+- Gas objects with a footprint are measured by footprint. In g10, g11a and g11b the strip's span
+ covers the meter, which makes the cable detour; those cases assert only gas and photo requests.
+ g13a keeps the span off the left routes.
+- s counts the length of a no-wall stretch (g08b: w2 starts at s = 2, after a 1 ft gap).
+- g09's last start is end − 2.583333333333 (the width as rules.yaml writes it) in floating point.
+ For L = 16, 20, 20.4, 21 and 11 the wall end is chosen so that start is exactly L. For 14, 15 and
+ 20.2 no such end exists, so the start lands 1.8e-15, 1.8e-15 and 3.6e-15 past L. No outcome
+ changes, but g09-reach-15 tests "past the confident reach", not the equality at exactly 15.
+- The vertical detour is an inference. Neither rules.yaml nor the result schema gives the
+ formula; "go over or under it at route height" does not say how far. If g09-vertical-run fails
+ on `measured_ft` alone, check `route.detours[].extra_ft` against 5 before calling it a server
+ error.
+
+## Changes after the first run
+
+First run: origin/t3/server 66d7ec8 through the solver shim, 10 of 27 passing
+(`~/house-scanning-data/reports/e2e/20260926-032427-66d7ec89/report.json`). These were case
+errors, corrected once `rules.yaml` was published:
+
+- **Meter working space.** c5, g13a and g13b expected unsure over starts in (−3.833, 1.25), which
+ `meter_working_space` fails. Their sweep assertions now expect that fail and assert unsure only
+ outside it. The g13a spot bound moved from −0.506 to −1.25.
+- **Ground coverage for pool and driveway.** Every "no photo request" case got
+ `missing_evidence` asking for ground up to 10 ft beyond the spot, for `pool_clearance` (and
+ `drive_clearance`, gas, AC and opening in g03 and g12b, where coverage stopped at the chain's
+ ends). "Fully observed" now reaches 15 ft past each end and 15 ft out. The straight-wall helper
+ applies this too, so g06, g08a, g10 and g12a gained coverage as well; their assertions are
+ unchanged.
+- **g01 layout.** The old spot right of the meter needed ground 10 ft to its left, which reached
+ into the unseen stretch, so the photo request was correct. The garage moved to [−5.5, −1] and the
+ unseen stretch to s < −9: no start between garage and meter survives the working space, and the
+ spot's 10 ft pool area is observed.
+- **Garage doors are not openings.** `openings.types` is `[door, window]`. g08a still holds through
+ the working space and the route. Its `rules_assumed` no longer lists `opening`.
+
+## Known server disagreement at 66d7ec8
+
+- **g03, corner-crossing starts.** The sweep is one run, `{"start_ft": [-9.0, 7.416667],
+ "outcome": "unsure"}`, and `stats.candidates` is 14. Starts in (−8.583, 7) cross a corner. They
+ are not evaluated, yet the run covering them claims they are unsure. Golden 04 says they fail
+ backing. The chosen spot itself is right: `[-8.583, -6.0]`, route 7.5.
+
+## Goldens not encoded
+
+- 02: its only assertion is the policy flag itself. Checking `policy.auto_approve` is false and no
+ result is `pass` across all runs covers it.
+- 04: the golden gives w1 outward [0, −1] for a baseline running +x, which contradicts the
+ schema's clockwise rule. Its pad also relies on a ground check whose passing surfaces are not
+ published. The flush rule it tests is exercised by g03 and g12b.
+- 11 (d): its expectation is a pass, which `auto_approve: false` could not produce when the cases were written. Its photo-request
+ half duplicates g01.
+- 14: needs a pass. The result schema already requires every field it lists.
diff --git a/verification/e2e/cases/c5-no-coverage.json b/verification/e2e/cases/c5-no-coverage.json
new file mode 100644
index 000000000..29e9225b6
--- /dev/null
+++ b/verification/e2e/cases/c5-no-coverage.json
@@ -0,0 +1,104 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "c5-no-coverage",
+ "source": "scene.schema.json coverage and end descriptions at origin/t3/server 69c6c3c; C5",
+ "description": "A clean straight wall with ground and facing measurements but no coverage field. Nothing is known to be observed, so no start may pass, the result must ask for views, and it cannot reject because both ends default to unexplored.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -6,
+ 0.0
+ ],
+ [
+ 10,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -6,
+ 0.0
+ ],
+ [
+ 10,
+ 0.0
+ ],
+ [
+ 10,
+ 10.0
+ ],
+ [
+ -6,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -6.0,
+ 10.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ]
+ },
+ "rules_assumed": {},
+ "expect": {
+ "decision_in": [
+ "manual_review"
+ ],
+ "missing_evidence_empty": false,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -6,
+ -3.9
+ ],
+ "outcome": "unsure"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -3.8,
+ 1.2
+ ],
+ "outcome": "fail",
+ "failing_match": "meter_working_space"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 1.3,
+ 7.4
+ ],
+ "outcome": "unsure"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/d-gas-drift.json b/verification/e2e/cases/d-gas-drift.json
new file mode 100644
index 000000000..e94419104
--- /dev/null
+++ b/verification/e2e/cases/d-gas-drift.json
@@ -0,0 +1,171 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "d-gas-drift",
+ "source": "rules.yaml errors.tap_ft, wall_ft, drift_per_ft and clearances.gas_ft at origin/t3/server f2705dd; C5 margin rule",
+ "description": "A tapped gas meter at s = 4 on a wall with default errors. For a start a right of it the gap is (a - 4) +/- (1.24 + 0.16(a + W)): fail below a = 4.609, pass above a = 10.302, unsure between.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1"
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 20,
+ 0.0
+ ]
+ ]
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ 4.0,
+ 4.0
+ ],
+ "bottom_ft": 2.5,
+ "top_ft": 4.0,
+ "source": "tap"
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 20,
+ 0
+ ],
+ [
+ 20,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1,
+ 20
+ ],
+ "depth_ft": 20.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 35.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 35.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 35.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 35.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0,
+ "route_length": 20.0
+ },
+ "expect": {
+ "start_outcomes": [
+ {
+ "wall_id": "w1",
+ "start_ft": 3.0,
+ "outcome": "fail",
+ "why": "battery [3, 5.58] covers the gas meter: gap 0 + 2.13 < 3"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 4.4092,
+ "outcome": "fail",
+ "why": "just inside (a - 4) + 1.24 + 0.16(a + W) < 3"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 4.8092,
+ "outcome": "unsure",
+ "why": "just past the fail line"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 7.5,
+ "outcome": "unsure",
+ "why": "gap 3.5 +/- 2.85"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 10.1016,
+ "outcome": "unsure",
+ "why": "just inside (a - 4) - 1.24 - 0.16(a + W) <= 3"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 10.5016,
+ "outcome": "pass",
+ "why": "just past (a - 4) - 1.24 - 0.16(a + W) > 3"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 11.0,
+ "outcome": "pass",
+ "why": "gap 7 - 3.41 = 3.59 > 3; route 11 + 2.77 < 15"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/d-reach-drift-left.json b/verification/e2e/cases/d-reach-drift-left.json
new file mode 100644
index 000000000..1f015c85b
--- /dev/null
+++ b/verification/e2e/cases/d-reach-drift-left.json
@@ -0,0 +1,158 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "d-reach-drift-left",
+ "source": "rules.yaml errors.drift_per_ft and route.* at origin/t3/server f2705dd; route range at 3baa338; C5 margin rule",
+ "description": "Mirror of d-reach-drift-right. Left of the meter the route runs to the battery's right edge b = a + W, so the boundaries sit at a = -12.058 - W and a = -24.659 - W.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1"
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -29,
+ 0.0
+ ],
+ [
+ 1,
+ 0.0
+ ]
+ ]
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -29,
+ 0
+ ],
+ [
+ 1,
+ 0
+ ],
+ [
+ 1,
+ 10
+ ],
+ [
+ -29,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -29,
+ 1
+ ],
+ "depth_ft": 20.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -44.0,
+ 16.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -44.0,
+ 16.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -44.0,
+ 16.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -44.0,
+ 16.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "start_outcomes": [
+ {
+ "wall_id": "w1",
+ "start_ft": -7.5833,
+ "outcome": "pass",
+ "why": "route 5 + 1.81 = 6.81 < 15 (b = a + W)"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": -14.4408,
+ "outcome": "pass",
+ "why": "just inside a + 0.6 + 0.16(a + W) < 15 (b = a + W)"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": -14.8408,
+ "outcome": "unsure",
+ "why": "just past the 15 ft review line (b = a + W)"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": -20.5833,
+ "outcome": "unsure",
+ "why": "18 + 3.89 > 15; 18 - 3.59 < 20 (b = a + W)"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": -27.0421,
+ "outcome": "unsure",
+ "why": "just inside a - 0.3 - 0.16(a + W) <= 20 (b = a + W)"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": -27.4421,
+ "outcome": "fail",
+ "why": "just past a - 0.3 - 0.16(a + W) > 20 (b = a + W)"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": -28.0833,
+ "outcome": "fail",
+ "why": "25.5 - 4.79 = 20.71 > 20 (b = a + W)"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/d-reach-drift-right.json b/verification/e2e/cases/d-reach-drift-right.json
new file mode 100644
index 000000000..826109a2e
--- /dev/null
+++ b/verification/e2e/cases/d-reach-drift-right.json
@@ -0,0 +1,158 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "d-reach-drift-right",
+ "source": "rules.yaml errors.drift_per_ft and route.* at origin/t3/server f2705dd; route range at 3baa338; C5 margin rule",
+ "description": "Straight wall with default (drifting) errors. The route to a start a right of the meter is a - (0.3 + 0.16(a + W)) to a + 0.6 + 0.16(a + W): pass below a = 12.058, fail above a = 24.659, unsure between.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1"
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 29,
+ 0.0
+ ]
+ ]
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 29,
+ 0
+ ],
+ [
+ 29,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1,
+ 29
+ ],
+ "depth_ft": 20.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 44.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 44.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 44.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 44.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "start_outcomes": [
+ {
+ "wall_id": "w1",
+ "start_ft": 5.0,
+ "outcome": "pass",
+ "why": "route 5 + 1.81 = 6.81 < 15"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 11.8575,
+ "outcome": "pass",
+ "why": "just inside a + 0.6 + 0.16(a + W) < 15"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 12.2575,
+ "outcome": "unsure",
+ "why": "just past the 15 ft review line"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 18.0,
+ "outcome": "unsure",
+ "why": "18 + 3.89 > 15; 18 - 3.59 < 20"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 24.4587,
+ "outcome": "unsure",
+ "why": "just inside a - 0.3 - 0.16(a + W) <= 20"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 24.8587,
+ "outcome": "fail",
+ "why": "just past a - 0.3 - 0.16(a + W) > 20"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": 25.5,
+ "outcome": "fail",
+ "why": "25.5 - 4.79 = 20.71 > 20"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g01-unseen-beyond-garage.json b/verification/e2e/cases/g01-unseen-beyond-garage.json
new file mode 100644
index 000000000..d382da71b
--- /dev/null
+++ b/verification/e2e/cases/g01-unseen-beyond-garage.json
@@ -0,0 +1,142 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g01-unseen-beyond-garage",
+ "source": "t3-lane-c-review.md golden 01 at origin/t3/research a7d91f1",
+ "description": "The wall beyond a garage door left of the meter was never observed. No spot there can be reached by a cable, so the result asks for no photos, and every start beyond the garage fails its route.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -20,
+ 0.0
+ ],
+ [
+ 10,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "garage_door",
+ "wall_id": "w1",
+ "span_ft": [
+ -5.5,
+ -1
+ ],
+ "bottom_ft": 0.0,
+ "top_ft": 7.0,
+ "attrs": {
+ "operable": true
+ },
+ "source": "tape",
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -20,
+ 0.0
+ ],
+ [
+ 10,
+ 0.0
+ ],
+ [
+ 10,
+ 10.0
+ ],
+ [
+ -20,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -20.0,
+ 10.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -9.0,
+ 25.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -9.0,
+ 25.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -9.0,
+ 25.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -9.0,
+ 25.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {},
+ "expect": {
+ "missing_evidence_empty": true,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -19.9,
+ -8.2
+ ],
+ "outcome": "fail",
+ "failing_match": "route"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g03-negative-s-near-edge.json b/verification/e2e/cases/g03-negative-s-near-edge.json
new file mode 100644
index 000000000..c7d60170b
--- /dev/null
+++ b/verification/e2e/cases/g03-negative-s-near-edge.json
@@ -0,0 +1,228 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g03-negative-s-near-edge",
+ "source": "t3-lane-c-review.md golden 03 at origin/t3/research a7d91f1",
+ "description": "Two straight 3 ft pads, s = [-9, -6] and [7, 10], joined by segments 13/7 ft long that no battery fits on. The left pad's route runs to its near edge (6 ft), so it beats the right pad (7 ft); measuring to the far edge (103/12 ft) would pick the right pad.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -8.293569701353903,
+ -3.1154881209977927
+ ],
+ [
+ -5.749425412884625,
+ -1.525730328298178
+ ],
+ [
+ -4.052840991548366,
+ -0.7703622768716918
+ ],
+ [
+ -2.267640699091488,
+ -0.2584643303544074
+ ],
+ [
+ -0.4285714285714288,
+ 0.0
+ ],
+ [
+ 1.4285714285714288,
+ 0.0
+ ],
+ [
+ 3.267640699091489,
+ -0.2584643303544074
+ ],
+ [
+ 5.052840991548367,
+ -0.7703622768716918
+ ],
+ [
+ 6.7494254128846265,
+ -1.5257303282981778
+ ],
+ [
+ 9.293569701353903,
+ -3.1154881209977923
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -8.293569701353903,
+ -3.1154881209977927
+ ],
+ [
+ -5.749425412884625,
+ -1.525730328298178
+ ],
+ [
+ -4.052840991548366,
+ -0.7703622768716918
+ ],
+ [
+ -2.267640699091488,
+ -0.2584643303544074
+ ],
+ [
+ -0.4285714285714288,
+ 0.0
+ ],
+ [
+ 1.4285714285714288,
+ 0.0
+ ],
+ [
+ 3.267640699091489,
+ -0.2584643303544074
+ ],
+ [
+ 5.052840991548367,
+ -0.7703622768716918
+ ],
+ [
+ 6.7494254128846265,
+ -1.5257303282981778
+ ],
+ [
+ 9.293569701353903,
+ -3.1154881209977923
+ ],
+ [
+ 15.652600872152364,
+ 7.06108903287932
+ ],
+ [
+ 12.383084166315315,
+ 9.069640786008945
+ ],
+ [
+ 9.15708271145639,
+ 10.505949172559209
+ ],
+ [
+ 5.762580988904602,
+ 11.479306878451261
+ ],
+ [
+ 2.2656491135009325,
+ 11.970768603117891
+ ],
+ [
+ -1.2656491135009331,
+ 11.970768603117891
+ ],
+ [
+ -4.762580988904602,
+ 11.479306878451261
+ ],
+ [
+ -8.15708271145639,
+ 10.505949172559209
+ ],
+ [
+ -11.383084166315314,
+ 9.069640786008947
+ ],
+ [
+ -14.652600872152362,
+ 7.06108903287932
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -9,
+ 10
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -24.0,
+ 25.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -24.0,
+ 25.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -24.0,
+ 25.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -24.0,
+ 25.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {},
+ "expect": {
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ -9,
+ -6
+ ]
+ },
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -8.4,
+ 6.9
+ ],
+ "outcome": "fail"
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g05-inside-corner-gas.json b/verification/e2e/cases/g05-inside-corner-gas.json
new file mode 100644
index 000000000..78e61e185
--- /dev/null
+++ b/verification/e2e/cases/g05-inside-corner-gas.json
@@ -0,0 +1,170 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g05-inside-corner-gas",
+ "source": "t3-lane-c-review.md golden 05 at origin/t3/research a7d91f1",
+ "description": "A gas meter on the return wall of an inside corner is 65/12 ft away along the unrolled wall but about 2.589 ft away in plan from a battery at s = [6, 6 + 31/12]. Gas clearance is measured in plan, so those starts fail.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ -6.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -7,
+ 0.0
+ ],
+ [
+ 4,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "id": "w2",
+ "baseline": [
+ [
+ 4,
+ 0
+ ],
+ [
+ 4,
+ 12
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w2",
+ "span_ft": [
+ 14,
+ 14
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+ "footprint": [
+ [
+ 4,
+ 4
+ ]
+ ]
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -7,
+ 0
+ ],
+ [
+ 4,
+ 0
+ ],
+ [
+ 4,
+ 12
+ ],
+ [
+ -7,
+ 12
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1,
+ 10
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ },
+ {
+ "wall_id": "w2",
+ "span_ft": [
+ 10,
+ 22
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -1,
+ 22
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -1,
+ 22
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -1,
+ 22
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -1,
+ 22
+ ],
+ "out_ft": 10.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "decision_not": [
+ "pass"
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 6,
+ 7.4
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g06-regulator-footprint.json b/verification/e2e/cases/g06-regulator-footprint.json
new file mode 100644
index 000000000..2d12e8c84
--- /dev/null
+++ b/verification/e2e/cases/g06-regulator-footprint.json
@@ -0,0 +1,168 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g06-regulator-footprint",
+ "source": "t3-lane-c-review.md golden 06 at origin/t3/research a7d91f1",
+ "description": "The gas meter body stands 10 ft from the wall but its regulator reaches [7, 23/6], 2 ft from the front edge of any battery covering x = 7. Clearance is measured to every part of the footprint.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -3,
+ 0.0
+ ],
+ [
+ 16,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ 6.5,
+ 7.5
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+ "footprint": [
+ [
+ 6.5,
+ 10.5
+ ],
+ [
+ 6.5,
+ 9.5
+ ],
+ [
+ 6.9,
+ 9.5
+ ],
+ [
+ 7.0,
+ 3.8333333333333335
+ ],
+ [
+ 7.1,
+ 9.5
+ ],
+ [
+ 7.5,
+ 9.5
+ ],
+ [
+ 7.5,
+ 10.5
+ ]
+ ]
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -3,
+ 0.0
+ ],
+ [
+ 16,
+ 0.0
+ ],
+ [
+ 16,
+ 10.0
+ ],
+ [
+ -3,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -3,
+ 16
+ ],
+ "depth_ft": 12.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -18.0,
+ 31.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -18.0,
+ 31.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -18.0,
+ 31.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -18.0,
+ 31.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 3,
+ 9
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g07-headroom-fail.json b/verification/e2e/cases/g07-headroom-fail.json
new file mode 100644
index 000000000..4cbbcc947
--- /dev/null
+++ b/verification/e2e/cases/g07-headroom-fail.json
@@ -0,0 +1,151 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g07-headroom-fail",
+ "source": "t3-lane-c-review.md golden 07 at origin/t3/research a7d91f1",
+ "description": "An overhead over the whole wall has clear height 5.5 +/- 0.5 ft against headroom 6.5 ft, so every start's headroom check is fail.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ],
+ [
+ 15,
+ 10.0
+ ],
+ [
+ -5,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ },
+ "overheads": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "clearance_ft": 5.5,
+ "plus_minus_ft": 0.5
+ }
+ ]
+ },
+ "rules_assumed": {
+ "headroom": 6.5
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "headroom",
+ "outcome": "fail",
+ "measured_ft": 5.5,
+ "plus_minus_ft": 0.5
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -5,
+ 12.4
+ ],
+ "outcome": "fail",
+ "failing_match": "headroom"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ],
+ "spot": null
+ }
+}
diff --git a/verification/e2e/cases/g07-headroom-margin.json b/verification/e2e/cases/g07-headroom-margin.json
new file mode 100644
index 000000000..385c3d930
--- /dev/null
+++ b/verification/e2e/cases/g07-headroom-margin.json
@@ -0,0 +1,158 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g07-headroom-margin",
+ "source": "t3-lane-c-review.md golden 07 at origin/t3/research a7d91f1",
+ "description": "An overhead over the whole wall has clear height 6.8 +/- 0.5 ft against headroom 6.5 ft, so every start's headroom check is unsure.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ],
+ [
+ 15,
+ 10.0
+ ],
+ [
+ -5,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ },
+ "overheads": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "clearance_ft": 6.8,
+ "plus_minus_ft": 0.5
+ }
+ ]
+ },
+ "rules_assumed": {
+ "headroom": 6.5
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "headroom",
+ "outcome": "unsure",
+ "measured_ft": 6.8,
+ "plus_minus_ft": 0.5,
+ "unsure_cause": "margin"
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -5,
+ -3.9
+ ],
+ "outcome": "unsure"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 1.3,
+ 12.4
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g07-headroom-partial-span.json b/verification/e2e/cases/g07-headroom-partial-span.json
new file mode 100644
index 000000000..7da273f41
--- /dev/null
+++ b/verification/e2e/cases/g07-headroom-partial-span.json
@@ -0,0 +1,155 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g07-headroom-partial-span",
+ "source": "t3-lane-c-review.md golden 07 at origin/t3/research a7d91f1",
+ "description": "A low overhead (5.5 +/- 0.5 ft) covers only s = [8, 8.5]. Every battery whose 31 in stretch overlaps it fails headroom, even when the overhead covers a few inches of it.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ],
+ [
+ 15,
+ 10.0
+ ],
+ [
+ -5,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ },
+ "overheads": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ 8,
+ 8.5
+ ],
+ "clearance_ft": 5.5,
+ "plus_minus_ft": 0.5
+ }
+ ]
+ },
+ "rules_assumed": {
+ "headroom": 6.5
+ },
+ "expect": {
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 1.3,
+ 5.3
+ ],
+ "outcome": "pass"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 5.5,
+ 8.4
+ ],
+ "outcome": "fail",
+ "failing_match": "headroom"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 8.6,
+ 12.4
+ ],
+ "outcome": "pass"
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g07-headroom-pass.json b/verification/e2e/cases/g07-headroom-pass.json
new file mode 100644
index 000000000..5184547de
--- /dev/null
+++ b/verification/e2e/cases/g07-headroom-pass.json
@@ -0,0 +1,154 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g07-headroom-pass",
+ "source": "t3-lane-c-review.md golden 07 at origin/t3/research a7d91f1",
+ "description": "An overhead over the whole wall has clear height 7.2 +/- 0.5 ft against headroom 6.5 ft, so every start's headroom check is pass.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ],
+ [
+ 15,
+ 10.0
+ ],
+ [
+ -5,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ },
+ "overheads": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "clearance_ft": 7.2,
+ "plus_minus_ft": 0.5
+ }
+ ]
+ },
+ "rules_assumed": {
+ "headroom": 6.5
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "headroom",
+ "outcome": "pass",
+ "measured_ft": 7.2,
+ "plus_minus_ft": 0.5
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -5,
+ -3.9
+ ],
+ "outcome": "pass"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 1.3,
+ 12.4
+ ],
+ "outcome": "pass"
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g07-headroom-unobserved.json b/verification/e2e/cases/g07-headroom-unobserved.json
new file mode 100644
index 000000000..4f224cdd9
--- /dev/null
+++ b/verification/e2e/cases/g07-headroom-unobserved.json
@@ -0,0 +1,138 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g07-headroom-unobserved",
+ "source": "t3-lane-c-review.md golden 07 at origin/t3/research a7d91f1",
+ "description": "Nothing overhead was recorded and the overhead band was never observed, so every start's headroom check is unsure for lack of a view, and the result asks for that view.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ 0.0
+ ],
+ [
+ 15,
+ 0.0
+ ],
+ [
+ 15,
+ 10.0
+ ],
+ [
+ -5,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "headroom": 6.5
+ },
+ "expect": {
+ "decision_not": [
+ "pass"
+ ],
+ "checks": [
+ {
+ "match": "headroom",
+ "outcome": "unsure",
+ "unsure_cause": "unobserved"
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -5,
+ -3.9
+ ],
+ "outcome": "unsure"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 1.3,
+ 12.4
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "missing_evidence_empty": false
+ }
+}
diff --git a/verification/e2e/cases/g08a-garage-blocks-route.json b/verification/e2e/cases/g08a-garage-blocks-route.json
new file mode 100644
index 000000000..8d44257c0
--- /dev/null
+++ b/verification/e2e/cases/g08a-garage-blocks-route.json
@@ -0,0 +1,146 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g08a-garage-blocks-route",
+ "source": "t3-lane-c-review.md golden 08 at origin/t3/research a7d91f1",
+ "description": "A garage door spans s = [1, 5] and the wall ends at s = -0.5, so every start either overlaps the door or lies beyond it, where the cable would have to cross the door.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -0.5,
+ 0.0
+ ],
+ [
+ 12,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "garage_door",
+ "wall_id": "w1",
+ "span_ft": [
+ 1,
+ 5
+ ],
+ "bottom_ft": 0.0,
+ "top_ft": 7.0,
+ "attrs": {
+ "operable": true
+ },
+ "source": "tape",
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -0.5,
+ 0.0
+ ],
+ [
+ 12,
+ 0.0
+ ],
+ [
+ 12,
+ 10.0
+ ],
+ [
+ -0.5,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -0.5,
+ 12.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -15.5,
+ 27.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -15.5,
+ 27.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -15.5,
+ 27.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -15.5,
+ 27.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {},
+ "expect": {
+ "decision_not": [
+ "pass"
+ ],
+ "spot": null,
+ "missing_evidence_empty": true,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 5,
+ 9.4
+ ],
+ "outcome": "fail",
+ "failing_match": "route"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g08b-missing-wall-blocks-route.json b/verification/e2e/cases/g08b-missing-wall-blocks-route.json
new file mode 100644
index 000000000..6b20e34a5
--- /dev/null
+++ b/verification/e2e/cases/g08b-missing-wall-blocks-route.json
@@ -0,0 +1,153 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g08b-missing-wall-blocks-route",
+ "source": "t3-lane-c-review.md golden 08 at origin/t3/research a7d91f1",
+ "description": "The meter's wall w1 is 1.5 ft long, too short for a battery; after a 1 ft stretch with no wall, w2 runs from s = 2 to 12. No cable can cross the gap, so every start on w2 fails its route and no spot exists.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -0.5,
+ 0.0
+ ],
+ [
+ 1,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "id": "w2",
+ "baseline": [
+ [
+ 2,
+ 0.0
+ ],
+ [
+ 12,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -0.5,
+ 0
+ ],
+ [
+ 12,
+ 0
+ ],
+ [
+ 12,
+ 10
+ ],
+ [
+ -0.5,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -0.5,
+ 1
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ },
+ {
+ "wall_id": "w2",
+ "span_ft": [
+ 2,
+ 12
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -0.5,
+ 12
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -0.5,
+ 12
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -0.5,
+ 12
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -0.5,
+ 12
+ ],
+ "out_ft": 10.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {},
+ "expect": {
+ "decision_not": [
+ "pass"
+ ],
+ "spot": null,
+ "missing_evidence_empty": true,
+ "sweep_runs": [
+ {
+ "wall_id": "w2",
+ "start_ft": [
+ 2,
+ 9.4
+ ],
+ "outcome": "fail",
+ "failing_match": "route"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-reach-14.json b/verification/e2e/cases/g09-reach-14.json
new file mode 100644
index 000000000..c38cb8b27
--- /dev/null
+++ b/verification/e2e/cases/g09-reach-14.json
@@ -0,0 +1,164 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-reach-14",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "Route 14 ft, 1 ft inside the 15 ft confident reach: the route passes.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 16.583333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 14.0,
+ 0
+ ],
+ [
+ 14.0,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 14.0,
+ 0
+ ],
+ [
+ 16.583333333333,
+ 0
+ ],
+ [
+ 16.583333333333,
+ 10
+ ],
+ [
+ 14.0,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 16.583333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 31.583333333333
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 31.583333333333
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 31.583333333333
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 31.583333333333
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 14.0,
+ 16.583333333333332
+ ]
+ },
+ "checks": [
+ {
+ "match": "route_length",
+ "outcome": "pass",
+ "measured_ft": 14.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 13.999,
+ 14.001
+ ],
+ "outcome": "pass"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-reach-15.json b/verification/e2e/cases/g09-reach-15.json
new file mode 100644
index 000000000..2a3beb2ff
--- /dev/null
+++ b/verification/e2e/cases/g09-reach-15.json
@@ -0,0 +1,167 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-reach-15",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "Route exactly at the 15 ft confident reach: not inside it, so unsure.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 17.583333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 15.0,
+ 0
+ ],
+ [
+ 15.0,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 15.0,
+ 0
+ ],
+ [
+ 17.583333333333,
+ 0
+ ],
+ [
+ 17.583333333333,
+ 10
+ ],
+ [
+ 15.0,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 17.583333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 32.583333333333
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 32.583333333333
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 32.583333333333
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 32.583333333333
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 15.0,
+ 17.583333333333332
+ ]
+ },
+ "checks": [
+ {
+ "match": "route_length",
+ "outcome": "unsure",
+ "measured_ft": 15.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 14.999,
+ 15.001
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-reach-16.json b/verification/e2e/cases/g09-reach-16.json
new file mode 100644
index 000000000..3d44710e6
--- /dev/null
+++ b/verification/e2e/cases/g09-reach-16.json
@@ -0,0 +1,167 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-reach-16",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "Route 16 ft, between the 15 ft confident reach and the 20 ft maximum: unsure.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 18.583333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 16.0,
+ 0
+ ],
+ [
+ 16.0,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 16.0,
+ 0
+ ],
+ [
+ 18.583333333333,
+ 0
+ ],
+ [
+ 18.583333333333,
+ 10
+ ],
+ [
+ 16.0,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 18.583333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 33.583333333333
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 33.583333333333
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 33.583333333333
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 33.583333333333
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 16.0,
+ 18.583333333333332
+ ]
+ },
+ "checks": [
+ {
+ "match": "route_length",
+ "outcome": "unsure",
+ "measured_ft": 16.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 15.999,
+ 16.001
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-reach-20.json b/verification/e2e/cases/g09-reach-20.json
new file mode 100644
index 000000000..74e3350cf
--- /dev/null
+++ b/verification/e2e/cases/g09-reach-20.json
@@ -0,0 +1,167 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-reach-20",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "Route exactly at the 20 ft maximum with no error: 20 + 0 is not below 20 and 20 - 0 is not above it, so unsure.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 22.583333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 20.0,
+ 0
+ ],
+ [
+ 20.0,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 20.0,
+ 0
+ ],
+ [
+ 22.583333333333,
+ 0
+ ],
+ [
+ 22.583333333333,
+ 10
+ ],
+ [
+ 20.0,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 22.583333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 37.583333333333
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 37.583333333333
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 37.583333333333
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 37.583333333333
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 20.0,
+ 22.583333333333332
+ ]
+ },
+ "checks": [
+ {
+ "match": "route_length",
+ "outcome": "unsure",
+ "measured_ft": 20.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 19.999,
+ 20.001
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-reach-20p2-pm03.json b/verification/e2e/cases/g09-reach-20p2-pm03.json
new file mode 100644
index 000000000..b47ac4c51
--- /dev/null
+++ b/verification/e2e/cases/g09-reach-20p2-pm03.json
@@ -0,0 +1,167 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-reach-20p2-pm03",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "Meter at +/- 0.3 ft. The spot is the first start that may stand on the concrete, L - 0.3 = 19.9, whose route is 19.9 to 20.2: not above 20, so unsure. The start at 20.2 routes 20.2 to 20.5, above 20, so it fails.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.3
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 22.783333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 20.2,
+ 0
+ ],
+ [
+ 20.2,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 20.2,
+ 0
+ ],
+ [
+ 22.783333333333,
+ 0
+ ],
+ [
+ 22.783333333333,
+ 10
+ ],
+ [
+ 20.2,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 22.783333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 37.783333333333005
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 37.783333333333005
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 37.783333333333005
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 37.783333333333005
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 19.9,
+ 22.48333333333333
+ ]
+ },
+ "checks": [
+ {
+ "match": "route_length",
+ "outcome": "unsure",
+ "measured_ft": 20.05,
+ "plus_minus_ft": 0.15
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 20.199,
+ 20.201
+ ],
+ "outcome": "fail"
+ }
+ ],
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-reach-20p4-pm03.json b/verification/e2e/cases/g09-reach-20p4-pm03.json
new file mode 100644
index 000000000..7ff4ecd72
--- /dev/null
+++ b/verification/e2e/cases/g09-reach-20p4-pm03.json
@@ -0,0 +1,154 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-reach-20p4-pm03",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "Route 20.4 +/- 0.3 ft (meter tap error): 20.4 - 0.3 = 20.1 is above 20, so fail.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.3
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 22.983333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 20.4,
+ 0
+ ],
+ [
+ 20.4,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 20.4,
+ 0
+ ],
+ [
+ 22.983333333333,
+ 0
+ ],
+ [
+ 22.983333333333,
+ 10
+ ],
+ [
+ 20.4,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 22.983333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 37.983333333333
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 37.983333333333
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 37.983333333333
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 37.983333333333
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": null,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 20.399,
+ 20.401
+ ],
+ "outcome": "fail",
+ "failing_match": "route_length"
+ }
+ ],
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-reach-21.json b/verification/e2e/cases/g09-reach-21.json
new file mode 100644
index 000000000..2ea2706f7
--- /dev/null
+++ b/verification/e2e/cases/g09-reach-21.json
@@ -0,0 +1,154 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-reach-21",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "Route 21 ft, 1 ft past the 20 ft maximum: the only start on usable ground fails.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 23.583333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 21.0,
+ 0
+ ],
+ [
+ 21.0,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 21.0,
+ 0
+ ],
+ [
+ 23.583333333333,
+ 0
+ ],
+ [
+ 23.583333333333,
+ 10
+ ],
+ [
+ 21.0,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 23.583333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 38.583333333333
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 38.583333333333
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 38.583333333333
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 38.583333333333
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": null,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 20.999,
+ 21.001
+ ],
+ "outcome": "fail",
+ "failing_match": "route_length"
+ }
+ ],
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g09-vertical-run.json b/verification/e2e/cases/g09-vertical-run.json
new file mode 100644
index 000000000..ea0be5f32
--- /dev/null
+++ b/verification/e2e/cases/g09-vertical-run.json
@@ -0,0 +1,184 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g09-vertical-run",
+ "source": "t3-lane-c-review.md golden 09 at origin/t3/research a7d91f1",
+ "description": "The pad starts 11 ft along the wall, but the cable must climb over a 3.5 ft box at the 1 ft run height: 11 + 2 x 2.5 = 16 ft routed, past the 15 ft confident reach.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 13.583333333333,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "elec_box",
+ "wall_id": "w1",
+ "span_ft": [
+ 4,
+ 5
+ ],
+ "bottom_ft": 0.0,
+ "top_ft": 3.5,
+ "source": "tape",
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "ground": [
+ {
+ "type": "deck",
+ "polygon": [
+ [
+ -1,
+ 0
+ ],
+ [
+ 11.0,
+ 0
+ ],
+ [
+ 11.0,
+ 10
+ ],
+ [
+ -1,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ 11.0,
+ 0
+ ],
+ [
+ 13.583333333333,
+ 0
+ ],
+ [
+ 13.583333333333,
+ 10
+ ],
+ [
+ 11.0,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 13.583333333333
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 28.583333333333
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 28.583333333333
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 28.583333333333
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 28.583333333333
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "route_length": 20.0
+ },
+ "expect": {
+ "missing_evidence_empty": true,
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 11.0,
+ 13.583333333333334
+ ]
+ },
+ "checks": [
+ {
+ "match": "route_length",
+ "outcome": "unsure",
+ "measured_ft": 16.0,
+ "plus_minus_ft": 0.0
+ },
+ {
+ "match": "route_path",
+ "outcome": "pass"
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 10.999,
+ 11.001
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-derived-compound-error.json b/verification/e2e/cases/g10-derived-compound-error.json
new file mode 100644
index 000000000..d17aeb721
--- /dev/null
+++ b/verification/e2e/cases/g10-derived-compound-error.json
@@ -0,0 +1,159 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-derived-compound-error",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Gas gap 3.5 ft between a gas meter at +/- 0.3 and a battery on a wall at +/- 0.3. The gap carries +/- 0.6 (linear sum), so 3.5 - 0.6 = 2.9 is not above 3: unsure by margin. A root-sum-square error of 0.424 would wrongly pass it.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.3
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.3,
+ "footprint": [
+ [
+ -10,
+ 3.5
+ ],
+ [
+ 20,
+ 3.5
+ ],
+ [
+ 20,
+ 4.0
+ ],
+ [
+ -10,
+ 4.0
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "decision_not": [
+ "pass"
+ ],
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "unsure_cause": "margin",
+ "measured_ft": 3.5,
+ "plus_minus_ft": 0.6
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g10-e03-fail.json b/verification/e2e/cases/g10-e03-fail.json
new file mode 100644
index 000000000..9fdef2f3d
--- /dev/null
+++ b/verification/e2e/cases/g10-e03-fail.json
@@ -0,0 +1,170 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e03-fail",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 2.69 ft with error 0.3 ft (gas meter +/- 0.3, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.3,
+ "footprint": [
+ [
+ -10,
+ 2.69
+ ],
+ [
+ 20,
+ 2.69
+ ],
+ [
+ 20,
+ 3.19
+ ],
+ [
+ -10,
+ 3.19
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "fail",
+ "measured_ft": 2.69,
+ "plus_minus_ft": 0.3
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ],
+ "spot": null,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -5,
+ 12.4
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e03-lower-edge.json b/verification/e2e/cases/g10-e03-lower-edge.json
new file mode 100644
index 000000000..2596a533b
--- /dev/null
+++ b/verification/e2e/cases/g10-e03-lower-edge.json
@@ -0,0 +1,159 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e03-lower-edge",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 2.7 ft with error 0.3 ft (gas meter +/- 0.3, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.3,
+ "footprint": [
+ [
+ -10,
+ 2.7
+ ],
+ [
+ 20,
+ 2.7
+ ],
+ [
+ 20,
+ 3.2
+ ],
+ [
+ -10,
+ 3.2
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "measured_ft": 2.7,
+ "plus_minus_ft": 0.3,
+ "unsure_cause": "margin"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e03-pass.json b/verification/e2e/cases/g10-e03-pass.json
new file mode 100644
index 000000000..ecc3a6cd2
--- /dev/null
+++ b/verification/e2e/cases/g10-e03-pass.json
@@ -0,0 +1,155 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e03-pass",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 3.31 ft with error 0.3 ft (gas meter +/- 0.3, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.3,
+ "footprint": [
+ [
+ -10,
+ 3.31
+ ],
+ [
+ 20,
+ 3.31
+ ],
+ [
+ 20,
+ 3.81
+ ],
+ [
+ -10,
+ 3.81
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "pass",
+ "measured_ft": 3.31,
+ "plus_minus_ft": 0.3
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g10-e03-upper-edge.json b/verification/e2e/cases/g10-e03-upper-edge.json
new file mode 100644
index 000000000..b2014bcc3
--- /dev/null
+++ b/verification/e2e/cases/g10-e03-upper-edge.json
@@ -0,0 +1,159 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e03-upper-edge",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 3.3 ft with error 0.3 ft (gas meter +/- 0.3, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.3,
+ "footprint": [
+ [
+ -10,
+ 3.3
+ ],
+ [
+ 20,
+ 3.3
+ ],
+ [
+ 20,
+ 3.8
+ ],
+ [
+ -10,
+ 3.8
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "measured_ft": 3.3,
+ "plus_minus_ft": 0.3,
+ "unsure_cause": "margin"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e05-fail.json b/verification/e2e/cases/g10-e05-fail.json
new file mode 100644
index 000000000..b9e3050c5
--- /dev/null
+++ b/verification/e2e/cases/g10-e05-fail.json
@@ -0,0 +1,170 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e05-fail",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 2.49 ft with error 0.5 ft (gas meter +/- 0.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.5,
+ "footprint": [
+ [
+ -10,
+ 2.49
+ ],
+ [
+ 20,
+ 2.49
+ ],
+ [
+ 20,
+ 2.99
+ ],
+ [
+ -10,
+ 2.99
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "fail",
+ "measured_ft": 2.49,
+ "plus_minus_ft": 0.5
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ],
+ "spot": null,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -5,
+ 12.4
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e05-lower-edge.json b/verification/e2e/cases/g10-e05-lower-edge.json
new file mode 100644
index 000000000..95736f521
--- /dev/null
+++ b/verification/e2e/cases/g10-e05-lower-edge.json
@@ -0,0 +1,159 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e05-lower-edge",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 2.5 ft with error 0.5 ft (gas meter +/- 0.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.5,
+ "footprint": [
+ [
+ -10,
+ 2.5
+ ],
+ [
+ 20,
+ 2.5
+ ],
+ [
+ 20,
+ 3.0
+ ],
+ [
+ -10,
+ 3.0
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "measured_ft": 2.5,
+ "plus_minus_ft": 0.5,
+ "unsure_cause": "margin"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e05-pass.json b/verification/e2e/cases/g10-e05-pass.json
new file mode 100644
index 000000000..69f63c6be
--- /dev/null
+++ b/verification/e2e/cases/g10-e05-pass.json
@@ -0,0 +1,155 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e05-pass",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 3.51 ft with error 0.5 ft (gas meter +/- 0.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.5,
+ "footprint": [
+ [
+ -10,
+ 3.51
+ ],
+ [
+ 20,
+ 3.51
+ ],
+ [
+ 20,
+ 4.01
+ ],
+ [
+ -10,
+ 4.01
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "pass",
+ "measured_ft": 3.51,
+ "plus_minus_ft": 0.5
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g10-e05-upper-edge.json b/verification/e2e/cases/g10-e05-upper-edge.json
new file mode 100644
index 000000000..f59d16188
--- /dev/null
+++ b/verification/e2e/cases/g10-e05-upper-edge.json
@@ -0,0 +1,159 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e05-upper-edge",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 3.5 ft with error 0.5 ft (gas meter +/- 0.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.5,
+ "footprint": [
+ [
+ -10,
+ 3.5
+ ],
+ [
+ 20,
+ 3.5
+ ],
+ [
+ 20,
+ 4.0
+ ],
+ [
+ -10,
+ 4.0
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "measured_ft": 3.5,
+ "plus_minus_ft": 0.5,
+ "unsure_cause": "margin"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e15-fail.json b/verification/e2e/cases/g10-e15-fail.json
new file mode 100644
index 000000000..1f3d62be4
--- /dev/null
+++ b/verification/e2e/cases/g10-e15-fail.json
@@ -0,0 +1,170 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e15-fail",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 1.49 ft with error 1.5 ft (gas meter +/- 1.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 1.5,
+ "footprint": [
+ [
+ -10,
+ 1.49
+ ],
+ [
+ 20,
+ 1.49
+ ],
+ [
+ 20,
+ 1.99
+ ],
+ [
+ -10,
+ 1.99
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "fail",
+ "measured_ft": 1.49,
+ "plus_minus_ft": 1.5
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ],
+ "spot": null,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -5,
+ 12.4
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e15-lower-edge.json b/verification/e2e/cases/g10-e15-lower-edge.json
new file mode 100644
index 000000000..a11fae0c5
--- /dev/null
+++ b/verification/e2e/cases/g10-e15-lower-edge.json
@@ -0,0 +1,159 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e15-lower-edge",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 1.5 ft with error 1.5 ft (gas meter +/- 1.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 1.5,
+ "footprint": [
+ [
+ -10,
+ 1.5
+ ],
+ [
+ 20,
+ 1.5
+ ],
+ [
+ 20,
+ 2.0
+ ],
+ [
+ -10,
+ 2.0
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "measured_ft": 1.5,
+ "plus_minus_ft": 1.5,
+ "unsure_cause": "margin"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g10-e15-pass.json b/verification/e2e/cases/g10-e15-pass.json
new file mode 100644
index 000000000..b5ea1c602
--- /dev/null
+++ b/verification/e2e/cases/g10-e15-pass.json
@@ -0,0 +1,155 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e15-pass",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 4.51 ft with error 1.5 ft (gas meter +/- 1.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 1.5,
+ "footprint": [
+ [
+ -10,
+ 4.51
+ ],
+ [
+ 20,
+ 4.51
+ ],
+ [
+ 20,
+ 5.01
+ ],
+ [
+ -10,
+ 5.01
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "pass",
+ "measured_ft": 4.51,
+ "plus_minus_ft": 1.5
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g10-e15-upper-edge.json b/verification/e2e/cases/g10-e15-upper-edge.json
new file mode 100644
index 000000000..5b4204ba3
--- /dev/null
+++ b/verification/e2e/cases/g10-e15-upper-edge.json
@@ -0,0 +1,159 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g10-e15-upper-edge",
+ "source": "t3-lane-c-review.md golden 10 at origin/t3/research a7d91f1",
+ "description": "Every start has gas gap d = 4.5 ft with error 1.5 ft (gas meter +/- 1.5, wall exact), against a 3.0 ft threshold. The gas object's recognition conf of 0.6 must not change the error.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -5,
+ 15
+ ],
+ "source": "tape",
+ "plus_minus_ft": 1.5,
+ "footprint": [
+ [
+ -10,
+ 4.5
+ ],
+ [
+ 20,
+ 4.5
+ ],
+ [
+ 20,
+ 5.0
+ ],
+ [
+ -10,
+ 5.0
+ ]
+ ],
+ "conf": 0.6
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -5,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ -1.8333333333333333
+ ],
+ [
+ 15,
+ 8.166666666666666
+ ],
+ [
+ -5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -5.0,
+ 15.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -20.0,
+ 30.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "measured_ft": 4.5,
+ "plus_minus_ft": 1.5,
+ "unsure_cause": "margin"
+ }
+ ],
+ "missing_evidence_empty": true,
+ "decision_not": [
+ "pass"
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g11a-unexplored-corner.json b/verification/e2e/cases/g11a-unexplored-corner.json
new file mode 100644
index 000000000..d837ed903
--- /dev/null
+++ b/verification/e2e/cases/g11a-unexplored-corner.json
@@ -0,0 +1,161 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g11a-unexplored-corner",
+ "source": "t3-lane-c-review.md golden 11 (a) at origin/t3/research a7d91f1",
+ "description": "Every start on w1 fails gas by 2 ft (gap 1 ft). The walk stopped at the corner at s = 8 without seeing the next wall, so the result cannot reject and must ask for the wall past the corner.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -3,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -3,
+ 8
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+ "footprint": [
+ [
+ -10,
+ 1.0
+ ],
+ [
+ 20,
+ 1.0
+ ],
+ [
+ 20,
+ 1.5
+ ],
+ [
+ -10,
+ 1.5
+ ]
+ ]
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -3,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ 8.166666666666666
+ ],
+ [
+ -3,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -3.0,
+ 8.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "unexplored"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -3,
+ 8
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -3,
+ 8
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -3,
+ 8
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -3,
+ 8
+ ],
+ "out_ft": 10.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "decision_in": [
+ "manual_review"
+ ],
+ "spot": null,
+ "missing_evidence_empty": false,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -3,
+ 5.4
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g11b-both-ends-real-all-fail.json b/verification/e2e/cases/g11b-both-ends-real-all-fail.json
new file mode 100644
index 000000000..60fc39539
--- /dev/null
+++ b/verification/e2e/cases/g11b-both-ends-real-all-fail.json
@@ -0,0 +1,230 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g11b-both-ends-real-all-fail",
+ "source": "t3-lane-c-review.md golden 11 (b) at origin/t3/research a7d91f1",
+ "description": "w1 turns an outside corner at s = 8 onto w2, walked to its real end at s = 14. Gas strips 1 ft in front of each wall fail every start. Nothing is unobserved, so there is no spot and no photo request.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -3,
+ 0.0
+ ],
+ [
+ 8,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ },
+ {
+ "id": "w2",
+ "baseline": [
+ [
+ 8,
+ 0
+ ],
+ [
+ 8,
+ -6
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -3,
+ 8
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+ "footprint": [
+ [
+ -10,
+ 2.833333333333333
+ ],
+ [
+ 12,
+ 2.833333333333333
+ ],
+ [
+ 12,
+ 3.333333333333333
+ ],
+ [
+ -10,
+ 3.333333333333333
+ ]
+ ]
+ },
+ {
+ "type": "gas_meter",
+ "wall_id": "w2",
+ "span_ft": [
+ 8,
+ 14
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+ "footprint": [
+ [
+ 10.833333333333332,
+ -10
+ ],
+ [
+ 11.333333333333332,
+ -10
+ ],
+ [
+ 11.333333333333332,
+ 3
+ ],
+ [
+ 10.833333333333332,
+ 3
+ ]
+ ]
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -3,
+ 0
+ ],
+ [
+ 8,
+ 0
+ ],
+ [
+ 8,
+ -6
+ ],
+ [
+ 18,
+ -6
+ ],
+ [
+ 18,
+ 10
+ ],
+ [
+ -3,
+ 10
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -3,
+ 8
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ },
+ {
+ "wall_id": "w2",
+ "span_ft": [
+ 8,
+ 14
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -3,
+ 14
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -3,
+ 14
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -3,
+ 14
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -3,
+ 14
+ ],
+ "out_ft": 10.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "decision_in": [
+ "manual_review",
+ "reject"
+ ],
+ "spot": null,
+ "missing_evidence_empty": true,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -3,
+ 5.4
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ },
+ {
+ "wall_id": "w2",
+ "start_ft": [
+ 8,
+ 11.4
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g11c-unobserved-ground.json b/verification/e2e/cases/g11c-unobserved-ground.json
new file mode 100644
index 000000000..6ddd3c15f
--- /dev/null
+++ b/verification/e2e/cases/g11c-unobserved-ground.json
@@ -0,0 +1,175 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g11c-unobserved-ground",
+ "source": "t3-lane-c-review.md golden 11 (c) at origin/t3/research a7d91f1",
+ "description": "Starts below s = 2*sqrt(2) - 0.5 fail gas. Starts beyond it pass gas but stand on ground nobody saw (observed only up to s = 1), so they are unsure, never pass, and the result asks for that ground.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -10,
+ -1.8333333333333333
+ ],
+ [
+ 10,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ -10,
+ -0.5
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+ "footprint": [
+ [
+ -12,
+ 1.0
+ ],
+ [
+ -0.5,
+ 1.0
+ ],
+ [
+ -0.5,
+ 1.5
+ ],
+ [
+ -12,
+ 1.5
+ ]
+ ]
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -10,
+ -1.8333333333333333
+ ],
+ [
+ 1,
+ -1.8333333333333333
+ ],
+ [
+ 1,
+ 8.166666666666666
+ ],
+ [
+ -10,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -10.0,
+ 10.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -10,
+ 10
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -10,
+ 10
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -10,
+ 10
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -10,
+ 1
+ ],
+ "out_ft": 10.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "decision_in": [
+ "manual_review"
+ ],
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 2.3284271247461903,
+ 10
+ ]
+ },
+ "missing_evidence_empty": false,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -9.9,
+ 2.2
+ ],
+ "outcome": "fail",
+ "failing_match": "gas"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 2.5,
+ 7.3
+ ],
+ "outcome": "unsure"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g12a-subgrid-start.json b/verification/e2e/cases/g12a-subgrid-start.json
new file mode 100644
index 000000000..c05e3a148
--- /dev/null
+++ b/verification/e2e/cases/g12a-subgrid-start.json
@@ -0,0 +1,174 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g12a-subgrid-start",
+ "source": "t3-lane-c-review.md golden 12 at origin/t3/research a7d91f1",
+ "description": "Windows at s = [3, 4] and [10.1 + 31/12, 11.1 + 31/12] with 3 ft opening clearance leave legal starts only in (7, 7.1). A 2 in grid from s = 0 samples 7 and 43/6 and misses them.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 16,
+ 0.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "window",
+ "wall_id": "w1",
+ "span_ft": [
+ 3,
+ 4
+ ],
+ "bottom_ft": 4.0,
+ "top_ft": 7.0,
+ "attrs": {
+ "operable": true
+ },
+ "source": "tape",
+ "plus_minus_ft": 0.0
+ },
+ {
+ "type": "window",
+ "wall_id": "w1",
+ "span_ft": [
+ 12.683333333333334,
+ 13.683333333333334
+ ],
+ "bottom_ft": 4.0,
+ "top_ft": 7.0,
+ "attrs": {
+ "operable": true
+ },
+ "source": "tape",
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -1,
+ 0.0
+ ],
+ [
+ 16,
+ 0.0
+ ],
+ [
+ 16,
+ 10.0
+ ],
+ [
+ -1,
+ 10.0
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -1.0,
+ 16.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -16.0,
+ 31.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -16.0,
+ 31.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -16.0,
+ 31.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -16.0,
+ 31.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "opening": 3.0
+ },
+ "expect": {
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 7,
+ 9.683333333333334
+ ]
+ },
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -1,
+ 6.95
+ ],
+ "outcome": "fail",
+ "failing_match": "opening"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 7.2,
+ 13.4
+ ],
+ "outcome": "fail",
+ "failing_match": "opening"
+ }
+ ]
+ }
+}
diff --git a/verification/e2e/cases/g12b-exact-fit-last-start.json b/verification/e2e/cases/g12b-exact-fit-last-start.json
new file mode 100644
index 000000000..6ad22a994
--- /dev/null
+++ b/verification/e2e/cases/g12b-exact-fit-last-start.json
@@ -0,0 +1,182 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g12b-exact-fit-last-start",
+ "source": "t3-lane-c-review.md golden 12 at origin/t3/research a7d91f1",
+ "description": "Five 1.8 ft segments, then one straight segment exactly 31/12 ft long at s = [6, 6 + 31/12] where the wall ends. Its single start, s = 6, is also the last start and must be enumerated.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ 0.0
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -2.3965377130018712,
+ -1.7929208145156164
+ ],
+ [
+ -1.0176577153877109,
+ -0.6359031170798457
+ ],
+ [
+ 0.5088288576938558,
+ 0.3179515585399231
+ ],
+ [
+ 2.1532106814505374,
+ 1.0500775160763633
+ ],
+ [
+ 3.8834817341395116,
+ 1.546224756546962
+ ],
+ [
+ 5.665964257874338,
+ 1.7967363382750796
+ ],
+ [
+ 8.249297591207672,
+ 1.7967363382750796
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -2.3965377130018712,
+ -1.7929208145156164
+ ],
+ [
+ -1.0176577153877109,
+ -0.6359031170798457
+ ],
+ [
+ 0.5088288576938558,
+ 0.3179515585399231
+ ],
+ [
+ 2.1532106814505374,
+ 1.0500775160763633
+ ],
+ [
+ 3.8834817341395116,
+ 1.546224756546962
+ ],
+ [
+ 5.665964257874338,
+ 1.7967363382750796
+ ],
+ [
+ 8.249297591207672,
+ 1.7967363382750796
+ ],
+ [
+ 8.249297591207672,
+ 13.79673633827508
+ ],
+ [
+ 4.8288865729448345,
+ 13.767504941392971
+ ],
+ [
+ 1.3885414443264001,
+ 13.28399596535263
+ ],
+ [
+ -1.9510310384574874,
+ 12.326388965507265
+ ],
+ [
+ -5.124829895736833,
+ 10.913322672847046
+ ],
+ [
+ -8.071080742897388,
+ 9.072300815419522
+ ],
+ [
+ -10.109989029240344,
+ 7.39961250291212
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -3,
+ 8.583333333333334
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -18.0,
+ 23.583333333333336
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -18.0,
+ 23.583333333333336
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -18.0,
+ 23.583333333333336
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -18.0,
+ 23.583333333333336
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {},
+ "expect": {
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ 6,
+ 8.583333333333334
+ ]
+ },
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g13a-unsure-never-outranks-pass.json b/verification/e2e/cases/g13a-unsure-never-outranks-pass.json
new file mode 100644
index 000000000..90a65f0a7
--- /dev/null
+++ b/verification/e2e/cases/g13a-unsure-never-outranks-pass.json
@@ -0,0 +1,178 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g13a-unsure-never-outranks-pass",
+ "source": "t3-lane-c-review.md golden 13 at origin/t3/research a7d91f1",
+ "description": "Starts right of the meter have gas gap 3.2 +/- 0.3 (unsure) and the shortest routes. Batteries whose right edge is left of s = 0.3 - sqrt(0.65) clear gas and pass. The chosen spot must be a passing one on the left.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -8,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ 0.3,
+ 8
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.3,
+ "footprint": [
+ [
+ 0.3,
+ 3.2
+ ],
+ [
+ 12,
+ 3.2
+ ],
+ [
+ 12,
+ 3.7
+ ],
+ [
+ 0.3,
+ 3.7
+ ]
+ ]
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -8,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ 8.166666666666666
+ ],
+ [
+ -8,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -8.0,
+ 8.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -23.0,
+ 23.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -23.0,
+ 23.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -23.0,
+ 23.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -23.0,
+ 23.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ -8,
+ -1.25
+ ]
+ },
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "pass"
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -3.8,
+ 1.2
+ ],
+ "outcome": "fail",
+ "failing_match": "meter_working_space"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 1.3,
+ 5
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/g13b-unsure-alone.json b/verification/e2e/cases/g13b-unsure-alone.json
new file mode 100644
index 000000000..a92e83c01
--- /dev/null
+++ b/verification/e2e/cases/g13b-unsure-alone.json
@@ -0,0 +1,184 @@
+{
+ "generated_by": "e2e/cases/generate.py",
+ "id": "g13b-unsure-alone",
+ "source": "t3-lane-c-review.md golden 13 at origin/t3/research a7d91f1",
+ "description": "The wall ends at s = -0.5, so every start has gas gap 3.2 +/- 0.3. The best spot is unsure by margin and fully observed: manual review with no photo request.",
+ "scene": {
+ "schema_version": "1.0",
+ "meter": {
+ "pos": [
+ 0.0,
+ 4.0,
+ -1.8333333333333333
+ ],
+ "wall_id": "w1",
+ "plus_minus_ft": 0.0
+ },
+ "walls": [
+ {
+ "id": "w1",
+ "baseline": [
+ [
+ -0.5,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ -1.8333333333333333
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "objects": [
+ {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [
+ 0.3,
+ 8
+ ],
+ "source": "tape",
+ "plus_minus_ft": 0.3,
+ "footprint": [
+ [
+ 0.3,
+ 3.2
+ ],
+ [
+ 12,
+ 3.2
+ ],
+ [
+ 12,
+ 3.7
+ ],
+ [
+ 0.3,
+ 3.7
+ ]
+ ]
+ }
+ ],
+ "ground": [
+ {
+ "type": "concrete",
+ "polygon": [
+ [
+ -0.5,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ -1.8333333333333333
+ ],
+ [
+ 8,
+ 8.166666666666666
+ ],
+ [
+ -0.5,
+ 8.166666666666666
+ ]
+ ],
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "facing": [
+ {
+ "wall_id": "w1",
+ "span_ft": [
+ -0.5,
+ 8.0
+ ],
+ "depth_ft": 9.0,
+ "plus_minus_ft": 0.0
+ }
+ ],
+ "coverage": {
+ "ends": {
+ "left": {
+ "kind": "limit"
+ },
+ "right": {
+ "kind": "limit"
+ }
+ },
+ "observed": [
+ {
+ "band": "wall",
+ "span_ft": [
+ -15.5,
+ 23.0
+ ]
+ },
+ {
+ "band": "overhead",
+ "span_ft": [
+ -15.5,
+ 23.0
+ ]
+ },
+ {
+ "band": "facing",
+ "span_ft": [
+ -15.5,
+ 23.0
+ ]
+ },
+ {
+ "band": "ground",
+ "span_ft": [
+ -15.5,
+ 23.0
+ ],
+ "out_ft": 15.0
+ }
+ ]
+ }
+ },
+ "rules_assumed": {
+ "gas": 3.0
+ },
+ "expect": {
+ "decision_in": [
+ "manual_review"
+ ],
+ "spot": {
+ "wall_id": "w1",
+ "span_within": [
+ -0.5,
+ 8
+ ]
+ },
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "unsure_cause": "margin",
+ "measured_ft": 3.2,
+ "plus_minus_ft": 0.3
+ }
+ ],
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ -0.4,
+ 1.2
+ ],
+ "outcome": "fail",
+ "failing_match": "meter_working_space"
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [
+ 1.3,
+ 5.3
+ ],
+ "outcome": "unsure"
+ }
+ ],
+ "missing_evidence_empty": true
+ }
+}
diff --git a/verification/e2e/cases/generate.py b/verification/e2e/cases/generate.py
new file mode 100644
index 000000000..e30079fec
--- /dev/null
+++ b/verification/e2e/cases/generate.py
@@ -0,0 +1,1025 @@
+"""Write the black-box placement cases in this folder, one JSON file per case.
+
+Run from verification/: `uv run python e2e/cases/generate.py`. README.md gives the arithmetic
+behind every expected number. The scenes follow server/schemas/scene.schema.json at
+origin/t3/server; they were written from that schema and the lane C review, not from the
+server's code or fixtures.
+"""
+
+import json
+import math
+from pathlib import Path
+
+HERE = Path(__file__).parent
+RESEARCH_SHA = "a7d91f1"
+SERVER_SHA = "69c6c3c"
+
+# Battery footprint from the lane C review's parameter table: 31 in wide, 22 in deep.
+W = 31 / 12
+D = 11 / 6
+METER_Y = 4.0
+# Walls placed at z = -D put a flush battery's front edge at z = 0 exactly in floating point,
+# so a gas strip at z = d is measured as exactly d. The margin cases need that: 3.3 - 0.3 and
+# 2.7 + 0.3 both evaluate to exactly 3.0, while 11/6 + 3.3 - 11/6 does not return 3.3.
+FRONT0 = -D
+# rules.yaml meter_working_space: 2.5 ft wide centred on the meter (NEC 110.26). A battery
+# overlapping s = (-1.25, 1.25) fails, i.e. starts in (-1.25 - W, 1.25) = (-3.833, 1.25).
+WS = "meter_working_space"
+
+
+def golden(n):
+ return f"t3-lane-c-review.md golden {n} at origin/t3/research {RESEARCH_SHA}"
+
+
+def straight(wall_id, x0, x1, z=0.0, pm=0.0):
+ return {"id": wall_id, "baseline": [[x0, z], [x1, z]], "plus_minus_ft": pm}
+
+
+def rect(x0, x1, z0, z1):
+ return [[x0, z0], [x1, z0], [x1, z1], [x0, z1]]
+
+
+def concrete(polygon):
+ return {"type": "concrete", "polygon": polygon, "plus_minus_ft": 0.0}
+
+
+def facing(wall_id, span, depth=9.0):
+ return {"wall_id": wall_id, "span_ft": list(span), "depth_ft": depth, "plus_minus_ft": 0.0}
+
+
+# How far "fully observed" coverage reaches past the chain's ends and out from the wall.
+# rules.yaml (origin/t3/server) has pool_ft 10 and drive_ft 5, and an unseen pool or driveway
+# within that distance of the footprint makes the check UNSURE and asks for a photo. The
+# footprint's front is 11/6 ft out, so the ground must be seen to 10 + 11/6 ft out and 10 ft
+# along the wall past any footprint. 15 ft clears both.
+PAST = 15.0
+
+
+def coverage(
+ span,
+ ground=None,
+ left="limit",
+ right="limit",
+ bands=("wall", "overhead", "facing"),
+ past=(0.0, 0.0),
+ out=10.0,
+):
+ """Observed bands over `span` widened by `past` (left, right); ground over `ground`."""
+ wide = [span[0] - past[0], span[1] + past[1]] if any(past) else list(span)
+ observed = [{"band": b, "span_ft": wide} for b in bands]
+ for g in [wide] if ground is None else ground:
+ observed.append({"band": "ground", "span_ft": list(g), "out_ft": out})
+ return {"ends": {"left": {"kind": left}, "right": {"kind": right}}, "observed": observed}
+
+
+def full_coverage(span):
+ return coverage(span, past=(PAST, PAST), out=PAST)
+
+
+def gas(wall_id, span, footprint, pm=0.0, conf=None):
+ obj = {
+ "type": "gas_meter",
+ "wall_id": wall_id,
+ "span_ft": list(span),
+ "source": "tape",
+ "plus_minus_ft": pm,
+ "footprint": footprint,
+ }
+ if conf is not None:
+ obj["conf"] = conf
+ return obj
+
+
+def opening(kind, wall_id, span, bottom, top):
+ return {
+ "type": kind,
+ "wall_id": wall_id,
+ "span_ft": list(span),
+ "bottom_ft": bottom,
+ "top_ft": top,
+ "attrs": {"operable": True},
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+ }
+
+
+def meter(x, z, wall_id="w1"):
+ return {"pos": [x, METER_Y, z], "wall_id": wall_id, "plus_minus_ft": 0.0}
+
+
+def polyline_wall(wall_id, s_start, segments):
+ """A wall of straight segments given as (length, heading in degrees), heading 0 = +x.
+
+ The first point sits at s = s_start. The baseline is shifted so the point at s = 0 (the
+ meter) is the origin. Headings fall left to right, so every corner is an outside corner.
+ """
+ pts = [(0.0, 0.0)]
+ s_marks = [s_start]
+ for length, heading in segments:
+ a = math.radians(heading)
+ x, z = pts[-1]
+ pts.append((x + length * math.cos(a), z + length * math.sin(a)))
+ s_marks.append(s_marks[-1] + length)
+ for i in range(len(segments)):
+ if s_marks[i] <= 0 <= s_marks[i + 1]:
+ t = (0 - s_marks[i]) / (s_marks[i + 1] - s_marks[i])
+ ox = pts[i][0] + t * (pts[i + 1][0] - pts[i][0])
+ oz = pts[i][1] + t * (pts[i + 1][1] - pts[i][1])
+ break
+ shifted = [[x - ox, z - oz] for x, z in pts]
+ return {"id": wall_id, "baseline": shifted, "plus_minus_ft": 0.0}
+
+
+def offset_ground(baseline, out=12.0):
+ """Ground polygon in front of a convex wall: the baseline, then the baseline pushed out."""
+ front = []
+ n = len(baseline)
+ for i, (x, z) in enumerate(baseline):
+ normals = []
+ for j in (i - 1, i):
+ if 0 <= j < n - 1:
+ dx = baseline[j + 1][0] - baseline[j][0]
+ dz = baseline[j + 1][1] - baseline[j][1]
+ length = math.hypot(dx, dz)
+ normals.append((-dz / length, dx / length))
+ nx = sum(v[0] for v in normals) / len(normals)
+ nz = sum(v[1] for v in normals) / len(normals)
+ norm = math.hypot(nx, nz)
+ front.append([x + out * nx / norm, z + out * nz / norm])
+ return concrete([list(p) for p in baseline] + front[::-1])
+
+
+CASES = []
+
+
+def case(cid, source, description, scene, expect, rules_assumed=None):
+ CASES.append(
+ {
+ "id": cid,
+ "source": source,
+ "description": description,
+ "scene": scene,
+ "rules_assumed": rules_assumed or {},
+ "expect": expect,
+ }
+ )
+
+
+def straight_scene(x0, x1, z=0.0, meter_x=0.0, objects=(), ground=None, cov=None, wall_pm=0.0):
+ scene = {
+ "schema_version": "1.0",
+ "meter": meter(meter_x, z),
+ "walls": [straight("w1", x0, x1, z, wall_pm)],
+ "objects": list(objects),
+ "ground": [concrete(rect(x0, x1, z, z + 10))] if ground is None else ground,
+ "facing": [facing("w1", (x0 - meter_x, x1 - meter_x))],
+ }
+ scene["coverage"] = full_coverage((x0 - meter_x, x1 - meter_x)) if cov is None else cov
+ return scene
+
+
+# g01: an unseen stretch behind a garage door needs no photo, since no cable can reach it.
+# The garage ends at s = -1, inside the meter's working space, so no battery can sit between
+# garage and meter; the unseen stretch starts at s = -9, more than pool_ft (10) left of the
+# first start right of the meter (1.25; 2 once garage doors count as openings, f2705dd).
+case(
+ "g01-unseen-beyond-garage",
+ golden("01"),
+ "The wall beyond a garage door left of the meter was never observed. No spot there can be "
+ "reached by a cable, so the result asks for no photos, and every start beyond the garage "
+ "fails its route.",
+ straight_scene(
+ -20,
+ 10,
+ objects=[opening("garage_door", "w1", (-5.5, -1), 0.0, 7.0)],
+ cov=coverage((-9, 10), past=(0.0, PAST), out=PAST),
+ ),
+ {
+ "missing_evidence_empty": True,
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [-19.9, -8.2],
+ "outcome": "fail",
+ "failing_match": "route",
+ }
+ ],
+ },
+)
+
+# g03: pads at s = [-9, -6] and [7, 10]; the stretch between is short segments.
+seg = 13 / 7
+g03_wall = polyline_wall("w1", -9, [(3, 32)] + [(seg, 24 - 8 * k) for k in range(7)] + [(3, -32)])
+case(
+ "g03-negative-s-near-edge",
+ golden("03"),
+ "Two straight 3 ft pads, s = [-9, -6] and [7, 10], joined by segments 13/7 ft long that no "
+ "battery fits on. The left pad's route runs to its near edge (6 ft), so it beats the right "
+ "pad (7 ft); measuring to the far edge (103/12 ft) would pick the right pad.",
+ {
+ "schema_version": "1.0",
+ "meter": meter(0.0, 0.0),
+ "walls": [g03_wall],
+ "objects": [],
+ "ground": [offset_ground(g03_wall["baseline"])],
+ "facing": [facing("w1", (-9, 10))],
+ "coverage": full_coverage((-9, 10)),
+ },
+ {
+ "spot": {"wall_id": "w1", "span_within": [-9, -6]},
+ "sweep_runs": [{"wall_id": "w1", "start_ft": [-8.4, 6.9], "outcome": "fail"}],
+ "missing_evidence_empty": True,
+ },
+)
+
+# g05: inside corner, gas meter on the return wall at [4, 4].
+g05 = {
+ "schema_version": "1.0",
+ "meter": meter(-6.0, 0.0),
+ "walls": [
+ straight("w1", -7, 4),
+ {"id": "w2", "baseline": [[4, 0], [4, 12]], "plus_minus_ft": 0.0},
+ ],
+ "objects": [gas("w2", (14, 14), [[4, 4]])],
+ "ground": [concrete(rect(-7, 4, 0, 12))],
+ "facing": [facing("w1", (-1, 10)), facing("w2", (10, 22))],
+ "coverage": coverage((-1, 22)),
+}
+case(
+ "g05-inside-corner-gas",
+ golden("05"),
+ "A gas meter on the return wall of an inside corner is 65/12 ft away along the unrolled "
+ "wall but about 2.589 ft away in plan from a battery at s = [6, 6 + 31/12]. Gas clearance "
+ "is measured in plan, so those starts fail.",
+ g05,
+ {
+ "decision_not": ["pass"],
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [6, 7.4], "outcome": "fail", "failing_match": "gas"}
+ ],
+ },
+ {"gas": 3.0},
+)
+
+# g06: gas meter body 10 ft out, regulator reaching [7, 23/6].
+spike = [[6.5, 10.5], [6.5, 9.5], [6.9, 9.5], [7.0, 23 / 6], [7.1, 9.5], [7.5, 9.5], [7.5, 10.5]]
+case(
+ "g06-regulator-footprint",
+ golden("06"),
+ "The gas meter body stands 10 ft from the wall but its regulator reaches [7, 23/6], 2 ft "
+ "from the front edge of any battery covering x = 7. Clearance is measured to every part of "
+ "the footprint.",
+ straight_scene(-3, 16, objects=[gas("w1", (6.5, 7.5), spike)])
+ | {"facing": [facing("w1", (-3, 16), depth=12.0)]},
+ {
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [3, 9], "outcome": "fail", "failing_match": "gas"}
+ ],
+ },
+ {"gas": 3.0},
+)
+
+# g08a: garage door right of the meter, nothing usable before it.
+case(
+ "g08a-garage-blocks-route",
+ golden("08"),
+ "A garage door spans s = [1, 5] and the wall ends at s = -0.5, so every start either "
+ "overlaps the door or lies beyond it, where the cable would have to cross the door.",
+ straight_scene(-0.5, 12, objects=[opening("garage_door", "w1", (1, 5), 0.0, 7.0)]),
+ {
+ "decision_not": ["pass"],
+ "spot": None,
+ "missing_evidence_empty": True,
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [5, 9.4], "outcome": "fail", "failing_match": "route"}
+ ],
+ },
+)
+
+# g08b: a 1 ft stretch with no wall between the meter's short wall and the rest.
+g08b = {
+ "schema_version": "1.0",
+ "meter": meter(0.0, 0.0),
+ "walls": [straight("w1", -0.5, 1), straight("w2", 2, 12)],
+ "objects": [],
+ "ground": [concrete(rect(-0.5, 12, 0, 10))],
+ "facing": [facing("w1", (-0.5, 1)), facing("w2", (2, 12))],
+ "coverage": coverage((-0.5, 12)),
+}
+case(
+ "g08b-missing-wall-blocks-route",
+ golden("08"),
+ "The meter's wall w1 is 1.5 ft long, too short for a battery; after a 1 ft stretch with no "
+ "wall, w2 runs from s = 2 to 12. No cable can cross the gap, so every start on w2 fails "
+ "its route and no spot exists.",
+ g08b,
+ {
+ "decision_not": ["pass"],
+ "spot": None,
+ "missing_evidence_empty": True,
+ "sweep_runs": [
+ {"wall_id": "w2", "start_ft": [2, 9.4], "outcome": "fail", "failing_match": "route"}
+ ],
+ },
+)
+
+# g10: uniform gas gap d with error e. The strip spans past both wall ends, so every start
+# on the wall sees the same perpendicular gap.
+T = 3.0
+
+
+def g10_scene(d, e, wall_pm=0.0):
+ strip = gas("w1", (-5, 15), rect(-10, 20, d, d + 0.5), pm=e, conf=0.6)
+ return straight_scene(-5, 15, z=FRONT0, objects=[strip], wall_pm=wall_pm) | {
+ "ground": [concrete(rect(-5, 15, FRONT0, FRONT0 + 10))]
+ }
+
+
+ROWS = [
+ ("pass", lambda e: round(T + e + 0.01, 2), "pass", None),
+ ("upper-edge", lambda e: round(T + e, 2), "unsure", "margin"),
+ ("lower-edge", lambda e: round(T - e, 2), "unsure", "margin"),
+ ("fail", lambda e: round(T - e - 0.01, 2), "fail", None),
+]
+for e in (0.3, 0.5, 1.5):
+ for name, dfun, outcome, cause in ROWS:
+ d = dfun(e)
+ check = {"match": "gas", "outcome": outcome, "measured_ft": d, "plus_minus_ft": e}
+ if cause:
+ check["unsure_cause"] = cause
+ expect = {"checks": [check], "missing_evidence_empty": True}
+ if outcome != "pass":
+ expect["decision_not"] = ["pass"]
+ if outcome == "fail":
+ expect["spot"] = None
+ expect["sweep_runs"] = [
+ {"wall_id": "w1", "start_ft": [-5, 12.4], "outcome": "fail", "failing_match": "gas"}
+ ]
+ case(
+ f"g10-e{round(e * 10):02d}-{name}",
+ golden("10"),
+ f"Every start has gas gap d = {d} ft with error {e} ft (gas meter +/- {e}, wall "
+ f"exact), against a {T} ft threshold. The gas object's recognition conf of 0.6 "
+ "must not change the error.",
+ g10_scene(d, e),
+ expect,
+ {"gas": T},
+ )
+
+case(
+ "g10-derived-compound-error",
+ golden("10"),
+ "Gas gap 3.5 ft between a gas meter at +/- 0.3 and a battery on a wall at +/- 0.3. The gap "
+ "carries +/- 0.6 (linear sum), so 3.5 - 0.6 = 2.9 is not above 3: unsure by margin. A "
+ "root-sum-square error of 0.424 would wrongly pass it.",
+ g10_scene(3.5, 0.3, wall_pm=0.3),
+ {
+ "decision_not": ["pass"],
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "unsure_cause": "margin",
+ "measured_ft": 3.5,
+ "plus_minus_ft": 0.6,
+ }
+ ],
+ "missing_evidence_empty": True,
+ },
+ {"gas": T},
+)
+
+# g11a: every start fails gas, but the right end is an untapped corner.
+case(
+ "g11a-unexplored-corner",
+ golden("11 (a)"),
+ "Every start on w1 fails gas by 2 ft (gap 1 ft). The walk stopped at the corner at s = 8 "
+ "without seeing the next wall, so the result cannot reject and must ask for the wall past "
+ "the corner.",
+ straight_scene(
+ -3,
+ 8,
+ z=FRONT0,
+ objects=[gas("w1", (-3, 8), rect(-10, 20, 1.0, 1.5))],
+ ground=[concrete(rect(-3, 8, FRONT0, FRONT0 + 10))],
+ cov=coverage((-3, 8), right="unexplored"),
+ ),
+ {
+ "decision_in": ["manual_review"],
+ "spot": None,
+ "missing_evidence_empty": False,
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [-3, 5.4], "outcome": "fail", "failing_match": "gas"}
+ ],
+ },
+ {"gas": 3.0},
+)
+
+# g11b: both walls walked to real ends, every start fails gas.
+strip_out = D + 1.0
+g11b = {
+ "schema_version": "1.0",
+ "meter": meter(0.0, 0.0),
+ "walls": [
+ straight("w1", -3, 8),
+ {"id": "w2", "baseline": [[8, 0], [8, -6]], "plus_minus_ft": 0.0},
+ ],
+ "objects": [
+ gas("w1", (-3, 8), rect(-10, 12, strip_out, strip_out + 0.5)),
+ gas("w2", (8, 14), rect(8 + strip_out, 8 + strip_out + 0.5, -10, 3)),
+ ],
+ "ground": [concrete([[-3, 0], [8, 0], [8, -6], [18, -6], [18, 10], [-3, 10]])],
+ "facing": [facing("w1", (-3, 8)), facing("w2", (8, 14))],
+ "coverage": coverage((-3, 14)),
+}
+case(
+ "g11b-both-ends-real-all-fail",
+ golden("11 (b)"),
+ "w1 turns an outside corner at s = 8 onto w2, walked to its real end at s = 14. Gas strips "
+ "1 ft in front of each wall fail every start. Nothing is unobserved, so there is no spot "
+ "and no photo request.",
+ g11b,
+ {
+ "decision_in": ["manual_review", "reject"],
+ "spot": None,
+ "missing_evidence_empty": True,
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [-3, 5.4], "outcome": "fail", "failing_match": "gas"},
+ {"wall_id": "w2", "start_ft": [8, 11.4], "outcome": "fail", "failing_match": "gas"},
+ ],
+ },
+ {"gas": 3.0},
+)
+
+# g11c: observed starts fail gas; the ground right of s = 1 was never seen (closed gate).
+case(
+ "g11c-unobserved-ground",
+ golden("11 (c)"),
+ "Starts below s = 2*sqrt(2) - 0.5 fail gas. Starts beyond it pass gas but stand on ground "
+ "nobody saw (observed only up to s = 1), so they are unsure, never pass, and the result "
+ "asks for that ground.",
+ straight_scene(
+ -10,
+ 10,
+ z=FRONT0,
+ objects=[gas("w1", (-10, -0.5), rect(-12, -0.5, 1.0, 1.5))],
+ ground=[concrete(rect(-10, 1, FRONT0, FRONT0 + 10))],
+ cov=coverage((-10, 10), ground=[(-10, 1)]),
+ ),
+ {
+ "decision_in": ["manual_review"],
+ "spot": {"wall_id": "w1", "span_within": [2 * math.sqrt(2) - 0.5, 10]},
+ "missing_evidence_empty": False,
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [-9.9, 2.2], "outcome": "fail", "failing_match": "gas"},
+ {"wall_id": "w1", "start_ft": [2.5, 7.3], "outcome": "unsure"},
+ ],
+ },
+ {"gas": 3.0},
+)
+
+# g12a: legal starts only in the open interval (7, 7.1), narrower than a 2 in grid step.
+win2 = 10.1 + W
+case(
+ "g12a-subgrid-start",
+ golden("12"),
+ "Windows at s = [3, 4] and [10.1 + 31/12, 11.1 + 31/12] with 3 ft opening clearance leave "
+ "legal starts only in (7, 7.1). A 2 in grid from s = 0 samples 7 and 43/6 and misses them.",
+ straight_scene(
+ -1,
+ 16,
+ objects=[
+ opening("window", "w1", (3, 4), 4.0, 7.0),
+ opening("window", "w1", (win2, win2 + 1), 4.0, 7.0),
+ ],
+ ),
+ {
+ "spot": {"wall_id": "w1", "span_within": [7, 7.1 + W]},
+ "sweep_runs": [
+ {
+ "wall_id": "w1",
+ "start_ft": [-1, 6.95],
+ "outcome": "fail",
+ "failing_match": "opening",
+ },
+ {
+ "wall_id": "w1",
+ "start_ft": [7.2, 13.4],
+ "outcome": "fail",
+ "failing_match": "opening",
+ },
+ ],
+ },
+ {"opening": 3.0},
+)
+
+# g12b: one straight segment exactly one battery wide; its only start is its last start.
+g12b_wall = polyline_wall("w1", -3, [(1.8, 40 - 8 * k) for k in range(5)] + [(W, 0)])
+case(
+ "g12b-exact-fit-last-start",
+ golden("12"),
+ "Five 1.8 ft segments, then one straight segment exactly 31/12 ft long at s = [6, 6 + 31/12] "
+ "where the wall ends. Its single start, s = 6, is also the last start and must be "
+ "enumerated.",
+ {
+ "schema_version": "1.0",
+ "meter": meter(0.0, 0.0),
+ "walls": [g12b_wall],
+ "objects": [],
+ "ground": [offset_ground(g12b_wall["baseline"])],
+ "facing": [facing("w1", (-3, 6 + W))],
+ "coverage": full_coverage((-3, 6 + W)),
+ },
+ {"spot": {"wall_id": "w1", "span_within": [6, 6 + W]}, "missing_evidence_empty": True},
+)
+
+
+# g13: F = right of the meter, gas gap 3.2 +/- 0.3 (unsure); S = left, passes.
+def g13_scene(x0):
+ strip = gas("w1", (0.3, 8), rect(0.3, 12, 3.2, 3.7), pm=0.3)
+ return straight_scene(x0, 8, z=FRONT0, objects=[strip]) | {
+ "ground": [concrete(rect(x0, 8, FRONT0, FRONT0 + 10))]
+ }
+
+
+# Passing spots need their right edge left of both the gas edge 0.3 - sqrt(0.65) ~ -0.506 and
+# the meter working space's left edge, -1.25 (rules.yaml meter_working_space width 2.5).
+s_edge = min(0.3 - math.sqrt(3.3**2 - 3.2**2), -1.25)
+case(
+ "g13a-unsure-never-outranks-pass",
+ golden("13"),
+ "Starts right of the meter have gas gap 3.2 +/- 0.3 (unsure) and the shortest routes. "
+ "Batteries whose right edge is left of s = 0.3 - sqrt(0.65) clear gas and pass. The chosen "
+ "spot must be a passing one on the left.",
+ g13_scene(-8),
+ {
+ "spot": {"wall_id": "w1", "span_within": [-8, s_edge]},
+ "checks": [{"match": "gas", "outcome": "pass"}],
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [-3.8, 1.2], "outcome": "fail", "failing_match": WS},
+ {"wall_id": "w1", "start_ft": [1.3, 5], "outcome": "unsure"},
+ ],
+ "missing_evidence_empty": True,
+ },
+ {"gas": 3.0},
+)
+case(
+ "g13b-unsure-alone",
+ golden("13"),
+ "The wall ends at s = -0.5, so every start has gas gap 3.2 +/- 0.3. The best spot is "
+ "unsure by margin and fully observed: manual review with no photo request.",
+ g13_scene(-0.5),
+ {
+ "decision_in": ["manual_review"],
+ "spot": {"wall_id": "w1", "span_within": [-0.5, 8]},
+ "checks": [
+ {
+ "match": "gas",
+ "outcome": "unsure",
+ "unsure_cause": "margin",
+ "measured_ft": 3.2,
+ "plus_minus_ft": 0.3,
+ }
+ ],
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [-0.4, 1.2], "outcome": "fail", "failing_match": WS},
+ {"wall_id": "w1", "start_ft": [1.3, 5.3], "outcome": "unsure"},
+ ],
+ "missing_evidence_empty": True,
+ },
+ {"gas": 3.0},
+)
+
+# No coverage field: nothing counts as observed and both ends default to unexplored.
+nocov = straight_scene(-6, 10)
+del nocov["coverage"]
+case(
+ "c5-no-coverage",
+ f"scene.schema.json coverage and end descriptions at origin/t3/server {SERVER_SHA}; C5",
+ "A clean straight wall with ground and facing measurements but no coverage field. Nothing is "
+ "known to be observed, so no start may pass, the result must ask for views, and it cannot "
+ "reject because both ends default to unexplored.",
+ nocov,
+ {
+ "decision_in": ["manual_review"],
+ "missing_evidence_empty": False,
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [-6, -3.9], "outcome": "unsure"},
+ {"wall_id": "w1", "start_ft": [-3.8, 1.2], "outcome": "fail", "failing_match": WS},
+ {"wall_id": "w1", "start_ft": [1.3, 7.4], "outcome": "unsure"},
+ ],
+ },
+)
+
+# g07: headroom. rules.yaml headroom.min_ft = 6.5 (at_least). The schema's overheads carry only
+# a stretch of wall (span_ft) and a clear height, not a depth out from the wall, so "covers the
+# whole footprint" is tested along the wall: a partial overhead over part of the battery's width
+# must still limit it.
+HEADROOM = 6.5
+
+
+def overhead(span, clearance, pm=0.5):
+ return {"wall_id": "w1", "span_ft": list(span), "clearance_ft": clearance, "plus_minus_ft": pm}
+
+
+G07_ROWS = [
+ # name, clearance, outcome, unsure cause
+ ("fail", HEADROOM - 1, "fail", None),
+ ("pass", HEADROOM + 0.7, "pass", None),
+ ("margin", HEADROOM + 0.3, "unsure", "margin"),
+]
+for name, clearance, outcome, cause in G07_ROWS:
+ check = {
+ "match": "headroom",
+ "outcome": outcome,
+ "measured_ft": clearance,
+ "plus_minus_ft": 0.5,
+ }
+ if cause:
+ check["unsure_cause"] = cause
+ if outcome == "fail":
+ runs = [
+ {
+ "wall_id": "w1",
+ "start_ft": [-5, 12.4],
+ "outcome": "fail",
+ "failing_match": "headroom",
+ }
+ ]
+ else:
+ runs = [
+ {"wall_id": "w1", "start_ft": [-5, -3.9], "outcome": outcome},
+ {"wall_id": "w1", "start_ft": [1.3, 12.4], "outcome": outcome},
+ ]
+ expect = {"checks": [check], "sweep_runs": runs, "missing_evidence_empty": True}
+ if outcome != "pass":
+ expect["decision_not"] = ["pass"]
+ if outcome == "fail":
+ expect["spot"] = None
+ case(
+ f"g07-headroom-{name}",
+ golden("07"),
+ f"An overhead over the whole wall has clear height {clearance} +/- 0.5 ft against "
+ f"headroom {HEADROOM} ft, so every start's headroom check is {outcome}.",
+ straight_scene(-5, 15) | {"overheads": [overhead((-5, 15), clearance)]},
+ expect,
+ {"headroom": HEADROOM},
+ )
+
+g07u = straight_scene(
+ -5, 15, cov=coverage((-5, 15), bands=("wall", "facing"), past=(PAST, PAST), out=PAST)
+)
+case(
+ "g07-headroom-unobserved",
+ golden("07"),
+ "Nothing overhead was recorded and the overhead band was never observed, so every start's "
+ "headroom check is unsure for lack of a view, and the result asks for that view.",
+ g07u,
+ {
+ "decision_not": ["pass"],
+ "checks": [{"match": "headroom", "outcome": "unsure", "unsure_cause": "unobserved"}],
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [-5, -3.9], "outcome": "unsure"},
+ {"wall_id": "w1", "start_ft": [1.3, 12.4], "outcome": "unsure"},
+ ],
+ "missing_evidence_empty": False,
+ },
+ {"headroom": HEADROOM},
+)
+case(
+ "g07-headroom-partial-span",
+ golden("07"),
+ "A low overhead (5.5 +/- 0.5 ft) covers only s = [8, 8.5]. Every battery whose 31 in "
+ "stretch overlaps it fails headroom, even when the overhead covers a few inches of it.",
+ straight_scene(-5, 15) | {"overheads": [overhead((8, 8.5), HEADROOM - 1)]},
+ {
+ "sweep_runs": [
+ {"wall_id": "w1", "start_ft": [1.3, 5.3], "outcome": "pass"},
+ {
+ "wall_id": "w1",
+ "start_ft": [5.5, 8.4],
+ "outcome": "fail",
+ "failing_match": "headroom",
+ },
+ {"wall_id": "w1", "start_ft": [8.6, 12.4], "outcome": "pass"},
+ ],
+ "missing_evidence_empty": True,
+ },
+ {"headroom": HEADROOM},
+)
+
+# g09: reach uses the routed length. rules.yaml route.confident_reach_ft = 15 (beyond it the
+# route is unsure), route.max_ft = 20 (route_length check, at_most; beyond it the route fails).
+# Every start before L sits on deck, which rules.yaml ground.allowed excludes, so it fails
+# ground_surface; the wall ends at L + W, so L is the last start and the only one on concrete.
+# Starts left of the meter overlap its working space (the wall begins at -1).
+CONFIDENT, MAX_ROUTE = 15.0, 20.0
+# The battery width as rules.yaml writes it. The last start is end - W_RULES in floating point.
+# The wall's end is the nearest float to L + W_RULES for which that start is exactly L. For
+# L = 14, 15 and 20.2 no such float exists (end has a coarser ulp than L), so the end is the
+# smallest float whose last start is at or past L: 14 and 15 land 1.8e-15 past, 20.2 lands 3.6e-15
+# past. None of those flips an outcome except that L = 15 tests "past the confident reach", not
+# the equality at 15 itself.
+W_RULES = 2.583333333333
+
+
+def exact_end(L):
+ base = L + W_RULES
+ near = [base]
+ for direction in (-math.inf, math.inf):
+ x = base
+ for _ in range(4):
+ x = math.nextafter(x, direction)
+ near.append(x)
+ exact = [x for x in near if x - W_RULES == L]
+ end = (
+ min(exact, key=lambda x: abs(x - base))
+ if exact
+ else min(x for x in near if x - W_RULES >= L)
+ )
+ assert (end + 1) - 1 == end
+ return end
+
+
+def reach_scene(L, meter_pm=0.0, objects=()):
+ end = exact_end(L)
+ scene = straight_scene(-1, end, objects=objects)
+ scene["meter"]["plus_minus_ft"] = meter_pm
+ scene["ground"] = [
+ {"type": "deck", "polygon": rect(-1, L, 0, 10), "plus_minus_ft": 0.0},
+ concrete(rect(L, end, 0, 10)),
+ ]
+ return scene
+
+
+def reach_case(
+ cid,
+ L,
+ route_ft,
+ e,
+ outcome,
+ description,
+ objects=(),
+ extra_checks=(),
+ meter_pm=None,
+ spot_start=None,
+ at_L_outcome=None,
+):
+ """`e` is the route check's expected error; the meter's error is `e` unless `meter_pm` says
+ otherwise. The spot starts at L unless `spot_start` says otherwise, and the start at L has
+ the spot's outcome unless `at_L_outcome` says otherwise."""
+ meter_pm = e if meter_pm is None else meter_pm
+ spot_start = L if spot_start is None else spot_start
+ at_L_outcome = outcome if at_L_outcome is None else at_L_outcome
+ at_L = [round(L - 0.001, 3), round(L + 0.001, 3)]
+ check = {
+ "match": "route_length",
+ "outcome": outcome,
+ "measured_ft": route_ft,
+ "plus_minus_ft": e,
+ }
+ expect = {"missing_evidence_empty": True}
+ if outcome == "fail":
+ expect["spot"] = None
+ expect["sweep_runs"] = [
+ {"wall_id": "w1", "start_ft": at_L, "outcome": "fail", "failing_match": "route_length"}
+ ]
+ else:
+ expect["spot"] = {"wall_id": "w1", "span_within": [spot_start, spot_start + W]}
+ expect["checks"] = [check, *extra_checks]
+ expect["sweep_runs"] = [{"wall_id": "w1", "start_ft": at_L, "outcome": at_L_outcome}]
+ if outcome != "pass":
+ expect["decision_not"] = ["pass"]
+ case(
+ cid,
+ golden("09"),
+ description,
+ reach_scene(L, meter_pm, objects),
+ expect,
+ {"route_length": MAX_ROUTE},
+ )
+
+
+reach_case(
+ "g09-reach-14",
+ 14.0,
+ 14.0,
+ 0.0,
+ "pass",
+ "Route 14 ft, 1 ft inside the 15 ft confident reach: the route passes.",
+)
+reach_case(
+ "g09-reach-15",
+ 15.0,
+ 15.0,
+ 0.0,
+ "unsure",
+ "Route exactly at the 15 ft confident reach: not inside it, so unsure.",
+)
+reach_case(
+ "g09-reach-16",
+ 16.0,
+ 16.0,
+ 0.0,
+ "unsure",
+ "Route 16 ft, between the 15 ft confident reach and the 20 ft maximum: unsure.",
+)
+reach_case(
+ "g09-reach-20",
+ 20.0,
+ 20.0,
+ 0.0,
+ "unsure",
+ "Route exactly at the 20 ft maximum with no error: 20 + 0 is not below 20 and 20 - 0 "
+ "is not above it, so unsure.",
+)
+reach_case(
+ "g09-reach-21",
+ 21.0,
+ 21.0,
+ 0.0,
+ "fail",
+ "Route 21 ft, 1 ft past the 20 ft maximum: the only start on usable ground fails.",
+)
+# The meter's error can lengthen the run, since the meter may stand off the wall, but not
+# shorten it while it stands on its wall's line (server README, route_length, at 3baa338): a
+# start s routes over [s, s + 0.3]. The battery moves with the meter against the ground patches,
+# so starts from L - 0.3 may stand on the concrete and are unsure; the nearest is the spot.
+reach_case(
+ "g09-reach-20p2-pm03",
+ 20.2,
+ 20.2 - 0.3 / 2,
+ 0.3 / 2,
+ "unsure",
+ "Meter at +/- 0.3 ft. The spot is the first start that may stand on the concrete, "
+ "L - 0.3 = 19.9, whose route is 19.9 to 20.2: not above 20, so unsure. The start at 20.2 "
+ "routes 20.2 to 20.5, above 20, so it fails.",
+ meter_pm=0.3,
+ spot_start=20.2 - 0.3,
+ at_L_outcome="fail",
+)
+reach_case(
+ "g09-reach-20p4-pm03",
+ 20.4,
+ 20.4,
+ 0.3,
+ "fail",
+ "Route 20.4 +/- 0.3 ft (meter tap error): 20.4 - 0.3 = 20.1 is above 20, so fail.",
+)
+# Vertical run: an electrical box standing on the ground at s = [4, 5], 3.5 ft tall, across the
+# 1 ft cable run (rules.yaml route.height_ft). route.crossing.elec_box is detour; going under is
+# impossible (bottom 0), so the cable climbs 3.5 - 1 = 2.5 ft and comes back down: 5 ft extra.
+ebox = {
+ "type": "elec_box",
+ "wall_id": "w1",
+ "span_ft": [4, 5],
+ "bottom_ft": 0.0,
+ "top_ft": 3.5,
+ "source": "tape",
+ "plus_minus_ft": 0.0,
+}
+reach_case(
+ "g09-vertical-run",
+ CONFIDENT - 4,
+ CONFIDENT + 1,
+ 0.0,
+ "unsure",
+ "The pad starts 11 ft along the wall, but the cable must climb over a 3.5 ft box at "
+ "the 1 ft run height: 11 + 2 x 2.5 = 16 ft routed, past the 15 ft confident reach.",
+ objects=[ebox],
+ extra_checks=[{"match": "route_path", "outcome": "pass"}],
+)
+
+# Drift cases. Walls, meter and objects carry no plus_minus_ft, so rules.yaml's defaults apply:
+# errors.meter_ft 0.3 (no drift), errors.wall_ft 0.3 and errors.tap_ft 0.3, each plus
+# errors.drift_per_ft 0.16 per foot walked along the walls from the meter. Ground and facing keep
+# explicit errors so that only the checks under test move. README.md ("Drift cases") has the
+# derivation; the model assumed is: a measured point's error is its default plus drift times its
+# walked distance, the battery carries the drift of its edge further from the meter (S2 70ab0b0,
+# "checks take it at the far edge"), and a gap or route sums the errors of its two ends.
+TAP, WALL_E, METER_E, DRIFT = 0.3, 0.3, 0.3, 0.16
+PT = 0.2 # sample points sit 0.2 ft inside a boundary: more than one 2 in sweep step
+
+
+def drift_scene(x0, x1, objects=()):
+ return {
+ "schema_version": "1.0",
+ "meter": {"pos": [0.0, METER_Y, 0.0], "wall_id": "w1"},
+ "walls": [{"id": "w1", "baseline": [[x0, 0.0], [x1, 0.0]]}],
+ "objects": list(objects),
+ "ground": [concrete(rect(x0, x1, 0, 10))],
+ "facing": [facing("w1", (x0, x1), depth=20.0)],
+ "coverage": full_coverage((x0, x1)),
+ }
+
+
+def starts(points):
+ return [
+ {"wall_id": "w1", "start_ft": round(s, 4), "outcome": o, "why": why} for s, o, why in points
+ ]
+
+
+# Cable reach, right of the meter. Route = a (near edge). The wall's error at the far edge,
+# e_w = 0.3 + 0.16(a + W), moves it either way; the meter's 0.3 only lengthens it, since the
+# meter stands on its wall's line (server README, route_length, at 3baa338). Pass:
+# a + 0.3 + e_w < 15 (review line) -> a < (14.4 - 0.16W)/1.16. Fail: a - e_w > 20 ->
+# a > (20.3 + 0.16W)/0.84.
+R_PASS = (CONFIDENT - METER_E - WALL_E - DRIFT * W) / (1 + DRIFT)
+R_FAIL = (MAX_ROUTE + WALL_E + DRIFT * W) / (1 - DRIFT)
+reach_points = [
+ (5.0, "pass", "route 5 + 1.81 = 6.81 < 15"),
+ (R_PASS - PT, "pass", "just inside a + 0.6 + 0.16(a + W) < 15"),
+ (R_PASS + PT, "unsure", "just past the 15 ft review line"),
+ (18.0, "unsure", "18 + 3.89 > 15; 18 - 3.59 < 20"),
+ (R_FAIL - PT, "unsure", "just inside a - 0.3 - 0.16(a + W) <= 20"),
+ (R_FAIL + PT, "fail", "just past a - 0.3 - 0.16(a + W) > 20"),
+ (25.5, "fail", "25.5 - 4.79 = 20.71 > 20"),
+]
+case(
+ "d-reach-drift-right",
+ "rules.yaml errors.drift_per_ft and route.* at origin/t3/server f2705dd; route range at "
+ "3baa338; C5 margin rule",
+ "Straight wall with default (drifting) errors. The route to a start a right of the meter is "
+ "a - (0.3 + 0.16(a + W)) to a + 0.6 + 0.16(a + W): pass below a = 12.058, fail above "
+ "a = 24.659, unsure between.",
+ drift_scene(-1, 29),
+ {"start_outcomes": starts(reach_points)},
+ {"route_length": MAX_ROUTE},
+)
+# Mirror: left of the meter the near edge is the battery's right edge b = a + W, route = -b, and
+# the far edge is a.
+case(
+ "d-reach-drift-left",
+ "rules.yaml errors.drift_per_ft and route.* at origin/t3/server f2705dd; route range at "
+ "3baa338; C5 margin rule",
+ "Mirror of d-reach-drift-right. Left of the meter the route runs to the battery's right edge "
+ "b = a + W, so the boundaries sit at a = -12.058 - W and a = -24.659 - W.",
+ drift_scene(-29, 1),
+ {
+ "start_outcomes": starts([(-s - W, o, why + " (b = a + W)") for s, o, why in reach_points]),
+ },
+ {"route_length": MAX_ROUTE},
+)
+
+# Gas clearance. A tapped gas meter on the wall line at s = 4 (error 0.3 + 0.16 x 4 = 0.94). A
+# battery right of it has gap d = a - 4 and error 0.94 + (0.3 + 0.16(a + W)) = 1.24 + 0.16(a + W).
+# Fail: d + e < 3 -> 1.16a < 5.76 - 0.16W. Pass: d - e > 3 -> 0.84a > 8.24 + 0.16W.
+G_AT = 4.0
+G_E = TAP + DRIFT * G_AT
+G_FAIL = (3.0 + G_AT - G_E - WALL_E - DRIFT * W) / (1 + DRIFT)
+G_PASS = (3.0 + G_AT + G_E + WALL_E + DRIFT * W) / (1 - DRIFT)
+# Heights above the 1 ft cable run, so the route passes under it and only the clearance is tested:
+# with no heights the server (6c7ca23) calls the detour unknown and every route past it unsure.
+tap_gas = {
+ "type": "gas_meter",
+ "wall_id": "w1",
+ "span_ft": [G_AT, G_AT],
+ "bottom_ft": 2.5,
+ "top_ft": 4.0,
+ "source": "tap",
+}
+case(
+ "d-gas-drift",
+ "rules.yaml errors.tap_ft, wall_ft, drift_per_ft and clearances.gas_ft at origin/t3/server "
+ "f2705dd; C5 margin rule",
+ "A tapped gas meter at s = 4 on a wall with default errors. For a start a right of it the gap "
+ "is (a - 4) +/- (1.24 + 0.16(a + W)): fail below a = 4.609, pass above a = 10.302, unsure "
+ "between.",
+ drift_scene(-1, 20, objects=[tap_gas]),
+ {
+ "start_outcomes": starts(
+ [
+ (3.0, "fail", "battery [3, 5.58] covers the gas meter: gap 0 + 2.13 < 3"),
+ (G_FAIL - PT, "fail", "just inside (a - 4) + 1.24 + 0.16(a + W) < 3"),
+ (G_FAIL + PT, "unsure", "just past the fail line"),
+ (7.5, "unsure", "gap 3.5 +/- 2.85"),
+ (G_PASS - PT, "unsure", "just inside (a - 4) - 1.24 - 0.16(a + W) <= 3"),
+ (G_PASS + PT, "pass", "just past (a - 4) - 1.24 - 0.16(a + W) > 3"),
+ (11.0, "pass", "gap 7 - 3.41 = 3.59 > 3; route 11 + 2.77 < 15"),
+ ]
+ ),
+ },
+ {"gas": 3.0, "route_length": MAX_ROUTE},
+)
+
+
+# Marks the files this script owns. Regenerating replaces only those, so a regression case
+# written by hand in this folder survives.
+GENERATED_BY = "e2e/cases/generate.py"
+
+
+def main(folder: Path = HERE) -> None:
+ ids = {c["id"] for c in CASES}
+ for old in folder.glob("*.json"):
+ if json.loads(old.read_text()).get("generated_by") == GENERATED_BY:
+ old.unlink()
+ elif old.stem in ids:
+ raise SystemExit(f"{old.name} was written by hand but a generated case has its id")
+ for c in CASES:
+ case = {"generated_by": GENERATED_BY, **c}
+ (folder / f"{c['id']}.json").write_text(json.dumps(case, indent=2) + "\n")
+ print(f"wrote {len(CASES)} cases")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/project.pbxproj b/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/project.pbxproj
new file mode 100644
index 000000000..2b83f88b3
--- /dev/null
+++ b/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/project.pbxproj
@@ -0,0 +1,388 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 427F7989BACF1CEC6DA2D9C5 /* AccessibilityAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C29531C38DAF3788E36F3030 /* AccessibilityAuditTests.swift */; };
+ 7155F226D9F0FACC47C3489D /* AuditHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143718565DE5764127A93948 /* AuditHostApp.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ B1FE4BA30BEA3FD8D3508D07 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 58DE58FA588828120ADE9232 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 3247AD21C2DC26C0631148F6;
+ remoteInfo = AuditHost;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ 143718565DE5764127A93948 /* AuditHostApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditHostApp.swift; sourceTree = ""; };
+ B56910E308F1D85FFF1B2292 /* AccessibilityAudit.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = AccessibilityAudit.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ C29531C38DAF3788E36F3030 /* AccessibilityAuditTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAuditTests.swift; sourceTree = ""; };
+ E21BA944B212ED015C3642FE /* AuditHost.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = AuditHost.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+ 7EC2B27B2CFA88AA1E34F6C2 = {
+ isa = PBXGroup;
+ children = (
+ BB4FF326C88E53CFE7056923 /* AccessibilityAudit */,
+ 9ABA268B579D85C69FB70292 /* AuditHost */,
+ C17A46CCDC0398A153016DE6 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 9ABA268B579D85C69FB70292 /* AuditHost */ = {
+ isa = PBXGroup;
+ children = (
+ 143718565DE5764127A93948 /* AuditHostApp.swift */,
+ );
+ path = AuditHost;
+ sourceTree = "";
+ };
+ BB4FF326C88E53CFE7056923 /* AccessibilityAudit */ = {
+ isa = PBXGroup;
+ children = (
+ C29531C38DAF3788E36F3030 /* AccessibilityAuditTests.swift */,
+ );
+ path = AccessibilityAudit;
+ sourceTree = "";
+ };
+ C17A46CCDC0398A153016DE6 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ B56910E308F1D85FFF1B2292 /* AccessibilityAudit.xctest */,
+ E21BA944B212ED015C3642FE /* AuditHost.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 3247AD21C2DC26C0631148F6 /* AuditHost */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 6505F0C3EE204A6145D7C7E5 /* Build configuration list for PBXNativeTarget "AuditHost" */;
+ buildPhases = (
+ D0D4BE765A5BE20AE2232C7E /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = AuditHost;
+ packageProductDependencies = (
+ );
+ productName = AuditHost;
+ productReference = E21BA944B212ED015C3642FE /* AuditHost.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 8CCD665844B0D8DF2B1EE105 /* AccessibilityAudit */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D0152AB46C653989BEA3FA0D /* Build configuration list for PBXNativeTarget "AccessibilityAudit" */;
+ buildPhases = (
+ B11EDB5C8C3342DCB0A4A327 /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 00EA29F202EF6AECBCD5C1FC /* PBXTargetDependency */,
+ );
+ name = AccessibilityAudit;
+ packageProductDependencies = (
+ );
+ productName = AccessibilityAudit;
+ productReference = B56910E308F1D85FFF1B2292 /* AccessibilityAudit.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 58DE58FA588828120ADE9232 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ 8CCD665844B0D8DF2B1EE105 = {
+ TestTargetID = 3247AD21C2DC26C0631148F6;
+ };
+ };
+ };
+ buildConfigurationList = 2042BAAFD5134746EA8592DF /* Build configuration list for PBXProject "A11yAudit" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = 7EC2B27B2CFA88AA1E34F6C2;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = C17A46CCDC0398A153016DE6 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 8CCD665844B0D8DF2B1EE105 /* AccessibilityAudit */,
+ 3247AD21C2DC26C0631148F6 /* AuditHost */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXSourcesBuildPhase section */
+ B11EDB5C8C3342DCB0A4A327 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 427F7989BACF1CEC6DA2D9C5 /* AccessibilityAuditTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D0D4BE765A5BE20AE2232C7E /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 7155F226D9F0FACC47C3489D /* AuditHostApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 00EA29F202EF6AECBCD5C1FC /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 3247AD21C2DC26C0631148F6 /* AuditHost */;
+ targetProxy = B1FE4BA30BEA3FD8D3508D07 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 3C9E1A85CDCE6151C6199A4E /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGNING_ALLOWED = NO;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+ 708EB10FCF910F1ACB74A421 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.housescanning.accessibilityaudit;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = AuditHost;
+ };
+ name = Debug;
+ };
+ 841E56A50F19DED71F190C76 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.housescanning.accessibilityaudit;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = AuditHost;
+ };
+ name = Release;
+ };
+ BE3EA69AAFB233D09E1F40CA /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.housescanning.audithost;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ D55271EAC8D6256B4B11B08F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.housescanning.audithost;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ EC24429FA6E4DFBA10CCD5C8 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGNING_ALLOWED = NO;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 2042BAAFD5134746EA8592DF /* Build configuration list for PBXProject "A11yAudit" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ EC24429FA6E4DFBA10CCD5C8 /* Debug */,
+ 3C9E1A85CDCE6151C6199A4E /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 6505F0C3EE204A6145D7C7E5 /* Build configuration list for PBXNativeTarget "AuditHost" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BE3EA69AAFB233D09E1F40CA /* Debug */,
+ D55271EAC8D6256B4B11B08F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ D0152AB46C653989BEA3FA0D /* Build configuration list for PBXNativeTarget "AccessibilityAudit" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 708EB10FCF910F1ACB74A421 /* Debug */,
+ 841E56A50F19DED71F190C76 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 58DE58FA588828120ADE9232 /* Project object */;
+}
diff --git a/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000..919434a62
--- /dev/null
+++ b/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/xcshareddata/xcschemes/AccessibilityAudit.xcscheme b/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/xcshareddata/xcschemes/AccessibilityAudit.xcscheme
new file mode 100644
index 000000000..c5ee0e95f
--- /dev/null
+++ b/verification/fixtures/a11y-audit/A11yAudit.xcodeproj/xcshareddata/xcschemes/AccessibilityAudit.xcscheme
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/verification/fixtures/a11y-audit/AccessibilityAudit/AccessibilityAuditTests.swift b/verification/fixtures/a11y-audit/AccessibilityAudit/AccessibilityAuditTests.swift
new file mode 100644
index 000000000..b2639d7e7
--- /dev/null
+++ b/verification/fixtures/a11y-audit/AccessibilityAudit/AccessibilityAuditTests.swift
@@ -0,0 +1,110 @@
+import XCTest
+
+/// Launches the app under test by bundle id with the launch arguments it is given, then runs
+/// `performAccessibilityAudit` once per distinct screen until the app stops changing.
+///
+/// A screen is identified by the labels of its static texts and buttons, so a new instruction
+/// counts as a new screen. Each issue is printed as one `A11Y_ISSUE=` line and each
+/// screen as `A11Y_SCREEN=`, which hsverify.a11yaudit reads from the test output.
+/// Screenshots go to AUDIT_OUT on the host.
+///
+/// Inputs arrive as environment variables (set on the xcodebuild command line with the
+/// TEST_RUNNER_ prefix): AUDIT_BUNDLE, AUDIT_ARGS (arguments separated by U+001F),
+/// AUDIT_SECONDS (overall limit), AUDIT_IDLE (stop after this long without a new screen),
+/// AUDIT_OUT (folder for screenshots).
+final class AccessibilityAuditTests: XCTestCase {
+ @MainActor
+ func testAuditEveryScreen() throws {
+ let env = ProcessInfo.processInfo.environment
+ let bundle = try XCTUnwrap(env["AUDIT_BUNDLE"], "AUDIT_BUNDLE is required")
+ let args = (env["AUDIT_ARGS"] ?? "").split(separator: "\u{1F}").map(String.init)
+ let limit = Double(env["AUDIT_SECONDS"] ?? "") ?? 120
+ let idle = Double(env["AUDIT_IDLE"] ?? "") ?? 15
+ let out = env["AUDIT_OUT"].map { URL(fileURLWithPath: $0) }
+
+ let app = XCUIApplication(bundleIdentifier: bundle)
+ app.launchArguments = args
+ app.launch()
+
+ let start = Date()
+ var lastChange = Date()
+ var seen: [String] = []
+ while Date().timeIntervalSince(start) < limit, Date().timeIntervalSince(lastChange) < idle {
+ guard app.state == .runningForeground else {
+ emit("A11Y_SCREEN", ["index": seen.count + 1, "error": "app not running"])
+ break
+ }
+ guard let labels = screenLabels(app) else {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.5))
+ continue
+ }
+ let key = labels.joined(separator: "\n")
+ if !seen.contains(key) {
+ seen.append(key)
+ lastChange = Date()
+ audit(app, index: seen.count, labels: labels, out: out,
+ at: Date().timeIntervalSince(start))
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.5))
+ }
+ }
+
+ /// Labels from one snapshot of the accessibility tree. Querying elements and then reading
+ /// each label is two round trips, and a button that disappears in between (the screen
+ /// changing) fails the whole test.
+ @MainActor
+ private func screenLabels(_ app: XCUIApplication) -> [String]? {
+ guard let root = try? app.snapshot() else { return nil }
+ var texts: [String] = []
+ var buttons: [String] = []
+ func walk(_ node: any XCUIElementSnapshot) {
+ switch node.elementType {
+ case .staticText: texts.append("text:" + node.label)
+ case .button: buttons.append("button:" + node.label)
+ default: break
+ }
+ node.children.forEach(walk)
+ }
+ walk(root)
+ return texts + buttons
+ }
+
+ @MainActor
+ private func audit(
+ _ app: XCUIApplication, index: Int, labels: [String], out: URL?, at seconds: Double
+ ) {
+ var shot: String? = nil
+ if let out {
+ let name = String(format: "a11y-%02d.png", index)
+ try? XCUIScreen.main.screenshot().pngRepresentation
+ .write(to: out.appendingPathComponent(name))
+ shot = name
+ }
+ var issues = 0
+ do {
+ try app.performAccessibilityAudit(for: .all) { issue in
+ issues += 1
+ self.emit("A11Y_ISSUE", [
+ "screen": index,
+ "type": String(describing: issue.auditType),
+ "description": issue.compactDescription,
+ "detail": issue.detailedDescription,
+ "element": issue.element.map { "\($0.elementType.rawValue) \($0.label)" } ?? "",
+ ])
+ return true // record every issue; never stop at the first
+ }
+ } catch {
+ emit("A11Y_SCREEN", ["index": index, "error": "\(error)"])
+ }
+ emit("A11Y_SCREEN", [
+ "index": index, "seconds": seconds, "labels": labels, "issues": issues,
+ "screenshot": shot ?? "",
+ ])
+ }
+
+ private func emit(_ tag: String, _ object: [String: Any]) {
+ let data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]))
+ ?? Data("{}".utf8)
+ print("\(tag)=\(String(decoding: data, as: UTF8.self))")
+ }
+}
diff --git a/verification/fixtures/a11y-audit/AuditHost/AuditHostApp.swift b/verification/fixtures/a11y-audit/AuditHost/AuditHostApp.swift
new file mode 100644
index 000000000..bed353394
--- /dev/null
+++ b/verification/fixtures/a11y-audit/AuditHost/AuditHostApp.swift
@@ -0,0 +1,8 @@
+import SwiftUI
+
+@main
+struct AuditHostApp: App {
+ var body: some Scene {
+ WindowGroup { Text("Accessibility audit host") }
+ }
+}
diff --git a/verification/fixtures/a11y-audit/project.yml b/verification/fixtures/a11y-audit/project.yml
new file mode 100644
index 000000000..b668951b9
--- /dev/null
+++ b/verification/fixtures/a11y-audit/project.yml
@@ -0,0 +1,40 @@
+# Accessibility audit harness for hsverify.a11yaudit. The UI test launches an app that is already
+# installed on the Simulator, by bundle id, and runs Apple's accessibility audit on every distinct
+# screen it shows. AuditHost exists only because a UI test target needs a host app.
+name: A11yAudit
+options:
+ deploymentTarget:
+ iOS: "26.0"
+settings:
+ base:
+ SWIFT_VERSION: "6.0"
+ CODE_SIGNING_ALLOWED: NO
+targets:
+ AuditHost:
+ type: application
+ platform: iOS
+ sources: [AuditHost]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.housescanning.audithost
+ GENERATE_INFOPLIST_FILE: YES
+ INFOPLIST_KEY_UILaunchScreen_Generation: YES
+ AccessibilityAudit:
+ type: bundle.ui-testing
+ platform: iOS
+ sources: [AccessibilityAudit]
+ dependencies:
+ - target: AuditHost
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.housescanning.accessibilityaudit
+ GENERATE_INFOPLIST_FILE: YES
+ TEST_TARGET_NAME: AuditHost
+schemes:
+ AccessibilityAudit:
+ build:
+ targets:
+ AuditHost: all
+ AccessibilityAudit: [test]
+ test:
+ targets: [AccessibilityAudit]
diff --git a/verification/fixtures/state-probe/StateProbe.xcodeproj/project.pbxproj b/verification/fixtures/state-probe/StateProbe.xcodeproj/project.pbxproj
new file mode 100644
index 000000000..dfd18cc2b
--- /dev/null
+++ b/verification/fixtures/state-probe/StateProbe.xcodeproj/project.pbxproj
@@ -0,0 +1,286 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 6522E1C65A1BACAEC71F045D /* StateProbeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E18E6F4F55A0C4DDD1D9F7B /* StateProbeApp.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 4E18E6F4F55A0C4DDD1D9F7B /* StateProbeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateProbeApp.swift; sourceTree = ""; };
+ C9CD2DF097F422B75CA9BF54 /* StateProbe.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = StateProbe.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+ 25367929A4B1815698729180 /* StateProbe */ = {
+ isa = PBXGroup;
+ children = (
+ 4E18E6F4F55A0C4DDD1D9F7B /* StateProbeApp.swift */,
+ );
+ path = StateProbe;
+ sourceTree = "";
+ };
+ 8BB70B547481E3A64DB58AAE = {
+ isa = PBXGroup;
+ children = (
+ 25367929A4B1815698729180 /* StateProbe */,
+ F150664F13B4D3D8F233DCF1 /* Products */,
+ );
+ sourceTree = "";
+ };
+ F150664F13B4D3D8F233DCF1 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ C9CD2DF097F422B75CA9BF54 /* StateProbe.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ FAEE147748CA52747B658B0E /* StateProbe */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A136F875CF6480DCB5470BE1 /* Build configuration list for PBXNativeTarget "StateProbe" */;
+ buildPhases = (
+ D5123BEFD882A7E9BDB4CBD1 /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = StateProbe;
+ packageProductDependencies = (
+ );
+ productName = StateProbe;
+ productReference = C9CD2DF097F422B75CA9BF54 /* StateProbe.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 1C039B21D02E15301E6003C9 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ };
+ };
+ buildConfigurationList = 9C450EBC261D9A1F5EB7EBF5 /* Build configuration list for PBXProject "StateProbe" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = 8BB70B547481E3A64DB58AAE;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = F150664F13B4D3D8F233DCF1 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ FAEE147748CA52747B658B0E /* StateProbe */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXSourcesBuildPhase section */
+ D5123BEFD882A7E9BDB4CBD1 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 6522E1C65A1BACAEC71F045D /* StateProbeApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 559C3159F38CBBF3A6D4DEC2 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.housescanning.stateprobe;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ A8B6B380BE9E234A02662996 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+ AEE11670B0C02BAA2879B728 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+ BF4C25D52E56316274082339 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.housescanning.stateprobe;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 9C450EBC261D9A1F5EB7EBF5 /* Build configuration list for PBXProject "StateProbe" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AEE11670B0C02BAA2879B728 /* Debug */,
+ A8B6B380BE9E234A02662996 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ A136F875CF6480DCB5470BE1 /* Build configuration list for PBXNativeTarget "StateProbe" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BF4C25D52E56316274082339 /* Debug */,
+ 559C3159F38CBBF3A6D4DEC2 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 1C039B21D02E15301E6003C9 /* Project object */;
+}
diff --git a/verification/fixtures/state-probe/StateProbe.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/verification/fixtures/state-probe/StateProbe.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000..919434a62
--- /dev/null
+++ b/verification/fixtures/state-probe/StateProbe.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/verification/fixtures/state-probe/StateProbe/StateProbeApp.swift b/verification/fixtures/state-probe/StateProbe/StateProbeApp.swift
new file mode 100644
index 000000000..d7fd894ee
--- /dev/null
+++ b/verification/fixtures/state-probe/StateProbe/StateProbeApp.swift
@@ -0,0 +1,56 @@
+import OSLog
+import SwiftUI
+
+/// Walks through a fixed list of states, logging each as `STATE=` the way contract C4
+/// asks the real app to. `quick` lasts 0.3 s so the runner's transient flag is exercised.
+/// Launch arguments are echoed on screen so the runner's argument passing can be checked.
+/// With `-replay `, it also logs whether the app can read `/session.json`
+/// from the host path, which is how the real app receives a replay in the Simulator.
+@main
+struct StateProbeApp: App {
+ var body: some Scene {
+ WindowGroup { ProbeView() }
+ }
+}
+
+private let log = Logger(subsystem: "dev.housescanning.housescan", category: "state")
+private let steps: [(name: String, seconds: Double)] = [
+ ("probe_start", 2), ("quick", 0.3), ("probe_middle", 2), ("probe_done", 2),
+]
+
+private func replayFolder() -> String? {
+ let args = CommandLine.arguments
+ guard let i = args.firstIndex(of: "-replay"), i + 1 < args.count else { return nil }
+ return args[i + 1]
+}
+
+struct ProbeView: View {
+ @State private var current = ""
+
+ var body: some View {
+ VStack(spacing: 12) {
+ Text(current).font(.largeTitle.bold())
+ Text(CommandLine.arguments.dropFirst().joined(separator: " "))
+ .font(.footnote.monospaced())
+ if current == "probe_middle" {
+ // Negative control for the accessibility audit: an icon-only button with no
+ // label and a 12 pt target. The audit must report it.
+ Button {} label: { Image(systemName: "gearshape").font(.system(size: 9)) }
+ .frame(width: 12, height: 12)
+ }
+ }
+ .padding()
+ .task {
+ if let replay = replayFolder() {
+ let url = URL(fileURLWithPath: replay).appendingPathComponent("session.json")
+ let readable = (try? Data(contentsOf: url)).map { !$0.isEmpty } ?? false
+ log.info("STATE=\(readable ? "replay_readable" : "replay_unreadable", privacy: .public)")
+ }
+ for step in steps {
+ current = step.name
+ log.info("STATE=\(step.name, privacy: .public)")
+ try? await Task.sleep(for: .seconds(step.seconds))
+ }
+ }
+ }
+}
diff --git a/verification/fixtures/state-probe/project.yml b/verification/fixtures/state-probe/project.yml
new file mode 100644
index 000000000..6d5ef05ab
--- /dev/null
+++ b/verification/fixtures/state-probe/project.yml
@@ -0,0 +1,21 @@
+# Test fixture for hsverify.simrun, not a product. It logs a fixed STATE sequence (contract C4)
+# so the runner's log following and screenshots can be checked without the real app.
+name: StateProbe
+options:
+ deploymentTarget:
+ iOS: "26.0"
+settings:
+ base:
+ SWIFT_VERSION: "6.0"
+ SWIFT_STRICT_CONCURRENCY: complete
+targets:
+ StateProbe:
+ type: application
+ platform: iOS
+ sources: [StateProbe]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.housescanning.stateprobe
+ GENERATE_INFOPLIST_FILE: YES
+ INFOPLIST_KEY_UILaunchScreen_Generation: YES
+ CODE_SIGNING_ALLOWED: NO
diff --git a/verification/hsverify/__init__.py b/verification/hsverify/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/verification/hsverify/a11yaudit.py b/verification/hsverify/a11yaudit.py
new file mode 100644
index 000000000..f02b604bb
--- /dev/null
+++ b/verification/hsverify/a11yaudit.py
@@ -0,0 +1,280 @@
+"""Run Apple's accessibility audit on every screen an app shows, from a git ref.
+
+ uv run python -m hsverify.a11yaudit --ref origin/t3/ios-mvf \
+ --replay ~/house-scanning-data/replays/ --autopilot
+
+The app is built and installed exactly as `hsverify.simrun` does. Then the UI test bundle in
+`fixtures/a11y-audit` launches it by bundle id with the same launch arguments, and runs
+`performAccessibilityAudit(for: .all)` on each distinct screen: contrast, Dynamic Type, hit
+areas, clipped text, element descriptions and traits. A screen is new when the labels of its
+texts and buttons change.
+
+Apple's audit accepts a button whose label SwiftUI took from an SF Symbol name, which
+VoiceOver then reads aloud ("gearshape"). Those labels are flagged here as well.
+
+Reports go to ~/house-scanning-data/reports/a11y// with a screenshot per audited screen.
+The probe's middle screen carries a deliberately undersized, unlabelled button, so
+`--ref HEAD --project verification/fixtures/state-probe/StateProbe.xcodeproj --scheme
+StateProbe --min-screens 1` must report at least one issue; a clean result there means the
+audit is blind.
+
+A run proves nothing when the harness did not run, the audit threw on a screen, or fewer than
+`--min-screens` screens were audited. The report then says `ok: false` with the reasons in
+`problems`, and the exit code is 1 whatever the issue count.
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+from hsverify import gitref, simrun
+
+HARNESS = Path(__file__).resolve().parents[1] / "fixtures" / "a11y-audit" / "A11yAudit.xcodeproj"
+DEFAULT_REPORTS = Path.home() / "house-scanning-data" / "reports" / "a11y"
+HARNESS_DERIVED = Path("/tmp/hs-verify-a11y-derived-data")
+# SF Symbol names are lowercase words joined by dots: "gearshape", "arrow.left.circle.fill".
+SYMBOL_NAME = re.compile(r"^[a-z0-9]+(\.[a-z0-9]+)*$")
+ARG_SEPARATOR = "\x1f" # the harness splits AUDIT_ARGS on U+001F
+
+
+def parse_output(text: str) -> tuple[list[dict], list[dict]]:
+ """Screens and issues from the harness's `A11Y_SCREEN=` and `A11Y_ISSUE=` lines."""
+ screens, issues = [], []
+ for line in text.splitlines():
+ for tag, sink in (("A11Y_SCREEN=", screens), ("A11Y_ISSUE=", issues)):
+ if tag in line:
+ try:
+ sink.append(json.loads(line.split(tag, 1)[1]))
+ except json.JSONDecodeError:
+ continue
+ return screens, issues
+
+
+# One-word symbol names that are not ordinary English words. Dotted names ("arrow.left") are
+# always symbol names; a plain word such as "camera" could be a deliberate label.
+SYMBOL_WORDS = {"gearshape", "xmark", "checkmark", "ellipsis", "chevron", "info", "plus"}
+
+
+def symbol_name_labels(screens: list[dict]) -> list[dict]:
+ """Buttons whose accessible label looks like an SF Symbol name."""
+ found = []
+ for screen in screens:
+ for label in screen.get("labels", []):
+ kind, _, text = label.partition(":")
+ looks_like_symbol = SYMBOL_NAME.match(text) and ("." in text or text in SYMBOL_WORDS)
+ if kind == "button" and looks_like_symbol:
+ found.append({"screen": screen["index"], "label": text})
+ return found
+
+
+def audited_screens(screens: list[dict]) -> set:
+ """Indexes of screens the audit completed on.
+
+ A screen entry carrying "error" means the audit threw on that screen or the app stopped.
+ After a throw the harness still prints a normal entry for the same index, so a screen
+ counts only if no entry for its index carries an error.
+ """
+ errored = {s.get("index") for s in screens if "error" in s}
+ return {s.get("index") for s in screens if "error" not in s} - errored
+
+
+def audit_problems(screens: list[dict], harness_ran: bool, min_screens: int) -> list[str]:
+ """Why this run is not evidence of an audit; empty when it is."""
+ problems = []
+ if not harness_ran:
+ problems.append("the audit harness did not run (no TEST SUCCEEDED or TEST FAILED line)")
+ problems += [f"screen {s.get('index')}: {s['error']}" for s in screens if "error" in s]
+ audited = audited_screens(screens)
+ if not audited:
+ problems.append("no screen was audited")
+ elif len(audited) < min_screens:
+ problems.append(f"{len(audited)} screens audited, fewer than --min-screens {min_screens}")
+ return problems
+
+
+def run_harness(
+ udid: str, bundle_id: str, args: argparse.Namespace, launch: list[str], out: Path
+) -> str:
+ env = os.environ | {
+ "TEST_RUNNER_AUDIT_BUNDLE": bundle_id,
+ "TEST_RUNNER_AUDIT_ARGS": ARG_SEPARATOR.join(launch),
+ "TEST_RUNNER_AUDIT_SECONDS": str(args.seconds),
+ "TEST_RUNNER_AUDIT_IDLE": str(args.idle),
+ "TEST_RUNNER_AUDIT_OUT": str(out),
+ }
+ cmd = [
+ "xcodebuild",
+ "test",
+ "-project",
+ str(HARNESS),
+ "-scheme",
+ "AccessibilityAudit",
+ "-destination",
+ f"id={udid}",
+ "-derivedDataPath",
+ str(HARNESS_DERIVED),
+ "CODE_SIGNING_ALLOWED=NO",
+ ]
+ simrun.wait_for_other_builds(args.build_wait)
+ result = subprocess.run(cmd, env=env, capture_output=True, text=True)
+ (out / "xcodebuild-test.log").write_text(result.stdout + result.stderr)
+ return result.stdout
+
+
+def write_report(out: Path, data: dict) -> None:
+ (out / "report.json").write_text(json.dumps(data, indent=2) + "\n")
+ rows = []
+ for screen in data["screens"]:
+ if "error" in screen:
+ rows.append(
+ f"
Screen {screen['index']}: audit error
"
+ f"
{html.escape(str(screen['error']))}
"
+ )
+ continue
+ issues = [i for i in data["issues"] if i.get("screen") == screen["index"]]
+ symbols = [s for s in data["symbol_name_labels"] if s["screen"] == screen["index"]]
+ items = "".join(
+ f"
" + "".join(rows)
+ )
+ (out / "index.html").write_text(page)
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ parser.add_argument("--ref", default="origin/t3/ios-mvf")
+ parser.add_argument("--project", default="ios/HouseScan.xcodeproj")
+ parser.add_argument("--scheme", default="HouseScan")
+ parser.add_argument("--replay", type=Path)
+ parser.add_argument("--autopilot", action="store_true")
+ parser.add_argument("--server-url")
+ parser.add_argument("--extra-arg", action="append", default=[])
+ parser.add_argument("--seconds", type=float, default=180)
+ parser.add_argument("--idle", type=float, default=20)
+ parser.add_argument("--appearance", choices=["light", "dark"], default="light")
+ parser.add_argument("--content-size")
+ parser.add_argument("--label", default="")
+ parser.add_argument("--build-wait", type=float, default=1200)
+ # Nine: the app's ten states less `unsupported`, which needs a device without LiDAR (the
+ # same count as metric S3-7). The state-probe fixture has fewer screens; pass a lower value.
+ parser.add_argument("--min-screens", type=int, default=9)
+ args = parser.parse_args(argv)
+
+ gitref.fetch()
+ sha = gitref.resolve(args.ref)
+ stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
+ name = "-".join(
+ filter(
+ None,
+ [
+ stamp,
+ simrun.slug(args.ref.removeprefix("origin/")),
+ sha[:8],
+ simrun.slug(args.label),
+ ],
+ )
+ )
+ out = DEFAULT_REPORTS / name
+ out.mkdir(parents=True, exist_ok=True)
+
+ launch: list[str] = []
+ if args.replay:
+ launch += ["-replay", str(args.replay.expanduser().resolve())]
+ if args.autopilot:
+ launch.append("-autopilot")
+ if args.server_url:
+ launch += ["-serverURL", args.server_url]
+ launch += args.extra_arg
+
+ with gitref.detached_worktree(sha) as tree:
+ build, app = simrun.build_app(tree, out, args.build_wait, args.project, args.scheme)
+ if app is None:
+ print(f"Build failed; see {out / 'build.log'}")
+ return 1
+ device = simrun.ensure_device()
+ udid = device["udid"]
+ try:
+ bundle_id = simrun.bundle_id_of(app)
+ simrun.prepare_display(udid, args.appearance, args.content_size)
+ subprocess.run(["xcrun", "simctl", "uninstall", udid, bundle_id], capture_output=True)
+ simrun.simctl("install", udid, str(app))
+ simrun.discard_app(app)
+ subprocess.run(
+ ["xcrun", "simctl", "privacy", udid, "grant", "camera", bundle_id], capture_output=True
+ )
+ print(f"Auditing {bundle_id} at {sha[:12]}...", flush=True)
+ output = run_harness(udid, bundle_id, args, launch, out)
+ finally:
+ subprocess.run(["xcrun", "simctl", "shutdown", udid], capture_output=True)
+
+ screens, issues = parse_output(output)
+ symbols = symbol_name_labels(screens)
+ harness_ran = "** TEST SUCCEEDED **" in output or "** TEST FAILED **" in output
+ problems = audit_problems(screens, harness_ran, args.min_screens)
+ data = {
+ # ok: the run is evidence of an audit. Issues found are counted separately below.
+ "ok": not problems,
+ "problems": problems,
+ "ref": args.ref,
+ "build": build,
+ "sha": sha,
+ "launch_arguments": launch,
+ "appearance": args.appearance,
+ "content_size": args.content_size or "large",
+ "screens": screens,
+ "issues": issues,
+ "symbol_name_labels": symbols,
+ "issue_count": len(issues) + len(symbols),
+ "screens_audited": len(audited_screens(screens)),
+ "min_screens": args.min_screens,
+ "harness_ran": harness_ran,
+ }
+ write_report(out, data)
+ print(
+ f"{data['screens_audited']} screens, {data['issue_count']} issues. "
+ f"Open {out / 'index.html'}"
+ )
+ for issue in issues[:20]:
+ print(
+ f" screen {issue.get('screen')}: {issue.get('description')} {issue.get('element', '')}"
+ )
+ for s in symbols:
+ print(f" screen {s['screen']}: button label is a symbol name: {s['label']}")
+ for problem in problems:
+ print(f"PROBLEM: {problem}; see {out / 'xcodebuild-test.log'}")
+ return 0 if not problems and data["issue_count"] == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/verification/hsverify/e2e.py b/verification/hsverify/e2e.py
new file mode 100644
index 000000000..87e8bde35
--- /dev/null
+++ b/verification/hsverify/e2e.py
@@ -0,0 +1,886 @@
+"""Send scenes through the placement server's HTTP API and judge every answer.
+
+ uv run python -m hsverify.e2e --server-ref origin/t3/server # cases/ + real scenes
+ uv run python -m hsverify.e2e --server-url http://127.0.0.1:8000 --scene path/to/scene.json
+
+The server under test is either started from a git ref (a detached worktree under /tmp,
+`uv sync --locked`, then uvicorn on a free port) or reached at `--server-url`. Its scene
+endpoint is read from its OpenAPI document; with more than one candidate the run stops and
+asks for `--endpoint` instead of guessing.
+
+A server started here is killed past 3.5 GB. Scenes go as the app sends them: JSON when the
+endpoint takes it, a zip only for the compressed-scene hostile input. Bundles read from disk
+stay within fixed entry and size limits, checked before reading.
+
+Each scene is first validated against the scene schema published at the same ref, so a bad
+input is reported as an input problem, not a server bug. Each response must be a JSON object
+that validates against the result schema (anything else is a failure in the report, not a
+crash) and passes the invariants in `resultcheck`, with rules read from the ref's `rules.yaml`.
+Case files add the outcomes their geometry forces. Every scene is also resent
+changed, and the answers must stay ordered: again (identical apart from timing), mirrored (same
+decision and outcome lengths), without or with less coverage, with ground trimmed just short
+of the largest clearance, with more error or tape re-measured by tap (nothing improves), and
+with every requested view added, up to three rounds (no check left unsure for coverage). Five
+hostile inputs must be refused or answered within 10 s and 500 MB of server growth.
+
+A run claims the contract (`contract_ok`) only when at least one scene was answered and
+checked and none broke it, and real-scene latency only from an HTTP 200 result.
+
+Reports go to ~/house-scanning-data/reports/e2e// (outside git; real scenes can
+reference dataset images).
+"""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import datetime as dt
+import hashlib
+import io
+import json
+import os
+import re
+import signal
+import socket
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+import zipfile
+from collections.abc import Iterator
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from hsverify import gitref, memory
+from hsverify.memory import peak_rss_mb
+from hsverify.resultcheck import (
+ RuleSet,
+ assumption_mismatches,
+ comparable,
+ coverage_blocks,
+ expectation_problems,
+ invariant_problems,
+ less_coverage_problems,
+ mirror_scene,
+ more_error_problems,
+ outcome_lengths,
+ schema_errors,
+ tape_to_tap,
+ unobserved_checks,
+ with_ground_short_of,
+ with_less_coverage,
+ with_more_error,
+ with_requests_captured,
+)
+
+HERE = Path(__file__).resolve().parents[1]
+DEFAULT_CASES = HERE / "e2e" / "cases"
+DEFAULT_REPORTS = Path.home() / "house-scanning-data" / "reports" / "e2e"
+SCENE_SCHEMA = "server/schemas/scene.schema.json"
+RESULT_SCHEMA = "server/schemas/result.schema.json"
+# The plan's S2 metric: a real-derived scene returns a result in under 1 s.
+LATENCY_BUDGET_MS = 1000.0
+MIRROR_LENGTH_TOL_FT = 0.5
+
+
+# --- Inputs ----------------------------------------------------------------------------------
+
+
+@dataclass
+class SceneInput:
+ name: str
+ scene: dict
+ raw: bytes # exactly the bytes sent, so the server's input hash can be checked
+ images: dict[str, bytes] = field(default_factory=dict)
+ expect: dict | None = None
+ rules_assumed: dict[str, float] = field(default_factory=dict)
+ real: bool = False
+ source: str = ""
+ skip_reason: str | None = None
+ app_export: bool = False # exported by the iOS app (from a replay), the S4 end-to-end path
+ app_sha: str | None = None # the app commit that exported it, when known
+ hostile: bool = False # judged by judge_hostile: refused or answered within a budget
+ bundle: bytes | None = None # a prebuilt upload, sent as is (the compressed-scene input)
+
+
+def load_app_export(path: Path) -> SceneInput:
+ """A scene the iOS app exported: a scan.zip, or a Simulator report folder holding one (whose
+ report.json names the app commit that produced it)."""
+ app_sha = None
+ if path.is_dir():
+ run = json.loads((path / "report.json").read_text())
+ if not run.get("app_export"):
+ raise SystemExit(f"{path} has no app export; the run never reached the upload")
+ app_sha = run["sha"]
+ path = path / run["app_export"]
+ item = load_input(path, real=True)
+ item.app_export, item.app_sha = True, app_sha
+ item.source = str(path)
+ return item
+
+
+# Limits on a scene bundle this harness reads into memory: the server's own scene.json cap, and
+# room for a few hundred phone JPEGs, well under the shared Mac's ~4 GB per-process budget. A
+# compressed bundle can declare anything, so reads stop at the limit whatever it declares.
+MAX_BUNDLE_ENTRIES = 2000
+MAX_SCENE_BYTES = 10 * 2**20
+MAX_IMAGE_BYTES = 32 * 2**20
+MAX_BUNDLE_BYTES = 512 * 2**20
+
+
+def read_bounded(bundle: zipfile.ZipFile, info: zipfile.ZipInfo, limit: int) -> bytes:
+ """An entry's bytes, refused when it declares or turns out to hold more than `limit`."""
+ if info.file_size > limit:
+ raise SystemExit(f"{info.filename}: declares {info.file_size} bytes, limit {limit}")
+ with bundle.open(info) as f:
+ data = f.read(limit + 1)
+ if len(data) > limit:
+ raise SystemExit(f"{info.filename}: holds more than {limit} bytes")
+ return data
+
+
+def read_bundle(path: Path) -> tuple[bytes, dict[str, bytes]]:
+ """scene.json and the JPEGs of a C1 zip bundle, within the limits above."""
+ with zipfile.ZipFile(path) as bundle:
+ infos = [i for i in bundle.infolist() if not i.is_dir()]
+ if len(infos) > MAX_BUNDLE_ENTRIES:
+ raise SystemExit(f"{path}: {len(infos)} entries, limit {MAX_BUNDLE_ENTRIES}")
+ declared = sum(i.file_size for i in infos)
+ if declared > MAX_BUNDLE_BYTES:
+ raise SystemExit(f"{path}: declares {declared} bytes, limit {MAX_BUNDLE_BYTES}")
+ scene = next((i for i in infos if i.filename.endswith("scene.json")), None)
+ if scene is None:
+ raise SystemExit(f"{path}: no scene.json in the bundle")
+ raw = read_bounded(bundle, scene, MAX_SCENE_BYTES)
+ images = {
+ Path(i.filename).name: read_bounded(bundle, i, MAX_IMAGE_BYTES)
+ for i in infos
+ if i.filename.lower().endswith(".jpg")
+ }
+ return raw, images
+
+
+def load_input(path: Path, real: bool = False) -> SceneInput:
+ """A case file (scene + expect), a bare scene.json, or a C1 zip bundle."""
+ if path.suffix == ".zip":
+ raw, images = read_bundle(path)
+ return SceneInput(path.stem, json.loads(raw), raw, images, real=real, source=str(path))
+ data = json.loads(path.read_text())
+ if "scene_path" in data and "expect" in data:
+ # A committed case about a scene that stays outside git (dataset-derived).
+ target = Path(data["scene_path"]).expanduser()
+ if not target.exists():
+ return SceneInput(
+ data["id"],
+ {},
+ b"",
+ source=str(path),
+ skip_reason=f"{target} not built here; see the case's README",
+ )
+ item = load_input(target, real=bool(data.get("real", real)))
+ item.name, item.expect = data["id"], data["expect"]
+ item.rules_assumed = data.get("rules_assumed", {})
+ item.source = data.get("source", str(path))
+ return item
+ if "scene" in data and "expect" in data:
+ raw = json.dumps(data["scene"], indent=1).encode()
+ return SceneInput(
+ data.get("id", path.stem),
+ data["scene"],
+ raw,
+ expect=data["expect"],
+ rules_assumed=data.get("rules_assumed", {}),
+ real=bool(data.get("real", real)),
+ source=data.get("source", str(path)),
+ skip_reason=data.get("skip_reason"),
+ )
+ return SceneInput(path.stem, data, path.read_bytes(), real=real, source=str(path))
+
+
+# --- Server ----------------------------------------------------------------------------------
+
+
+def free_port() -> int:
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def find_app(server_dir: Path) -> str:
+ """`module:variable` of the FastAPI app. Exactly one is required."""
+ found = []
+ for path in sorted(server_dir.glob("*.py")):
+ for match in re.finditer(r"^(\w+)\s*=\s*FastAPI\(", path.read_text(), re.M):
+ found.append(f"{path.stem}:{match.group(1)}")
+ if len(found) != 1:
+ raise SystemExit(
+ f"Expected one `app = FastAPI(...)` in {server_dir}, found {found or 'none'}. "
+ "Start the server yourself and pass --server-url."
+ )
+ return found[0]
+
+
+# A server under test that passes this is killed and the run says so: the Mac is shared, and a
+# careless server can parse a small hostile input into gigabytes.
+SERVER_LIMIT_MB = 3500
+
+
+@contextlib.contextmanager
+def server_from_ref(sha: str, log_path: Path) -> Iterator[tuple[str, memory.TreeLimit]]:
+ """The server at `sha` running its own FastAPI app on a free port, under a memory limit."""
+ with gitref.detached_worktree(sha) as tree:
+ server_dir = tree / "server"
+ subprocess.run(["uv", "sync", "--locked", "--quiet"], cwd=server_dir, check=True)
+ port = free_port()
+ url = f"http://127.0.0.1:{port}"
+ target = ["uvicorn", find_app(server_dir), "--host", "127.0.0.1", "--port", str(port)]
+ with log_path.open("w") as log:
+ proc = subprocess.Popen(
+ ["uv", "run", "--quiet", *target],
+ cwd=server_dir,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ )
+ try:
+ with memory.TreeLimit(proc, SERVER_LIMIT_MB, interval_s=0.2) as limit:
+ wait_until_up(url, proc)
+ yield url, limit
+ finally:
+ # SIGTERM lets uvicorn finish in-flight requests, and a server still solving a
+ # hostile input would run on for minutes: kill it after 10 s.
+ if proc.poll() is None:
+ os.killpg(proc.pid, signal.SIGTERM)
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ os.killpg(proc.pid, signal.SIGKILL)
+ proc.wait()
+
+
+def wait_until_up(url: str, proc: subprocess.Popen, timeout_s: float = 60) -> None:
+ deadline = time.monotonic() + timeout_s
+ while time.monotonic() < deadline:
+ if proc.poll() is not None:
+ raise SystemExit(f"The server exited with code {proc.returncode}; see server.log.")
+ try:
+ urllib.request.urlopen(f"{url}/openapi.json", timeout=2).read()
+ return
+ except (urllib.error.URLError, ConnectionError, TimeoutError):
+ time.sleep(0.5)
+ raise SystemExit(f"The server did not answer at {url} within {timeout_s:.0f} s.")
+
+
+@dataclass
+class Endpoint:
+ path: str
+ content_type: str # "application/json" or "multipart/form-data"
+ file_field: str | None = None
+ accepts_zip: bool = False # also takes a raw application/zip body (a prebuilt bundle)
+
+
+def discover_endpoint(openapi: dict, override: str | None = None) -> Endpoint:
+ """The POST operation that takes a scene: JSON body, or a multipart upload of the bundle."""
+ candidates = []
+ for path, ops in openapi.get("paths", {}).items():
+ post = ops.get("post", {})
+ # The result is JSON; a POST declared to answer with something else (an SVG site
+ # plan, say) is not the placement endpoint.
+ answers = post.get("responses", {}).get("200", {}).get("content")
+ if answers and "application/json" not in answers:
+ continue
+ body = post.get("requestBody", {}).get("content", {})
+ for kind in ("application/json", "multipart/form-data"):
+ if kind in body:
+ candidates.append((path, kind, body[kind].get("schema", {})))
+ if override:
+ declared = [c for c in candidates if c[0] == override]
+ # An endpoint that reads the raw request body declares no body schema; the caller has
+ # named it, so it takes the scene as JSON.
+ if not declared and "post" in openapi.get("paths", {}).get(override, {}):
+ declared = [(override, "application/json", {})]
+ if not declared:
+ raise SystemExit(f"--endpoint {override} is not a POST in the server's OpenAPI")
+ candidates = declared
+ elif len(candidates) > 1:
+ named = [c for c in candidates if re.search(r"scene|place|solve|placement", c[0])]
+ candidates = named if len(named) == 1 else candidates
+ # One path taking both a JSON body and an upload: use JSON, which is how the app sends
+ # scene.json since t3/ios-mvf 657ab28 (the hosted API refuses bodies over 4.5 MB).
+ if len({c[0] for c in candidates}) == 1 and len(candidates) > 1:
+ candidates = [c for c in candidates if c[1] == "application/json"] or candidates[:1]
+ if len(candidates) != 1:
+ listed = ", ".join(f"{p} ({k})" for p, k, _ in candidates) or "none"
+ raise SystemExit(f"Cannot tell which endpoint takes a scene: {listed}. Pass --endpoint.")
+ path, kind, schema = candidates[0]
+ body = openapi.get("paths", {}).get(path, {}).get("post", {}).get("requestBody", {})
+ accepts_zip = "application/zip" in body.get("content", {})
+ file_field = None
+ if kind == "multipart/form-data":
+ props = resolve_ref(openapi, schema).get("properties", {})
+ binary = [
+ k
+ for k, v in props.items()
+ if v.get("format") == "binary"
+ or v.get("contentMediaType") == "application/octet-stream"
+ ]
+ if len(binary) != 1:
+ raise SystemExit(f"{path}: expected one file field in the upload form, got {binary}")
+ file_field = binary[0]
+ return Endpoint(path, kind, file_field, accepts_zip)
+
+
+def resolve_ref(openapi: dict, schema: dict) -> dict:
+ ref = schema.get("$ref")
+ if not ref:
+ return schema
+ node = openapi
+ for part in ref.removeprefix("#/").split("/"):
+ node = node[part]
+ return node
+
+
+def bundle_zip(item: SceneInput) -> bytes:
+ if item.bundle is not None:
+ return item.bundle
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
+ z.writestr("scene.json", item.raw)
+ for name, data in item.images.items():
+ z.writestr(name, data)
+ return buf.getvalue()
+
+
+def post(
+ url: str, endpoint: Endpoint, item: SceneInput, timeout_s: float = 60
+) -> tuple[int | None, bytes, float]:
+ """Send one scene. A timeout or a dropped connection comes back as status None."""
+ if item.bundle is not None and endpoint.accepts_zip:
+ body, ctype = item.bundle, "application/zip"
+ elif endpoint.content_type == "application/json":
+ body, ctype = item.raw, "application/json"
+ else:
+ boundary = uuid.uuid4().hex
+ body = (
+ (
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{endpoint.file_field}"; '
+ f'filename="scene.zip"\r\nContent-Type: application/zip\r\n\r\n'
+ ).encode()
+ + bundle_zip(item)
+ + f"\r\n--{boundary}--\r\n".encode()
+ )
+ ctype = f"multipart/form-data; boundary={boundary}"
+ request = urllib.request.Request(
+ url + endpoint.path, data=body, headers={"Content-Type": ctype}, method="POST"
+ )
+ start = time.perf_counter()
+ try:
+ with urllib.request.urlopen(request, timeout=timeout_s) as response:
+ status, payload = response.status, response.read()
+ except urllib.error.HTTPError as exc:
+ status, payload = exc.code, exc.read()
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
+ status, payload = None, str(exc).encode()
+ return status, payload, (time.perf_counter() - start) * 1000
+
+
+# --- Judging ---------------------------------------------------------------------------------
+
+
+def variant(item: SceneInput, name: str, scene: dict) -> SceneInput:
+ raw = json.dumps(scene, indent=1).encode()
+ # Keep the bundle's images: a variant that still lists keyframes must still carry them.
+ return SceneInput(
+ f"{item.name}~{name}", scene, raw, images=item.images, real=item.real, source=item.source
+ )
+
+
+def parse_result(payload: bytes) -> dict | None:
+ """The body as a JSON object, or None: a server's malformed answer is a failure to report,
+ not a reason to stop the run before the report is written."""
+ try:
+ body = json.loads(payload)
+ except ValueError:
+ return None
+ return body if isinstance(body, dict) else None
+
+
+def answer(url: str, endpoint: Endpoint, item: SceneInput) -> tuple[dict | None, str | None]:
+ """The server's result for a scene, or why there is none."""
+ status, payload, _ = post(url, endpoint, item)
+ if status is None:
+ return None, f"no answer: {payload.decode(errors='replace')[:200]}"
+ if status != 200:
+ return None, f"HTTP {status}: {payload[:300]!r}"
+ result = parse_result(payload)
+ if result is None:
+ return None, f"HTTP 200 with a body that is not a JSON object: {payload[:200]!r}"
+ return result, None
+
+
+def judge(
+ url: str,
+ endpoint: Endpoint,
+ item: SceneInput,
+ schemas: dict,
+ rules: RuleSet,
+ limit: memory.TreeLimit | None = None,
+) -> dict:
+ record: dict = {
+ "name": item.name,
+ "source": item.source,
+ "real": item.real,
+ "app_export": item.app_export,
+ "app_sha": item.app_sha,
+ }
+ if item.skip_reason:
+ return record | {"status": "skipped", "problems": [], "skip_reason": item.skip_reason}
+ if item.hostile:
+ return record | {"hostile": True} | judge_hostile(url, endpoint, item, schemas, limit)
+ input_errors = schema_errors(item.scene, schemas["scene"])
+ if input_errors:
+ return record | {"status": "bad input", "problems": input_errors[:20]}
+
+ status, payload, ms = post(url, endpoint, item)
+ record |= {"http_status": status, "latency_ms": round(ms, 1)}
+ if status != 200:
+ failure = [
+ f"HTTP {status} for a scene that validates: {payload[:500]!r}"
+ if status is not None
+ else f"no answer within 60 s: {payload[:200]!r}"
+ ]
+ return record | {"status": "fail", "problems": failure, "contract_problems": failure}
+ result = parse_result(payload)
+ if result is None:
+ failure = [f"HTTP 200 with a body that is not a JSON object: {payload[:200]!r}"]
+ return record | {"status": "fail", "problems": failure, "contract_problems": failure}
+ record["result"] = result
+ problems = [f"result schema: {e}" for e in schema_errors(result, schemas["result"])]
+ if problems:
+ return record | {"status": "fail", "problems": problems[:20], "contract_problems": problems}
+
+ # Contract problems hold for any scene; expectation problems depend on one case's geometry.
+ contract = invariant_problems(item.scene, result, sent=item.raw, rules=rules)
+ contract += property_problems(url, endpoint, item, result, schemas, rules)
+ if item.real and ms > LATENCY_BUDGET_MS:
+ contract.append(f"real-derived scene took {ms:.0f} ms, budget {LATENCY_BUDGET_MS:.0f}")
+ mismatches = assumption_mismatches(item.rules_assumed, result)
+ expected = expectation_problems(item.expect, result) if item.expect and not mismatches else []
+ problems = contract + expected
+
+ record["decision"] = result["decision"]
+ record["outcome_lengths_ft"] = outcome_lengths(result)
+ if mismatches:
+ record["assumption_mismatches"] = mismatches
+ record["contract_problems"] = contract
+ record["expectation_problems"] = expected
+ record["problems"] = problems
+ record["status"] = "fail" if problems else "assumption mismatch" if mismatches else "pass"
+ return record
+
+
+def property_problems(
+ url: str, endpoint: Endpoint, item: SceneInput, result: dict, schemas: dict, rules: RuleSet
+) -> list[str]:
+ """The same scene resent unchanged and transformed. Every answer to a transformed scene must
+ also satisfy the invariants, and the transforms that only lose information must not make
+ the server surer."""
+ problems: list[str] = []
+
+ def resend(name: str, scene: dict) -> dict | None:
+ other, failure = answer(url, endpoint, variant(item, name, scene))
+ if failure:
+ problems.append(f"{name}: {failure}")
+ return None
+ errors = schema_errors(other, schemas["result"])
+ if errors:
+ problems.append(f"{name}: result schema: {errors[0]}")
+ return None
+ problems.extend(f"{name}: {p}" for p in invariant_problems(scene, other, rules=rules))
+ return other
+
+ # 1. Same input, same answer.
+ status, payload, _ = post(url, endpoint, item)
+ again = parse_result(payload) if status == 200 else None
+ if again is None or comparable(again) != comparable(result):
+ problems.append("sending the same scene twice gave different results")
+
+ # 2. Mirrored left to right: same decision, same amount of each outcome along the wall.
+ mirrored = resend("mirrored", mirror_scene(item.scene))
+ if mirrored is not None:
+ if mirrored["decision"] != result["decision"]:
+ problems.append(
+ f"mirrored: decided {mirrored['decision']}, original {result['decision']}"
+ )
+ a, b = outcome_lengths(result), outcome_lengths(mirrored)
+ for outcome in a:
+ if abs(a[outcome] - b[outcome]) > MIRROR_LENGTH_TOL_FT:
+ problems.append(
+ f"mirrored: {outcome} starts cover {b[outcome]:.2f} ft, "
+ f"original {a[outcome]:.2f} ft"
+ )
+
+ # 3. Less information never makes the server surer.
+ if "coverage" in item.scene:
+ bare = {k: v for k, v in item.scene.items() if k != "coverage"}
+ if (other := resend("no coverage", bare)) is not None:
+ problems += less_coverage_problems(result, other, "no coverage")
+ for name, scene in (
+ ("less coverage", with_less_coverage(item.scene)),
+ ("ground short of the largest clearance", with_ground_short_of(item.scene, rules)),
+ ):
+ if scene is not None and (other := resend(name, scene)) is not None:
+ problems += less_coverage_problems(result, other, name)
+ for name, scene in (
+ ("more error", with_more_error(item.scene, rules)),
+ ("tape re-measured by tap", tape_to_tap(item.scene)),
+ ):
+ if scene is not None and (other := resend(name, scene)) is not None:
+ problems += more_error_problems(result, other, name)
+
+ # 4. Showing everything the result asked for settles every check that was unsure only
+ # because an area was unobserved (the chosen spot may move; three rounds at most).
+ scene, current = item.scene, result
+ for round_ in range(1, 4):
+ if not coverage_blocks(current):
+ break
+ scene = with_requests_captured(scene, current)
+ if scene is None:
+ break # what remains needs a walk past an end, not another view
+ current = resend(f"requests captured ({round_})", scene)
+ if current is None:
+ break
+ else:
+ if coverage_blocks(current):
+ problems.append(
+ "after capturing every requested view three times, checks are still unsure "
+ f"for coverage: {unobserved_checks(current)}"
+ )
+ return problems
+
+
+# --- Hostile inputs ---------------------------------------------------------------------------
+
+# A refusal or an answer must come within this; an input that keeps the server busy longer is a
+# denial-of-service risk. Not derived from any requirement: it is ten times the real-scene
+# budget. S2 at 739fb6f answered the crowded input in 8.2 s on this Mac, close to the line.
+HOSTILE_BUDGET_MS = 10_000.0
+
+
+def hostile_inputs() -> list[SceneInput]:
+ """Small requests that cost a careless server a lot. Built here, not stored, so the
+ repository carries no blobs; none takes the harness more than a few MB to build."""
+ base = {"meter": {"pos": [0.0, 4.5, 0.0], "wall_id": "w1"}}
+ # Every object and coverage fragment adds boundaries, and each object its own error: a
+ # solver that pairs every boundary with every error offset grows quadratically.
+ crowded = base | {
+ "walls": [{"id": "w1", "baseline": [[-60.0, 0.0], [60.0, 0.0]]}],
+ "objects": [
+ {
+ "type": "elec_box",
+ "wall_id": "w1",
+ "span_ft": [-55 + 0.75 * i, -54.85 + 0.75 * i],
+ "bottom_ft": 3.5,
+ "top_ft": 4.5,
+ "source": "tap",
+ "plus_minus_ft": round(0.02 + 0.0013 * i, 4),
+ }
+ for i in range(150)
+ ],
+ "coverage": {
+ "observed": [
+ {"band": "wall", "span_ft": [-55.6 + 0.75 * i, -55.2 + 0.75 * i]}
+ for i in range(150)
+ ]
+ + [{"band": "ground", "span_ft": [-60, 60], "out_ft": 15}]
+ },
+ }
+ huge = {
+ "meter": {"pos": [1e300, 4.5, 0.0], "wall_id": "w1"},
+ "walls": [{"id": "w1", "baseline": [[-1e300, 0.0], [1e300, 0.0]]}],
+ }
+ long_wall = base | {
+ "walls": [{"id": "w1", "baseline": [[-2500.0, 0.0], [2500.0, 0.0]]}],
+ "coverage": {
+ "observed": [
+ {"band": "wall", "span_ft": [-2500, 2500]},
+ {"band": "ground", "span_ft": [-2500, 2500], "out_ft": 20},
+ ]
+ },
+ }
+ nested = ('{"meter": ' + "[" * 5000 + "]" * 5000 + "}").encode()
+ items = [
+ SceneInput(
+ "hostile: 150 objects, each its own error", crowded, json.dumps(crowded).encode()
+ ),
+ SceneInput("hostile: coordinates of 1e300 ft", huge, json.dumps(huge).encode()),
+ SceneInput("hostile: a 5000 ft wall", long_wall, json.dumps(long_wall).encode()),
+ SceneInput("hostile: nested 5000 deep", {}, nested),
+ SceneInput("hostile: 400 MB scene.json in a small zip", {}, b"", bundle=zip_bomb(400)),
+ ]
+ for item in items:
+ item.hostile = True
+ return items
+
+
+def zip_bomb(megabytes: int) -> bytes:
+ """A zip whose scene.json is `megabytes` of spaces around a number: under 1 MB to send,
+ written in chunks so the harness never holds the expanded text."""
+ buf = io.BytesIO()
+ chunk = b" " * 2**20
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z, z.open("scene.json", "w") as f:
+ f.write(b'{"meter": ')
+ for _ in range(megabytes):
+ f.write(chunk)
+ f.write(b"1}")
+ return buf.getvalue()
+
+
+# A hostile input may cost the server this much memory above what it held before the request.
+# Not derived from any requirement: S2 at 739fb6f grew by at most 186 MB on these inputs, and at
+# 26d2870, before its input caps, by 557 to 1176 MB.
+HOSTILE_MEMORY_MB = 500
+
+
+def judge_hostile(
+ url: str,
+ endpoint: Endpoint,
+ item: SceneInput,
+ schemas: dict,
+ limit: memory.TreeLimit | None = None,
+) -> dict:
+ """A refusal (400, 413, 422) or a valid result, either within the time budget and, for a
+ server started here, the memory budget. A crash, a hang, a slow answer or a large
+ allocation is a failure."""
+ if (
+ item.bundle is not None
+ and endpoint.content_type == "application/json"
+ and not (endpoint.accepts_zip)
+ ):
+ return {
+ "status": "skipped",
+ "skip_reason": "the endpoint takes neither a zip nor an upload",
+ }
+ before = limit.reset() if limit else 0.0
+ status, payload, ms = post(url, endpoint, item, timeout_s=HOSTILE_BUDGET_MS / 1000 + 5)
+ record = {"http_status": status, "latency_ms": round(ms, 1)}
+ grew = limit.peak_mb - before if limit else 0.0
+ if limit:
+ record["server_growth_mb"] = round(grew, 1)
+ if grew > HOSTILE_MEMORY_MB:
+ failure = f"the server grew by {grew:.0f} MB, budget {HOSTILE_MEMORY_MB} MB"
+ elif status is None:
+ failure = f"no answer within {HOSTILE_BUDGET_MS / 1000 + 5:.0f} s"
+ elif ms > HOSTILE_BUDGET_MS:
+ failure = f"answered HTTP {status} after {ms:.0f} ms, budget {HOSTILE_BUDGET_MS:.0f}"
+ elif status in (400, 413, 422):
+ failure = None
+ elif status == 200:
+ result = parse_result(payload)
+ if result is None:
+ failure = f"HTTP 200 with a body that is not a JSON object: {payload[:200]!r}"
+ else:
+ errors = schema_errors(result, schemas["result"])
+ failure = f"result schema: {errors[0]}" if errors else None
+ else:
+ failure = f"HTTP {status}: {payload[:200]!r}"
+ problems = [failure] if failure else []
+ return record | {
+ "status": "fail" if problems else "pass",
+ "problems": problems,
+ "contract_problems": problems,
+ }
+
+
+# --- Report ----------------------------------------------------------------------------------
+
+
+def write_report(out: Path, meta: dict, records: list[dict]) -> dict:
+ counts: dict[str, int] = {}
+ for r in records:
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
+ # A scene counts as checked only when the server answered it and the answer was judged; a
+ # skipped scene, a refused one or scene input that failed its own schema checks nothing.
+ checked = [r for r in records if "decision" in r and not r.get("hostile")]
+ # Latency counts only a real scene the server answered with a result, never a fast refusal.
+ real = [r["latency_ms"] for r in checked if r.get("real") and r.get("http_status") == 200]
+ exported = [r for r in records if r.get("app_export")]
+ contract_problems = sum(len(r.get("contract_problems", [])) for r in records)
+ killed = bool(meta["peak_memory"].get("server_killed"))
+ report = meta | {
+ "counts": counts,
+ "all_passed": all(r["status"] in ("pass", "skipped") for r in records)
+ and bool(checked)
+ and not killed,
+ "latency_ms": max(real) if real else None,
+ "scenes_answered": sum(1 for r in records if "decision" in r),
+ "scenes_checked": len(checked),
+ "scenes_skipped": counts.get("skipped", 0),
+ "scenes_bad_input": counts.get("bad input", 0),
+ # Invariants and properties broken, summed over every answered scene. Case
+ # expectations are counted separately because a case can be wrong itself.
+ "contract_problem_count": contract_problems,
+ # The contract holds only on evidence: at least one scene checked, none broken.
+ "contract_ok": bool(checked) and contract_problems == 0 and not killed,
+ "expectation_problem_count": sum(len(r.get("expectation_problems", [])) for r in records),
+ "real_scene_passed": any(r.get("real") and r["status"] == "pass" for r in records),
+ # Null when the run had no app export, so the scoreboard looks for an older run.
+ "app_export_scene_valid": all(r["status"] != "bad input" for r in exported)
+ if exported
+ else None,
+ "app_export_passed": all(r["status"] == "pass" for r in exported) if exported else None,
+ # The app commit the export came from (the scoreboard ties app metrics to it); null when
+ # the run had none or several, or the export's origin is unknown.
+ "app_sha": app_shas.pop()
+ if len(app_shas := {r.get("app_sha") for r in exported}) == 1
+ else None,
+ "app_export_source": [r["source"] for r in exported],
+ "records": records,
+ }
+ (out / "report.json").write_text(json.dumps(report, indent=2) + "\n")
+ lines = [
+ f"# End-to-end run against `{meta['server_ref']}` at `{meta['server_sha'][:12]}`",
+ "",
+ f"{meta['started_at']}. Endpoint `{meta['endpoint']}`. "
+ f"Load average {meta['load_average_1_5_15']} on {os.cpu_count()} cores. "
+ + ", ".join(f"{v} {k}" for k, v in sorted(counts.items())),
+ "",
+ ]
+ if meta["peak_memory"].get("server_killed"):
+ lines += [
+ f"**The server passed {SERVER_LIMIT_MB} MB and was killed**; every scene after that "
+ "failed to connect.",
+ "",
+ ]
+ lines += [
+ "| Scene | Status | Decision | ms | Problems |",
+ "| --- | --- | --- | --- | --- |",
+ ]
+ for r in records:
+ issues = "; ".join(r.get("problems", []) + r.get("assumption_mismatches", []))
+ issues = issues or r.get("skip_reason", "")
+ lines.append(
+ f"| {r['name']}{' (real)' if r.get('real') else ''} | {r['status']} | "
+ f"{r.get('decision', '')} | {r.get('latency_ms', '')} | {issues.replace('|', '/')} |"
+ )
+ (out / "report.md").write_text("\n".join(lines) + "\n")
+ return report
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ where = parser.add_mutually_exclusive_group()
+ where.add_argument("--server-ref", default="origin/t3/server")
+ where.add_argument("--server-url", help="use a running server instead of starting one")
+ parser.add_argument("--schema-ref", help="ref for the schemas (default: --server-ref)")
+ parser.add_argument("--endpoint", help="POST path, when discovery is ambiguous")
+ parser.add_argument("--cases", type=Path, default=DEFAULT_CASES)
+ parser.add_argument(
+ "--scene",
+ type=Path,
+ action="append",
+ default=[],
+ help="extra case file, scene.json or scene.zip",
+ )
+ parser.add_argument(
+ "--real",
+ type=Path,
+ action="append",
+ default=[],
+ help="scene derived from real data; held to the latency budget",
+ )
+ parser.add_argument(
+ "--app-export",
+ type=Path,
+ action="append",
+ default=[],
+ help="scan.zip the app exported, or a sim report folder with one (records the app SHA)",
+ )
+ parser.add_argument(
+ "--no-hostile", action="store_true", help="skip the hostile inputs (quicker local runs)"
+ )
+ parser.add_argument("--out", type=Path)
+ args = parser.parse_args(argv)
+
+ gitref.fetch()
+ # --server-ref keeps its default when --server-url is given, so it also names the schemas then.
+ schema_ref = args.schema_ref or args.server_ref
+ schema_sha = gitref.resolve(schema_ref)
+ schemas = {}
+ for key, path in (("scene", SCENE_SCHEMA), ("result", RESULT_SCHEMA)):
+ text = gitref.show(schema_sha, path)
+ if text is None:
+ raise SystemExit(f"{path} does not exist at {schema_ref}")
+ schemas[key] = json.loads(text)
+ rules_text = gitref.show(schema_sha, "server/rules.yaml")
+ if rules_text is None:
+ raise SystemExit(f"server/rules.yaml does not exist at {schema_ref}")
+ rules = RuleSet.from_yaml(rules_text)
+
+ items = (
+ [load_input(p) for p in sorted(args.cases.glob("*.json"))] if args.cases.exists() else []
+ )
+ items += [load_input(p) for p in args.scene]
+ items += [load_input(p, real=True) for p in args.real]
+ for path in args.app_export:
+ items.append(load_app_export(path.expanduser()))
+ if not args.no_hostile:
+ items += hostile_inputs()
+ if not items:
+ raise SystemExit("No scenes: add case files to e2e/cases or pass --scene/--real.")
+
+ server_sha = "(running server)" if args.server_url else gitref.resolve(args.server_ref)
+ stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
+ label = urllib.parse.urlsplit(args.server_url).hostname if args.server_url else server_sha[:8]
+ out = (args.out or DEFAULT_REPORTS / f"{stamp}-{label}").expanduser()
+ out.mkdir(parents=True, exist_ok=True)
+
+ limit = None
+ with contextlib.ExitStack() as stack:
+ if args.server_url:
+ url = args.server_url
+ else:
+ url, limit = stack.enter_context(server_from_ref(server_sha, out / "server.log"))
+ openapi = json.loads(urllib.request.urlopen(f"{url}/openapi.json", timeout=10).read())
+ endpoint = discover_endpoint(openapi, args.endpoint)
+ print(f"Server {url}, endpoint POST {endpoint.path} ({endpoint.content_type})")
+ records = []
+ for item in items:
+ record = judge(url, endpoint, item, schemas, rules, limit)
+ records.append(record)
+ print(
+ f" {record['status']:<20} {item.name}"
+ + (
+ f" [{record.get('decision')}, {record.get('latency_ms')} ms]"
+ if "decision" in record
+ else ""
+ )
+ )
+ for problem in record.get("problems", [])[:6]:
+ print(f" - {problem}")
+
+ meta = {
+ "started_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
+ "server_ref": args.server_url or args.server_ref,
+ "server_sha": server_sha,
+ "schema_ref": schema_ref,
+ "schema_sha": schema_sha,
+ "sha": server_sha, # the scoreboard matches a report to a branch head by this key
+ "endpoint": f"POST {endpoint.path} ({endpoint.content_type})",
+ "command": " ".join([sys.executable, "-m", "hsverify.e2e", *(argv or sys.argv[1:])]),
+ # Latency on this shared Mac depends on what else runs; keep the load with the numbers.
+ "load_average_1_5_15": [round(x, 1) for x in os.getloadavg()],
+ "peak_memory": peak_rss_mb()
+ | ({"server_mb": round(limit.peak_mb, 1), "server_killed": limit.killed} if limit else {}),
+ "cases_sha256": hashlib.sha256(b"".join(i.raw for i in items)).hexdigest(),
+ }
+ report = write_report(out, meta, records)
+ print(f"Report: {out / 'report.md'}")
+ return 0 if report["all_passed"] else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/verification/hsverify/gitref.py b/verification/hsverify/gitref.py
new file mode 100644
index 000000000..e711e295d
--- /dev/null
+++ b/verification/hsverify/gitref.py
@@ -0,0 +1,69 @@
+"""Read-only access to other branches through throwaway worktrees under /tmp.
+
+Verification never checks out another thread's branch in its own worktree and never
+pushes to one. A detached worktree at the exact commit keeps the evidence tied to a SHA.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import shutil
+import subprocess
+import tempfile
+from collections.abc import Iterator
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[2]
+TMP_ROOT = Path("/tmp")
+
+
+def git(*args: str, cwd: Path = REPO) -> str:
+ return subprocess.run(
+ ["git", *args], cwd=cwd, check=True, capture_output=True, text=True
+ ).stdout.strip()
+
+
+def fetch() -> None:
+ git("fetch", "--quiet", "origin")
+
+
+def resolve(ref: str) -> str:
+ """Full SHA of a ref. Raises CalledProcessError with git's message if it does not exist."""
+ return git("rev-parse", "--verify", f"{ref}^{{commit}}")
+
+
+def ref_exists(ref: str) -> bool:
+ try:
+ resolve(ref)
+ except subprocess.CalledProcessError:
+ return False
+ return True
+
+
+def show(ref: str, path: str) -> str | None:
+ """File contents at a ref, or None when the file does not exist there."""
+ try:
+ return git("show", f"{ref}:{path}")
+ except subprocess.CalledProcessError:
+ return None
+
+
+@contextlib.contextmanager
+def detached_worktree(sha: str, keep: bool = False) -> Iterator[Path]:
+ """A detached worktree of `sha` under /tmp, removed afterwards unless `keep`.
+
+ The path is unique per call: several checks (and the scoreboard) run at once on this Mac,
+ and a path shared by SHA let one run delete a tree another was still using.
+ """
+ path = Path(tempfile.mkdtemp(prefix=f"hs-verify-{sha[:12]}-", dir=TMP_ROOT))
+ path.rmdir() # git creates it
+ git("worktree", "add", "--detach", "--force", str(path), sha)
+ try:
+ yield path
+ finally:
+ if not keep:
+ try:
+ git("worktree", "remove", "--force", str(path))
+ except subprocess.CalledProcessError:
+ shutil.rmtree(path, ignore_errors=True)
+ git("worktree", "prune")
diff --git a/verification/hsverify/memory.py b/verification/hsverify/memory.py
new file mode 100644
index 000000000..b277e15c5
--- /dev/null
+++ b/verification/hsverify/memory.py
@@ -0,0 +1,91 @@
+"""Peak memory of this harness process and of the child processes it waited for.
+
+The Mac is shared by several agents and one oversized process froze it once, so every report
+records these two numbers. `ru_maxrss` is in bytes on macOS and in kilobytes on Linux (CI).
+"""
+
+from __future__ import annotations
+
+import os
+import resource
+import signal
+import subprocess
+import sys
+import threading
+from collections.abc import Callable
+
+MAXRSS_UNIT = 1 if sys.platform == "darwin" else 1024
+
+
+def peak_rss_mb() -> dict[str, float]:
+ own = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * MAXRSS_UNIT
+ children = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss * MAXRSS_UNIT
+ return {"harness_mb": round(own / 2**20, 1), "largest_child_mb": round(children / 2**20, 1)}
+
+
+def tree_rss_mb(root: int) -> float:
+ """Resident memory of `root` and all its descendants, from one `ps` listing."""
+ listing = subprocess.run(
+ ["ps", "-A", "-o", "pid=,ppid=,rss="], capture_output=True, text=True, check=True
+ ).stdout
+ return sum_tree_mb(listing, root)
+
+
+def sum_tree_mb(listing: str, root: int) -> float:
+ """MB resident in `root` and its descendants, from `ps -o pid=,ppid=,rss=` output (KB)."""
+ children: dict[int, list[int]] = {}
+ rss: dict[int, int] = {}
+ for line in listing.splitlines():
+ pid, ppid, kb = (int(field) for field in line.split())
+ children.setdefault(ppid, []).append(pid)
+ rss[pid] = kb
+ total, stack = 0, [root]
+ while stack:
+ pid = stack.pop()
+ total += rss.get(pid, 0)
+ stack.extend(children.get(pid, []))
+ return total / 1024
+
+
+class TreeLimit:
+ """Kills a process group once its resident memory passes `limit_mb`.
+
+ A server under test is started in its own process group, so one signal stops `uv` and the
+ Python it started. `peak_mb` and `killed` go into the report."""
+
+ def __init__(
+ self,
+ proc: subprocess.Popen,
+ limit_mb: float,
+ interval_s: float = 0.5,
+ measure: Callable[[int], float] = tree_rss_mb,
+ ):
+ self.proc, self.limit_mb, self.interval_s = proc, limit_mb, interval_s
+ # How a process group's memory is read; tests pass a fixed reading, since how much
+ # the OS keeps resident varies with memory pressure and cannot be held steady.
+ self.measure = measure
+ self.peak_mb, self.killed = 0.0, False
+ self._stop = threading.Event()
+ self._thread = threading.Thread(target=self._watch, daemon=True)
+
+ def __enter__(self) -> TreeLimit:
+ self._thread.start()
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self._stop.set()
+ self._thread.join()
+
+ def reset(self) -> float:
+ """Restart the peak from the group's current memory, and return that."""
+ self.peak_mb = self.measure(self.proc.pid)
+ return self.peak_mb
+
+ def _watch(self) -> None:
+ while not self._stop.wait(self.interval_s) and self.proc.poll() is None:
+ used = self.measure(self.proc.pid)
+ self.peak_mb = max(self.peak_mb, used)
+ if used > self.limit_mb:
+ self.killed = True
+ os.killpg(self.proc.pid, signal.SIGKILL)
+ return
diff --git a/verification/hsverify/replaycheck.py b/verification/hsverify/replaycheck.py
new file mode 100644
index 000000000..eaf415414
--- /dev/null
+++ b/verification/hsverify/replaycheck.py
@@ -0,0 +1,266 @@
+"""Check a replay session (contract C3, Measure Lab session format v2) before anything trusts it.
+
+ uv run python -m hsverify.replaycheck ~/house-scanning-data/replays/
+
+Two parts:
+
+1. Format: the fields the app and server read are present, every listed JPEG exists with the
+ stated size, poses are rigid transforms, timestamps increase, and each `motion` keyframe
+ respects the spacing gate the session declares.
+2. Pose accuracy, when `ground_truth.json` sits beside `session.json`. The two worlds share
+ gravity but not heading or origin, so the ground truth is aligned with a rotation about
+ the vertical axis plus a translation. Reported two ways:
+ - best fit over all keyframes: the error left after the best possible alignment;
+ - anchored at the first keyframe: aligned using only the first keyframe's pose, the way
+ the app anchors everything to the meter. This is the error a result placed from the
+ start of the walk would carry, reported against distance walked (ground-truth feet).
+ The ratio of ARKit's path length to the ground truth's is reported separately, since a
+ scale disagreement dominates both errors when present.
+
+Exits non-zero when a format check fails. Accuracy numbers are reported, not judged.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import struct
+import sys
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+REQUIRED_KEYFRAME_FIELDS = ("id", "img", "w", "h", "intrinsics", "pose", "timestamp", "tracking")
+
+
+def pose_matrix(pose: list[float]) -> np.ndarray:
+ """16 numbers, column by column (simd_float4x4 layout), to a 4x4 array."""
+ return np.asarray(pose, dtype=float).reshape(4, 4).T
+
+
+def numbers(value: Any, count: int) -> list[float] | None:
+ """`value` as `count` finite numbers, or None if it is anything else."""
+ if not isinstance(value, list) or len(value) != count:
+ return None
+ if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value):
+ return None
+ return [float(v) for v in value] if all(math.isfinite(v) for v in value) else None
+
+
+def rigid_problems(m: np.ndarray, tol: float = 1e-3) -> list[str]:
+ problems = []
+ r = m[:3, :3]
+ if not np.allclose(r.T @ r, np.eye(3), atol=tol):
+ problems.append("rotation is not orthonormal")
+ if abs(np.linalg.det(r) - 1) > tol:
+ problems.append(f"rotation determinant {np.linalg.det(r):.4f}, expected 1")
+ if not np.allclose(m[3], [0, 0, 0, 1], atol=tol):
+ problems.append("bottom row is not [0, 0, 0, 1]")
+ return problems
+
+
+def rotation_angle_deg(a: np.ndarray, b: np.ndarray) -> float:
+ rel = a[:3, :3].T @ b[:3, :3]
+ cos = np.clip((np.trace(rel) - 1) / 2, -1.0, 1.0)
+ return float(np.degrees(np.arccos(cos)))
+
+
+def jpeg_size(path: Path) -> tuple[int, int]:
+ """(width, height) from a JPEG's start-of-frame marker."""
+ data = path.read_bytes()
+ if data[:2] != b"\xff\xd8":
+ raise ValueError("not a JPEG")
+ i = 2
+ while i + 9 < len(data):
+ if data[i] != 0xFF:
+ i += 1
+ continue
+ marker = data[i + 1]
+ if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
+ i += 2
+ continue
+ length = struct.unpack(">H", data[i + 2 : i + 4])[0]
+ if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
+ height, width = struct.unpack(">HH", data[i + 5 : i + 9])
+ return width, height
+ i += 2 + length
+ raise ValueError("no start-of-frame marker")
+
+
+def check_format(folder: Path, session: dict) -> tuple[list[str], dict]:
+ errors: list[str] = []
+ if session.get("format") != "measure-lab-session" or session.get("formatVersion") != 2:
+ errors.append(
+ f"format {session.get('format')!r} version {session.get('formatVersion')!r}, "
+ "expected 'measure-lab-session' version 2"
+ )
+ for key in ("session", "gates", "keyframes"):
+ if key not in session:
+ errors.append(f"missing top-level field {key!r}")
+ keyframes = session.get("keyframes", [])
+ gates = session.get("gates", {})
+ spacing_m = gates.get("keyframeSpacingMeters")
+ spacing_deg = gates.get("keyframeSpacingDegrees")
+ if spacing_m and spacing_deg is None:
+ errors.append("gates set keyframeSpacingMeters without keyframeSpacingDegrees")
+ spacing_m = None
+ previous = None
+ previous_motion = None
+ gate_violations = 0
+ tracking_counts: dict[str, int] = {}
+ for kf in keyframes:
+ name = kf.get("id", "?")
+ missing = [f for f in REQUIRED_KEYFRAME_FIELDS if f not in kf]
+ if missing:
+ errors.append(f"{name}: missing {', '.join(missing)}")
+ continue
+ tracking_counts[kf["tracking"]] = tracking_counts.get(kf["tracking"], 0) + 1
+ image = folder / kf["img"]
+ if not image.exists():
+ errors.append(f"{name}: image {kf['img']} not found")
+ else:
+ try:
+ size = jpeg_size(image)
+ if size != (kf["w"], kf["h"]):
+ errors.append(f"{name}: JPEG is {size}, session says {(kf['w'], kf['h'])}")
+ except ValueError as exc:
+ errors.append(f"{name}: {exc}")
+ intrinsics, pose = numbers(kf["intrinsics"], 4), numbers(kf["pose"], 16)
+ if intrinsics is None or pose is None:
+ errors.append(f"{name}: intrinsics must be 4 finite numbers and pose 16")
+ continue
+ fx, fy, cx, cy = intrinsics
+ if not (fx > 0 and fy > 0 and 0 < cx < kf["w"] and 0 < cy < kf["h"]):
+ errors.append(f"{name}: intrinsics {kf['intrinsics']} outside the image")
+ m = pose_matrix(pose)
+ errors += [f"{name}: {p}" for p in rigid_problems(m)]
+ if previous is not None and kf["timestamp"] <= previous["timestamp"]:
+ errors.append(f"{name}: timestamp does not increase")
+ if kf.get("reason") == "motion" and previous_motion is not None and spacing_m:
+ moved = float(np.linalg.norm(m[:3, 3] - previous_motion[:3, 3]))
+ turned = rotation_angle_deg(previous_motion, m)
+ if moved < spacing_m * 0.99 and turned < spacing_deg * 0.99:
+ gate_violations += 1
+ if kf.get("reason") == "motion":
+ previous_motion = m
+ previous = kf
+ if gate_violations:
+ errors.append(f"{gate_violations} motion keyframes closer than the declared spacing gate")
+ summary = {
+ "keyframes": len(keyframes),
+ "tracking": tracking_counts,
+ "taps": len(session.get("taps", [])),
+ "walls": len(session.get("walls", [])),
+ }
+ return errors, summary
+
+
+def yaw_translation_fit(src: np.ndarray, dst: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ """Rotation about +y and translation that best map src points onto dst (least squares)."""
+ sc, dc = src.mean(axis=0), dst.mean(axis=0)
+ a, b = src - sc, dst - dc
+ # Rotation about y: x' = c x + s z, z' = -s x + c z. Maximise sum b . R a.
+ num = np.sum(a[:, 0] * b[:, 2] - a[:, 2] * b[:, 0])
+ den = np.sum(a[:, 0] * b[:, 0] + a[:, 2] * b[:, 2])
+ theta = np.arctan2(-num, den)
+ c, s = np.cos(theta), np.sin(theta)
+ r = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
+ return r, dc - r @ sc
+
+
+def yaw_of(r: np.ndarray) -> np.ndarray:
+ """The rotation about +y closest to `r`, from where it sends the camera's forward axis."""
+ forward = r @ np.array([0.0, 0.0, -1.0])
+ theta = np.arctan2(-forward[0], -forward[2])
+ c, s = np.cos(theta), np.sin(theta)
+ return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
+
+
+def accuracy(session: dict, truth: dict) -> dict:
+ by_id = {entry["keyframe"]: entry for entry in truth["keyframes"]}
+ ar, gt = [], []
+ for kf in session["keyframes"]:
+ if kf["id"] in by_id:
+ ar.append(pose_matrix(kf["pose"]))
+ gt.append(pose_matrix(by_id[kf["id"]]["pose"]))
+ if len(ar) < 2:
+ return {"matched_keyframes": len(ar)}
+ ar_pos = np.array([m[:3, 3] for m in ar])
+ gt_pos = np.array([m[:3, 3] for m in gt])
+
+ # Both worlds claim gravity alignment: each camera axis should have the same vertical part.
+ tilt = [float(np.max(np.abs(a[1, :3] - g[1, :3]))) for a, g in zip(ar, gt, strict=True)]
+
+ r, t = yaw_translation_fit(gt_pos, ar_pos)
+ fit_err = np.linalg.norm((gt_pos @ r.T + t) - ar_pos, axis=1)
+
+ # Anchor at the first keyframe: heading from its orientations, origin from its position.
+ r0 = yaw_of(ar[0][:3, :3]) @ yaw_of(gt[0][:3, :3]).T
+ t0 = ar_pos[0] - r0 @ gt_pos[0]
+ anchored = np.linalg.norm((gt_pos @ r0.T + t0) - ar_pos, axis=1)
+ walked = np.concatenate([[0.0], np.cumsum(np.linalg.norm(np.diff(gt_pos, axis=0), axis=1))])
+
+ def at(distance_m: float) -> float | None:
+ idx = np.searchsorted(walked, distance_m)
+ return None if idx >= len(walked) else round(float(anchored[idx]), 3)
+
+ ar_walked = float(np.sum(np.linalg.norm(np.diff(ar_pos, axis=0), axis=1)))
+ # Same fit after scaling the ground truth to ARKit's path length: what remains is shape
+ # and heading drift, with the scale disagreement taken out.
+ scale = ar_walked / walked[-1]
+ rs, ts = yaw_translation_fit(gt_pos * scale, ar_pos)
+ scaled_err = np.linalg.norm((gt_pos * scale @ rs.T + ts) - ar_pos, axis=1)
+
+ ft = 0.3048
+ return {
+ "matched_keyframes": len(ar),
+ "path_length_m": round(float(walked[-1]), 2),
+ "arkit_path_length_m": round(ar_walked, 2),
+ "arkit_to_truth_length_ratio": round(float(scale), 3),
+ "max_vertical_axis_mismatch": round(max(tilt), 4),
+ "best_fit_error_m": {
+ "median": round(float(np.median(fit_err)), 3),
+ "p90": round(float(np.percentile(fit_err, 90)), 3),
+ "max": round(float(fit_err.max()), 3),
+ },
+ "best_fit_error_after_scale_m": {
+ "median": round(float(np.median(scaled_err)), 3),
+ "p90": round(float(np.percentile(scaled_err, 90)), 3),
+ },
+ "anchored_error_m_after_ft_walked": {
+ f"{d}ft": at(d * ft) for d in (3, 10, 20, 30, 60, 100)
+ },
+ "anchored_error_m_final": round(float(anchored[-1]), 3),
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ parser.add_argument("folder", type=Path, help="session folder containing session.json")
+ parser.add_argument("--json", action="store_true", help="print the report as JSON")
+ args = parser.parse_args(argv)
+ folder = args.folder.expanduser()
+ session = json.loads((folder / "session.json").read_text())
+ errors, summary = check_format(folder, session)
+ report: dict = {"session": str(folder), "format_errors": errors, "summary": summary}
+ truth_path = folder / "ground_truth.json"
+ if truth_path.exists():
+ report["pose_accuracy"] = accuracy(session, json.loads(truth_path.read_text()))
+ if args.json:
+ print(json.dumps(report, indent=2))
+ else:
+ print(f"{folder.name}: {summary}")
+ print("Format: " + ("ok" if not errors else f"{len(errors)} problems"))
+ for e in errors[:30]:
+ print(f" - {e}")
+ if "pose_accuracy" in report:
+ print("Pose accuracy against ground truth:")
+ for k, v in report["pose_accuracy"].items():
+ print(f" {k}: {v}")
+ return 1 if errors else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/verification/hsverify/report.py b/verification/hsverify/report.py
new file mode 100644
index 000000000..fa3611fcb
--- /dev/null
+++ b/verification/hsverify/report.py
@@ -0,0 +1,113 @@
+"""Static HTML and Markdown pages for run folders. No external assets, so a folder opens offline."""
+
+from __future__ import annotations
+
+import html
+from pathlib import Path
+
+_CSS = """
+:root { color-scheme: light dark; --fg: #1d1d1f; --muted: #6e6e73; --line: #d2d2d7;
+ --bad: #b3261e; --warn: #8a5a00; --ok: #1b6e3a; --card: #f5f5f7; }
+@media (prefers-color-scheme: dark) { :root { --fg: #f5f5f7; --muted: #a1a1a6;
+ --line: #3a3a3c; --card: #1c1c1e; --bad: #ff8a80; --warn: #ffcc66; --ok: #7ee2a0; } }
+body { font: 15px/1.45 -apple-system, BlinkMacSystemFont, sans-serif; color: var(--fg);
+ margin: 32px auto; max-width: 1180px; padding: 0 20px; }
+h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 28px 0 8px; }
+.meta { color: var(--muted); font-size: 13px; }
+code { font: 12.5px ui-monospace, SFMono-Regular, monospace; }
+.problems li { color: var(--bad); }
+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 18px; }
+figure { margin: 0; }
+figure img { width: 100%; border-radius: 18px; border: 1px solid var(--line); }
+figcaption { font-size: 13px; margin-top: 6px; } figcaption b { font-weight: 600; }
+.tag { font-size: 11px; padding: 1px 6px; border-radius: 6px; background: var(--card);
+ color: var(--warn); margin-left: 4px; }
+table { border-collapse: collapse; font-size: 13px; } td, th { text-align: left;
+ padding: 3px 12px 3px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
+.ok { color: var(--ok); } .no { color: var(--bad); }
+"""
+
+
+def _esc(value: object) -> str:
+ return html.escape(str(value))
+
+
+def _yes(value: bool) -> str:
+ return 'yes' if value else 'no'
+
+
+def write_sim_report(out: Path, data: dict) -> None:
+ build = data["build"]
+ states = data["states"]
+ parts = [
+ "",
+ f"Simulator run {_esc(data['ref'])} {_esc(data['sha'][:8])}",
+ f"",
+ f"
Simulator run: {_esc(data['ref'])} at "
+ f"{_esc(data['sha'][:12])}
",
+ f"
{_esc(data['started_at'])} · {_esc(data['device'].get('runtime', ''))}"
+ f" · {_esc(data['device'].get('appearance', ''))}"
+ f" · text size {_esc(data['device'].get('content_size') or 'default')}"
+ f" · launch args {_esc(' '.join(data['launch_arguments']) or '(none)')}
",
+ f"
{len(states)} states. Ended: {_esc(data['end_reason'])}. Build "
+ f"{'passed' if build.get('ok') else 'failed'} in {_esc(build.get('seconds', '?'))} s with "
+ f"{len(build.get('warnings', []))} warnings.
",
+ ]
+ if data["problems"]:
+ parts.append("
Problems
")
+ parts += [f"
{_esc(p)}
" for p in data["problems"]]
+ parts.append("
")
+ parts.append("
States
")
+ for s in states:
+ tag = (
+ 'transient'
+ if s["transient"]
+ else ""
+ )
+ parts.append(
+ f""
+ f"{s['index']}. {_esc(s['state'])}{tag} "
+ f"+{s['seconds_after_launch']} s"
+ )
+ if data.get("final_screenshot"):
+ parts.append(
+ f""
+ "Final screen"
+ )
+ parts.append("
Contract C4 in source
")
+ parts += [
+ f"
{_esc(k)}
{_yes(v)}
" for k, v in data["static_c4"].items()
+ ]
+ parts.append("
")
+ if build.get("warnings") or build.get("errors"):
+ parts.append("
Build diagnostics
")
+ parts += [
+ f"
{_esc(x)}
"
+ for x in build.get("errors", []) + build.get("warnings", [])
+ ]
+ parts.append("