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"
  • {html.escape(i['description'])} {html.escape(i.get('element', ''))}" + f"
    {html.escape(i.get('detail', ''))}
  • " + for i in issues + ) + "".join( + f"
  • Button label is a symbol name {html.escape(s['label'])}
  • " + for s in symbols + ) + texts = [lab.split(":", 1)[1] for lab in screen.get("labels", []) if lab.split(":", 1)[1]] + rows.append( + f"
    " + f"

    Screen {screen['index']} +{screen.get('seconds', 0):.1f} s" + f"

    {html.escape(' · '.join(texts[:8]))}

    " + f"
      {items or '
    • No issues
    • '}
    " + ) + verdict = "".join(f"

    Not valid: {html.escape(p)}

    " for p in data["problems"]) + page = ( + "Accessibility audit" + f"

    Accessibility audit: {html.escape(data['ref'])} at " + f"{data['sha'][:12]}

    {verdict}

    {data['screens_audited']} screens, " + f"{data['issue_count']} issues. Text size {html.escape(str(data['content_size']))}, " + f"{html.escape(data['appearance'])}.

    " + "".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"
    {_esc(s[" + f"
    {s['index']}. {_esc(s['state'])}{tag}
    " + f"+{s['seconds_after_launch']} s
    " + ) + if data.get("final_screenshot"): + parts.append( + f"
    final screen" + "
    Final screen
    " + ) + parts.append("

    Contract C4 in source

    ") + parts += [ + f"" for k, v in data["static_c4"].items() + ] + parts.append("
    {_esc(k)}{_yes(v)}
    ") + 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("
    ") + parts.append( + "

    Files: report.json · " + "build.log · state.ndjson · " + "app-stdout.log · app-stderr.log" + "

    " + ) + (out / "index.html").write_text("\n".join(parts) + "\n") + + lines = [ + f"# Simulator run: `{data['ref']}` at `{data['sha'][:12]}`", + "", + f"- Build: {'passed' if build.get('ok') else 'failed'}, {build.get('seconds', '?')} s, " + f"{len(build.get('warnings', []))} warnings", + f"- Launch arguments: `{' '.join(data['launch_arguments']) or '(none)'}`", + f"- States: {len(states)}; ended: {data['end_reason']}", + ] + lines += [f"- Problem: {p}" for p in data["problems"]] + lines += ["", "| # | State | +s | Screenshot |", "| --- | --- | --- | --- |"] + lines += [ + f"| {s['index']} | {s['state']}{' (transient)' if s['transient'] else ''} | " + f"{s['seconds_after_launch']} | {s['screenshot']} |" + for s in states + ] + (out / "report.md").write_text("\n".join(lines) + "\n") diff --git a/verification/hsverify/resultcheck.py b/verification/hsverify/resultcheck.py new file mode 100644 index 000000000..bb276281a --- /dev/null +++ b/verification/hsverify/resultcheck.py @@ -0,0 +1,1440 @@ +"""Judge a placement result (contract C2) against its scene (C1) and the decision rule (C5). + +Everything here is pure: a scene and a result go in, a list of problems comes out. The +checks hold for any policy and any rule values, so they can run on every response: + +- decision consistency: what `pass`, `reject` and `manual_review` each require of the rest + of the record; +- the margin rule: a check's outcome must follow from its measurement, error and threshold + (PASS only when the margin beats the error, and clears the review line when the check has + one; FAIL only when past the threshold by more than the error; otherwise UNSURE); +- coverage: no battery position passes without the coverage each passing check needs, as the + server's README ("What settles each check") states it: the wall seen high enough over the + clearance's reach, the ground out to the battery's depth plus the reach, the facing and + overhead bands unless measured, and the cable route. The reach includes the battery's + position error, which depends on how the wall was found. Only points within reach whatever + the wall's shape are required, so a correct server is not flagged; +- requests: every check left unsure for coverage is named for each band it lacks, and nothing + is requested that was already seen as far as the request needs; +- bookkeeping: counts add up, the input hash matches what was sent, the spot's offset from + the meter matches its centre. + +Case expectations (a scene plus the outcomes geometry forces) and the mirror transform used +for the left-right symmetry check also live here. +""" + +from __future__ import annotations + +import copy +import hashlib +import itertools +import math +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import yaml +from jsonschema import Draft202012Validator + +EPS = 1e-6 +NUMBER_TOL_FT = 1e-5 +# Spot centres and offsets are rounded by the server; a hundredth of a foot is 1/8 inch. +OFFSET_TOL_FT = 0.01 + + +@dataclass(frozen=True) +class Need: + """One piece of coverage a check needs around a battery over [lo, hi] (server/README.md at the + tested ref, "What settles each check"): + + - `ground`: ground over [lo - r, hi + r], out to depth + r in front of the battery; + - `wall`: the wall band over [lo - r, hi + r], seen higher than `height`; + - `facing`, `overhead`: the band over [lo - r, hi + r]; where no measurement (`facing`, + `overheads`) covers a stretch, the band's `out_ft` there must be beyond `height`. + + r is `radius`, plus the battery's position error when `widen` (battery_error: the server's + checks see the wall's error at the battery's far edge, server/solver.py at 903d86f). + """ + + band: str + radius: float = 0.0 + height: float = 0.0 + widen: bool = True + # Widened by the battery's error along the walls only (along_error), not in plan: spans + # measured in s move with the meter as the battery does (server 3baa338, along_err). + along: bool = False + + +# Scene lists whose entries settle facing_gap and headroom where they cover the battery. +MEASUREMENTS = {"facing": "facing", "overhead": "overheads"} +# rules.yaml error keys for a wall's `source`; a wall without one is tapped. +WALL_ERROR = {"tap": "wall", "mesh": "mesh", "plane": "plane"} + + +@dataclass(frozen=True) +class RuleSet: + """What the checks here need from the server's rules.yaml at the tested ref.""" + + width_ft: float + depth_ft: float + # check id -> the coverage its pass needs + needs: dict[str, tuple[Need, ...]] + # default error by object source, for walls (tap, mesh, plane) and the meter, and drift_per_ft + errors: dict[str, float] + # the height up the wall the cable route must be seen, from the meter to the battery + route_height_ft: float = 0.0 + # the rules' sweep.step_ft: the spacing at which a passing sweep run's starts are sampled + step_ft: float | None = None + # the rules' sweep.wall_join_ft: the most two walls' ends may be apart and still meet + wall_join_ft: float | None = None + # the rules' battery.height_ft: a wall that declares height_ft must be taller + battery_height_ft: float | None = None + # the rules' headroom.min_ft: how high a wall view without out_ft reached + headroom_ft: float | None = None + + @classmethod + def from_yaml(cls, text: str) -> RuleSet: + data = yaml.safe_load(text) + + def value(*path: str, default: float | None = None) -> float: + node: Any = data + for key in path: + if not isinstance(node, dict) or key not in node: + if default is not None: + return default + raise ValueError(f"rules.yaml has no {'.'.join(path)}") + node = node[key] + if node is None and default is not None: + return default + return float(node["value"] if isinstance(node, dict) else node) + + depth = value("battery", "depth_ft") + headroom = value("headroom", "min_ft") + # Openings need the wall seen to headroom height, or to a lower exempt height the rules set. + opening_height = min( + headroom, value("openings", "exempt_bottom_above_ft", default=headroom) + ) + + def r(key: str) -> float: + return value("clearances", key) + + def reach(key: str) -> Need: + return Need("ground", r(key)) + + needs = { + "ground_surface": (Need("ground", 0.0),), + "wall_backing": (Need("wall", 0.0, value("battery", "height_ft")),), + "gas_clearance": (reach("gas_ft"), Need("wall", r("gas_ft"), headroom)), + "battery_clearance": (reach("battery_ft"), Need("wall", r("battery_ft"), headroom)), + "ac_clearance": (reach("ac_ft"),), + "drive_clearance": (reach("drive_ft"),), + "pool_clearance": (reach("pool_ft"),), + "opening_clearance": (Need("wall", r("opening_ft"), opening_height),), + # Widened by the battery's error from 3baa338 (server README, wall_equipment_above). + "wall_equipment_above": (Need("wall", r("wall_equipment_ft"), headroom, along=True),), + "facing_gap": (Need("facing", 0.0, depth + value("facing", "min_ft"), along=True),), + "headroom": (Need("overhead", 0.0, headroom, along=True),), + } + errors = { + name: value("errors", f"{name}_ft") + for name in ("tap", "vlm", "tape", "wall", "mesh", "plane", "meter") + } + errors["drift_per_ft"] = value("errors", "drift_per_ft") + return cls( + value("battery", "width_ft"), + depth, + needs, + errors, + value("route", "height_ft"), + value("sweep", "step_ft"), + value("sweep", "wall_join_ft"), + value("battery", "height_ft"), + headroom, + ) + + +def schema_errors(instance: Any, schema: dict) -> list[str]: + validator = Draft202012Validator(schema) + errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path)) + return [f"{'/'.join(map(str, e.absolute_path)) or '(root)'}: {e.message}" for e in errors] + + +# --- Coverage -------------------------------------------------------------------------------- + + +def reached(entry: dict) -> float: + """How far a facing, overhead or ground entry's view reached: its `out_ft`, or without one, + none for ground and all the way for facing (to whatever faces the wall) and overhead (clear + to the sky). A wall entry without one is handled in `observed`.""" + default = 0.0 if entry["band"] == "ground" else math.inf + return entry.get("out_ft", default) + + +# The server's float tolerance (server/scene.py EPS), for comparisons that must agree with it +# exactly: where walls meet, whether a declared wall is taller, and the wall view default. +SERVER_EPS = 1e-9 + + +def observed( + scene: dict, + band: str, + min_out_ft: float = 0.0, + beyond: bool = False, + wall_default_ft: float | None = None, +) -> list[tuple[float, float]]: + """Merged observed s-intervals for a band, counting only entries whose view reached + `min_out_ft` (out from the wall for ground and facing, up for wall and overhead). With + `beyond`, the view must pass it strictly, as the server requires of a wall seen "higher + than" a height (server/scene.py at 6c7ca23). + + A wall entry without `out_ft` reached headroom height (`wall_default_ft`, the rules' + headroom.min_ft) and settles any height up to it inclusively, `beyond` or not; S2 chose + this category default at 6200098 (server/scene.py `observed_intervals`). None keeps the + old reading, all the way up.""" + spans = [] + for entry in scene.get("coverage", {}).get("observed", []): + if entry["band"] != band: + continue + if band == "wall" and "out_ft" not in entry and wall_default_ft is not None: + if min_out_ft > wall_default_ft + SERVER_EPS: + continue + spans.append(tuple(sorted(entry["span_ft"]))) + continue + far = reached(entry) + # Without `beyond` the view must reach min_out_ft itself: a request's out_ft is set just + # past a rule's line (server _above, 1e-6 over it), so any looser slack than the + # server's own would count a view at the line as reaching past it. + if (far <= min_out_ft) if beyond else (far + SERVER_EPS < min_out_ft): + continue + spans.append(tuple(sorted(entry["span_ft"]))) + spans.sort() + merged: list[list[float]] = [] + for a, b in spans: + if merged and a <= merged[-1][1] + EPS: + merged[-1][1] = max(merged[-1][1], b) + else: + merged.append([a, b]) + return [(a, b) for a, b in merged] + + +def covers(intervals: list[tuple[float, float]], a: float, b: float, slack: float = 0.0) -> bool: + return any(lo - slack <= a + EPS and b - EPS <= hi + slack for lo, hi in intervals) + + +# A gap in a 1D band narrower than this is rounding between the capture's spans and the unrolled +# walls, not an unseen stretch (server/scene.py COVERAGE_TOLERANCE_FT and missing() at 3baa338). +COVERAGE_TOLERANCE_FT = 0.01 + + +def gaps_over( + intervals: list[tuple[float, float]], a: float, b: float +) -> list[tuple[float, float]]: + """The parts of [a, b] no interval covers, as the server's missing() finds them: gaps + narrower than COVERAGE_TOLERANCE_FT don't count.""" + gaps, at = [], a + for lo, hi in sorted(intervals): + if hi <= at: + continue + if lo > at: + gaps.append((at, min(lo, b))) + at = max(at, hi) + if at >= b: + break + if at < b: + gaps.append((at, b)) + return [(x, y) for x, y in gaps if y - x >= COVERAGE_TOLERANCE_FT] + + +# --- Invariants ------------------------------------------------------------------------------ + + +def margin_problem(check: dict) -> str | None: + """The C5 rule for one check, when its numbers are all present. + + PASS needs a clear pass and FAIL a clear fail. A check with a review band + (`review_threshold_ft`) passes only when it also clears that line. UNSURE for another cause + (unobserved area, unknown attribute, review rule) may sit on numbers that would pass, never + on numbers that clearly fail. UNSURE labelled `margin` must lie within its error of a line: + C2 defines that cause as "measured inside the error band". + """ + m, e, t, cmp = ( + check.get(k) for k in ("measured_ft", "plus_minus_ft", "threshold_ft", "comparison") + ) + if m is None or e is None or t is None or cmp is None: + return None + review = check.get("review_threshold_ft") + lines = [t] if review is None else [t, review] + if cmp == "at_least": + clear_pass = all(m - e > x + EPS for x in lines) + clear_fail = m + e < t - EPS + else: + clear_pass = all(m + e < x - EPS for x in lines) + clear_fail = m - e > t + EPS + expected = "pass" if clear_pass else "fail" if clear_fail else "unsure" + outcome, cause = check["outcome"], check.get("unsure_cause") + band = f" ({cmp} {t}" + (f", review {review})" if review is not None else ")") + if outcome == "unsure" and cause == "margin" and expected == "unsure": + if not any(abs(m - x) <= e + EPS for x in lines): + return ( + f"check {check['id']}: unsure_cause margin, but {m} ± {e} is not within its " + f"error of any line{band}; a review band is rule_requires_review" + ) + return None + other_cause = outcome == "unsure" and cause not in (None, "margin") + if outcome == expected or (other_cause and not clear_fail): + return None + # The EPS above allows for float noise; a PASS may also clear its line by less. The server + # rounds each number to 6 decimals and rule lines sit on that grid, and rounding keeps order: + # a true margin of zero or less can't come out positive. So any positive reported margin + # proves the pass (3baa338 places spots at the edge of a pass, a margin of 1e-6). + if outcome == "pass" and expected == "unsure": + if cmp == "at_least": + proven = all(m - e - x > SERVER_EPS for x in lines) + else: + proven = all(x - (m + e) > SERVER_EPS for x in lines) + if proven: + return None + because = f" ({cause})" if cause else "" + return ( + f"check {check['id']}: measured {m} ± {e}{band} should be {expected}, " + f"server says {outcome}{because}" + ) + + +def invariant_problems( + scene: dict, result: dict, sent: bytes | None = None, rules: RuleSet | None = None +) -> list[str]: + problems: list[str] = [] + decision = result["decision"] + spot = result.get("spot") + checks = result.get("checks", []) + + if decision == "pass": + if spot is None or spot.get("outcome") != "pass": + problems.append("decision pass without a passing spot") + if not checks: + problems.append("decision pass with no checks") + not_passing = [c["id"] for c in checks if c["outcome"] != "pass"] + if not_passing: + problems.append(f"decision pass with checks not passing: {not_passing}") + if result.get("missing_evidence"): + problems.append("decision pass while asking for more evidence") + if not result["policy"].get("auto_approve"): + problems.append("decision pass although policy.auto_approve is false") + if decision == "reject": + if spot is not None: + problems.append("decision reject but a spot is given") + # A reject says no start can pass or be settled by a person: every start fails. + for outcome in ("pass", "unsure"): + runs = [r["start_ft"] for r in result.get("sweep", []) if r["outcome"] == outcome] + if runs: + problems.append(f"decision reject but the sweep has {outcome} starts {runs}") + if result.get("missing_evidence"): + problems.append("decision reject while asking for more evidence") + if decision == "manual_review" and not result.get("reasons"): + problems.append("decision manual_review without reasons") + # A person reviews a spot that may pass; one that fails is a reject's, not theirs. + if decision == "manual_review" and spot is not None and spot["outcome"] == "fail": + problems.append("decision manual_review with a failing spot") + # A spot's outcome is its checks': a pass has every check passing, an unsure spot none failing. + if spot is not None and spot["outcome"] in ("pass", "unsure"): + for c in checks: + if c["outcome"] == "fail" or (spot["outcome"] == "pass" and c["outcome"] != "pass"): + verb = "fails" if c["outcome"] == "fail" else f"is {c['outcome']}" + problems.append(f"the spot is {spot['outcome']} but its check {c['id']} {verb}") + if (spot is None) != (result.get("route") is None): + problems.append("spot and route must be both null or both present") + + for check in checks: + problem = margin_problem(check) + if problem: + problems.append(problem) + cause = check.get("unsure_cause") + if check["outcome"] == "unsure" and cause is None: + problems.append(f"check {check['id']}: unsure without unsure_cause") + if check["outcome"] != "unsure" and cause is not None: + problems.append(f"check {check['id']}: unsure_cause on a {check['outcome']} check") + + stats = result["stats"] + if stats["pass"] + stats["unsure"] + stats["fail"] != stats["candidates"]: + problems.append( + f"stats: pass {stats['pass']} + unsure {stats['unsure']} + fail {stats['fail']} " + f"!= candidates {stats['candidates']}" + ) + if sent is not None and stats["input_sha256"] != hashlib.sha256(sent).hexdigest(): + problems.append("stats.input_sha256 does not match the scene bytes sent") + + if spot is not None: + mx, _, mz = scene["meter"]["pos"] + want = [spot["center"][0] - mx, spot["center"][1] - mz] + got = spot["meter_offset_ft"] + if max(abs(want[0] - got[0]), abs(want[1] - got[1])) > OFFSET_TOL_FT: + problems.append(f"spot.meter_offset_ft {got} != centre - meter {want}") + + problems += spot_sweep_problems(result) + if rules is not None: + problems += footprint_problems(scene, result, rules) + problems += spot_position_problems(scene, result, rules) + problems += missing_evidence_problems(scene, result, rules) + if rules is not None: + problems += coverage_problems(scene, result, rules) + problems += declared_height_problems(scene, result, rules) + problems += dropped_check_problems(scene, result, rules) + return problems + + +# Seen ground grows by this in every direction before it is compared, closing gaps under the +# 0.01 ft coverage tolerance (server/scene.py SEEN_GROWTH_FT at 3baa338). +SEEN_GROWTH_FT = 0.005 - 1e-4 + +# Walls and baseline points within this of one straight line are one straight stretch +# (server/scene.py COLLINEAR_FT and _join_straight_walls at a726199). Also the slack on a +# footprint's ends, since s here and the server's s can differ by a merged point's offset. +COLLINEAR_FT = 0.05 + + +def straight_pieces(scene: dict, rules: RuleSet) -> list[tuple[float, float]]: + """The s-extent of each straight stretch of scanned wall a battery can back onto: baseline + segments joined where the next one starts at the previous one's end and continues its line, + within a wall or across two walls that meet.""" + pieces: list[list] = [] # [s0, s1, start point, end point] + for _, s0, s1, a, b in segments_s(scene, rules) or []: + if pieces: + prev = pieces[-1] + length = math.dist(prev[2], prev[3]) + if length > EPS and math.dist(prev[3], a) <= COLLINEAR_FT: + ux, uz = (prev[3][0] - prev[2][0]) / length, (prev[3][1] - prev[2][1]) / length + off = abs((b[0] - prev[2][0]) * uz - (b[1] - prev[2][1]) * ux) + ahead = (b[0] - prev[3][0]) * ux + (b[1] - prev[3][1]) * uz > 0 + if off <= COLLINEAR_FT and ahead and abs(s0 - prev[1]) <= COLLINEAR_FT: + prev[1], prev[3] = s1, b + continue + pieces.append([s0, s1, a, b]) + return [(p[0], p[1]) for p in pieces] + + +def footprint_problems(scene: dict, result: dict, rules: RuleSet) -> list[str]: + """A battery that passes or is left to a person backs onto one straight stretch of scanned + wall along its whole width; a footprint past the stretch's end (a corner, the end of the + wall, a gap) can't sit flush, so the server fails it (server/solver.py check_backing).""" + pieces = straight_pieces(scene, rules) + if not pieces: + return [] + + def off_wall(lo: float, hi: float, outcome: str, wall_id: str) -> str | None: + # The stretch it starts on, or else ends on: the one it would back onto. A start on a + # boundary belongs to the stretch it runs onto, an end to the one it runs off. + piece = next((p for p in pieces if p[0] - EPS <= lo < p[1] - EPS), None) or next( + (p for p in pieces if p[0] + EPS < hi <= p[1] + EPS), None + ) + if piece is None: + return "is not on any scanned wall" + if lo < piece[0] - COLLINEAR_FT or hi > piece[1] + COLLINEAR_FT: + return f"runs past its straight wall [{piece[0]:.2f}, {piece[1]:.2f}]" + if outcome != "pass": + return None + # A pass also keeps its position error clear of the stretch's ends: within it the + # battery may not sit flush, and the server leaves it unsure (check_backing). + e = battery_error(scene, rules, wall_id, lo, hi) + for end, clear in ((piece[0], lo - e - piece[0]), (piece[1], piece[1] - hi - e)): + if clear < -COLLINEAR_FT: + return f"ends within its error ({e:.2f} ft) of its straight wall's end at {end:.2f}" + return None + + problems = [] + spot = result.get("spot") + if spot is not None and spot["outcome"] in ("pass", "unsure"): + lo, hi = spot["span_ft"] + if (why := off_wall(lo, hi, spot["outcome"], spot["wall_id"])) is not None: + problems.append(f"spot [{lo:.2f}, {hi:.2f}] {why}") + for run in result.get("sweep", []): + if run["outcome"] not in ("pass", "unsure"): + continue + for start in dict.fromkeys(run["start_ft"]): # a run's two ends bound all its starts + battery = (start, start + rules.width_ft) + if (why := off_wall(*battery, run["outcome"], run["wall_id"])) is not None: + problems.append(f"sweep {run['outcome']} start {start:.2f} {why}") + return problems + + +def spot_position_problems(scene: dict, result: dict, rules: RuleSet) -> list[str]: + """Where the app places the battery must be where the result says it stands: spot.center is + its footprint's centre, and that is the middle of span_ft on the wall, half the battery's + depth out. meter_offset_ft is checked against center elsewhere, so a centre moved together + with its offset is caught here.""" + spot = result.get("spot") + if spot is None: + return [] + problems = [] + cx, cz = spot["center"] + corners = spot.get("footprint") or [] + if corners: + fx = sum(pt[0] for pt in corners) / len(corners) + fz = sum(pt[1] for pt in corners) / len(corners) + if math.dist((cx, cz), (fx, fz)) > OFFSET_TOL_FT: + problems.append( + f"spot.center [{cx:.2f}, {cz:.2f}] is not its footprint's centre " + f"[{fx:.2f}, {fz:.2f}]" + ) + lo, hi = spot["span_ft"] + plan = GroundPlan(scene, rules) + want = plan.point((lo + hi) / 2, (lo + hi) / 2, rules.depth_ft / 2) + if want is not None and math.dist((cx, cz), want) > COLLINEAR_FT: + problems.append( + f"spot.center [{cx:.2f}, {cz:.2f}] is not the centre of span [{lo:.2f}, {hi:.2f}] " + f"on its wall, [{want[0]:.2f}, {want[1]:.2f}]" + ) + return problems + + +def spot_sweep_problems(result: dict) -> list[str]: + """The chosen spot is one of the evaluated starts, so the sweep must give its start the + spot's own outcome, and a passing spot must lie in a passing run. A result could otherwise + report a pass at a start its own sweep says fails, with stats that agree with the sweep.""" + spot = result.get("spot") + if spot is None: + return [] + start = spot["span_ft"][0] + got = outcome_at(result, spot["wall_id"], start) + if got == spot["outcome"]: + return [] + verb = {"pass": "passes", "fail": "fails"}.get(spot["outcome"], "is unsure") + where = f"start {start:.2f} on {spot['wall_id']}" + if got is None: + return [f"spot {verb} but no sweep run has {where}"] + return [f"spot {verb} but the sweep has {where} as {got}"] + + +def required_span(lo: float, hi: float, radius: float) -> tuple[float, float]: + """Along-wall stretch within `radius` of a battery covering [lo, hi]. + + Along the wall chain, distance is never shorter than straight-line distance, so every point + in this stretch lies within the radius even where the chain bends: observing it is necessary + (not sufficient) for a PASS, and demanding it cannot wrongly flag a correct server. + """ + return lo - radius, hi + radius + + +def battery_error(scene: dict, rules: RuleSet, wall_id: str, lo: float, hi: float) -> float: + """Position error of a battery over [lo, hi] on `wall_id`, as server 3baa338 takes it for + its checks (solver.py _errors): its wall's error at its far edge from the meter, plus the + larger of the meter's error in plan and the battery's slide along the walls (slide).""" + wall = next((w for w in scene["walls"] if w["id"] == wall_id), None) + if wall is None: + raise ValueError(f"the result names wall {wall_id!r}, which the scene does not have") + return wall_error_at(wall, rules, max(abs(lo), abs(hi))) + max( + meter_error(scene, rules), slide(scene, rules, lo, hi) + ) + + +def along_error(scene: dict, rules: RuleSet, wall_id: str, lo: float, hi: float) -> float: + """The battery's error against spans measured along the walls: its wall's at its far edge + plus its slide, without the meter's plan error (server 3baa338 _errors, along_err).""" + wall = next(w for w in scene["walls"] if w["id"] == wall_id) + return wall_error_at(wall, rules, max(abs(lo), abs(hi))) + slide(scene, rules, lo, hi) + + +def wall_error_at(wall: dict, rules: RuleSet, s: float) -> float: + """A wall's error at distance |s| along the walls from the meter: its explicit one, or its + source's default plus drift.""" + if "plus_minus_ft" in wall: + return wall["plus_minus_ft"] + return wall_error(wall, rules) + rules.errors["drift_per_ft"] * abs(s) + + +def meter_error(scene: dict, rules: RuleSet) -> float: + """The meter's position error: its explicit one or rules.yaml's errors.meter_ft (no drift).""" + return scene.get("meter", {}).get("plus_minus_ft", rules.errors["meter"]) + + +def slide(scene: dict, rules: RuleSet, lo: float, hi: float) -> float: + """How far a battery over [lo, hi] can move along the walls against spans measured in s when + the meter or a corner between them moves within its error (server 3baa338 _slide): the + meter's error times the difference between its wall's direction and the battery's, plus each + other wall between the meter and the battery's near edge, at its far end, times the same + difference for that wall. 0 on the meter's own wall.""" + segments = segments_s(scene, rules) + if not segments: + return 0.0 + walls = {w["id"]: w for w in scene["walls"]} + + def along(seg) -> Point: + _, _, _, p, q = seg + n = math.dist(p, q) + return ((q[0] - p[0]) / n, (q[1] - p[1]) / n) + + def under(s: float): + return min(segments, key=lambda g: max(g[1] - s, s - g[2], 0.0)) + + battery = along(under((lo + hi) / 2)) + + def turn(seg) -> float: + a = along(seg) + return math.hypot(battery[0] - a[0], battery[1] - a[1]) + + meter_wall = scene["meter"]["wall_id"] + meter_seg = min( + (g for g in segments if g[0] == meter_wall), key=lambda g: max(g[1], -g[2], 0.0) + ) + near = lo if lo > 0 else min(hi, 0.0) + a0, b0 = min(0.0, near), max(0.0, near) + total = meter_error(scene, rules) * turn(meter_seg) + for seg in segments: + a, b = max(seg[1], a0), min(seg[2], b0) + if b - a <= EPS: + continue + total += wall_error_at(walls[seg[0]], rules, max(abs(a), abs(b))) * turn(seg) + return total + + +def wall_error(wall: dict, rules: RuleSet) -> float: + """A wall's own position error, without drift: its explicit one or its source's default.""" + return wall.get("plus_minus_ft", rules.errors[WALL_ERROR[wall.get("source", "tap")]]) + + +def reach_gaps( + scene: dict, rules: RuleSet, check: str, wall_id: str, lo: float, hi: float +) -> list[tuple[str, str]]: + """(band, what is missing) for each Need of `check` a battery over [lo, hi] does not have. + + Only points within the check's radius R of the battery whatever the wall's shape are + required, so a correct server is never flagged. R is widened by the battery's position + error e where the server widens it (C5: a pass needs the margin to exceed the error, and an + unseen hazard just past the seen area is a margin of zero). + + - wall band: [lo - R - e, hi + R + e] along the wall, since distance along the wall is + never shorter than straight-line distance, seen up to the check's height; + - ground band: out to depth + R + e in front of the battery (a battery sits flush on one + straight segment), and at distance d past either end out to depth + sqrt((R + e)^2 - d^2), + the straight-line reach from the footprint's corner; + - facing and overhead bands: over [lo - e, hi + e], every place the battery may sit, beyond + the check's distance unless measured. + """ + gaps = [] + for need in rules.needs[check]: + if not need.widen: + error = 0.0 + elif need.along: + error = along_error(scene, rules, wall_id, lo, hi) + else: + error = battery_error(scene, rules, wall_id, lo, hi) + reach = need.radius + error + if need.band == "ground": + plan = GroundPlan(scene, rules) if rules.wall_join_ft is not None else None + gap = ground_gap(scene, lo, hi, rules.depth_ft, reach, plan) + elif need.band == "wall": + a, b = required_span(lo, hi, reach) + wall = observed( + scene, "wall", need.height, beyond=True, wall_default_ft=rules.headroom_ft + ) + seen = not gaps_over(wall, a, b) + gap = None if seen else f"wall [{a:.2f}, {b:.2f}] observed higher than {need.height} ft" + else: + a, b = required_span(lo, hi, reach) + gap = measured_band_gap(scene, need.band, wall_id, a, b, need.height) + if gap: + gaps.append((need.band, gap)) + return gaps + + +def measured_band_gap( + scene: dict, band: str, wall_id: str, lo: float, hi: float, need: float +) -> str | None: + """The first stretch of [lo, hi] the facing or overhead band leaves unsettled: not observed, + or observed short of `need` where no measurement covers it.""" + entries = [ + (*sorted(e["span_ft"]), reached(e)) + for e in scene.get("coverage", {}).get("observed", []) + if e["band"] == band + ] + measured = [ + tuple(sorted(m["span_ft"])) + for m in scene.get(MEASUREMENTS[band], []) + if m.get("wall_id", wall_id) == wall_id + ] + edges = {x for e in entries for x in e[:2]} | {x for m in measured for x in m} + cuts = sorted({lo, hi} | {x for x in edges if lo < x < hi}) + for p, q in itertools.pairwise(cuts): + if q - p <= EPS: + continue + seen = [out for x, y, out in entries if x <= p + EPS and q - EPS <= y] + where = f"{band} [{p:.2f}, {q:.2f}] observed" + if not seen: + if q - p < COVERAGE_TOLERANCE_FT: + continue + return f"{where}, none seen" + settled = any(x <= p + EPS and q - EPS <= y for x, y in measured) + # Strictly beyond, as the server requires (server/solver.py at 6c7ca23). + if not settled and max(seen) <= need: + return f"{where} out to {need:.2f} ft or measured, seen {max(seen):.2f} ft" + return None + + +def run_starts(span: list[float], step_ft: float | None) -> list[float]: + """Starts sampled from a sweep run: its first, every step after it, and its last. + + A sample, not the server's own list: the server also evaluates boundaries and midpoints + that a run reports only as its two ends.""" + if step_ft is None or step_ft <= 0: + raise ValueError("checking a sweep run needs the rules' sweep.step_ft") + a, b = span + # Every step strictly before the last start, the first included however short the run. + count = max(0, math.ceil((b - a) / step_ft - EPS)) + return [a + k * step_ft for k in range(count)] + [b] + + +# Checks every result lists besides those with coverage needs (server README, "What settles +# each check", at 3baa338). +ALWAYS_CHECKS = frozenset({"meter_working_space", "route_path", "route_length"}) + + +def expected_checks(scene: dict, rules: RuleSet) -> set[str]: + """The checks the rules define for this scene. battery_clearance only when the scene marks + an existing battery or the rules set its clearance above gas's (server 3baa338).""" + ids = set(rules.needs) + battery = rules.needs.get("battery_clearance") + gas = rules.needs.get("gas_clearance") + marked = any(o.get("type") == "battery" for o in scene.get("objects", [])) + if battery and not marked and not (gas and battery[0].radius > gas[0].radius): + ids.discard("battery_clearance") + return ids + + +def dropped_check_problems(scene: dict, result: dict, rules: RuleSet) -> list[str]: + listed = {c["id"] for c in result.get("checks", [])} + wanted = expected_checks(scene, rules) | ALWAYS_CHECKS + return [ + f"the result leaves out check {c}, which the rules define" for c in sorted(wanted - listed) + ] + + +def coverage_problems(scene: dict, result: dict, rules: RuleSet) -> list[str]: + """Missing coverage is never a pass (C5), to the reach each check's rule looks out to (see + reach_gaps). A sweep run that passes needs, at each start sampled from it (run_starts), every + check's area around that start's battery with that battery's own position error, and the + wall and cable route back to the meter over the whole run. Checking the whole run as one + battery at its farthest start's error would ask for more than any single start needs.""" + problems: list[str] = [] + route_wall = observed( + scene, "wall", rules.route_height_ft, beyond=True, wall_default_ft=rules.headroom_ft + ) + # Coverage is needed for every check the rules define for this scene, whether or not the + # result lists it: a result that drops a check must not skip its coverage. + evaluated = expected_checks(scene, rules) + if "coverage" not in scene and result["decision"] == "pass": + problems.append("decision pass for a scene with no coverage at all") + + for run in result.get("sweep", []): + if run["outcome"] != "pass": + continue + lo, hi = run["start_ft"][0], run["start_ft"][1] + rules.width_ft + # The route starts at the meter, which may stand its error either side of s = 0 + # (server README, route_path), within the scanned chain. + m = meter_error(scene, rules) + ends = chain_ends_s(scene, rules) or (-math.inf, math.inf) + route_lo = max(min(lo, -m), ends[0]) + route_hi = min(max(hi, m), ends[1]) + if gaps_over(route_wall, route_lo, route_hi): + problems.append( + f"sweep pass for starts {run['start_ft']} but the wall and cable route " + f"[{route_lo:.2f}, {route_hi:.2f}] were not all observed higher than " + f"{rules.route_height_ft} ft" + ) + for check in sorted(set(rules.needs) & evaluated): + gaps = {} # each gap once, at the first start that needs it + for start in run_starts(run["start_ft"], rules.step_ft): + battery = (start, start + rules.width_ft) + for _, gap in reach_gaps(scene, rules, check, run["wall_id"], *battery): + gaps.setdefault(gap, start) + problems += [ + f"sweep pass for starts {run['start_ft']} but at start {start:.2f} {check} " + f"needs {gap}" + for gap, start in gaps.items() + ] + + spot = result.get("spot") + if spot is not None: + lo, hi = spot["span_ft"] + for check in result.get("checks", []): + if check["outcome"] != "pass" or check["id"] not in rules.needs: + continue + for _, gap in reach_gaps(scene, rules, check["id"], spot["wall_id"], lo, hi): + problems.append(f"check {check['id']} passes but needs {gap}") + + for request in result.get("missing_evidence", []): + if request["kind"] != "band" or "span_ft" not in request or "band" not in request: + continue + a, b = sorted(request["span_ft"]) + # A request may want a farther or higher view over a span already seen: it is redundant + # only where the band was seen as far as it asks (`out_ft`, on every band since S2 + # 55cb4b0). Without one, a ground request is redundant past GROUND_FAR_FT, and any other + # only where the band was seen all the way (no `out_ft`). + default = GROUND_FAR_FT if request["band"] == "ground" else math.inf + far = request.get("out_ft", default) + seen = observed(scene, request["band"], far, wall_default_ft=rules.headroom_ft) + if b - a > EPS and covers(seen, a, b): + problems.append( + f"missing_evidence asks for {request['band']} {request['span_ft']}, " + "which the scene lists as observed" + ) + return problems + + +def ground_gap( + scene: dict, + lo: float, + hi: float, + depth: float, + radius: float, + plan: GroundPlan | None = None, +) -> str | None: + """The first stretch of ground seen less far out than a battery over [lo, hi] needs (see + coverage_problems), or None. With `plan`, ground not seen in the battery's own wall's band + still counts where it lies in another wall's observed band in plan (GroundPlan).""" + entries = [ + (*sorted(e["span_ft"]), e.get("out_ft", 0.0)) + for e in scene.get("coverage", {}).get("observed", []) + if e["band"] == "ground" + ] + a, b = lo - radius, hi + radius + cuts = sorted({a, lo, hi, b} | {x for e in entries for x in e[:2] if a < x < b}) + for p, q in itertools.pairwise(cuts): + if q - p <= EPS: + continue + + # Past an end, a point d along the wall from the battery is within the radius of its + # footprint (depth D out from the wall) out to D + sqrt(R^2 - d^2). + def need_at(s: float) -> float: + d = max(lo - s, s - hi, 0.0) + return depth + math.sqrt(max(0.0, radius**2 - d**2)) + + need = need_at(q if q <= lo else p if p >= hi else (lo + hi) / 2) + seen = max((out for x, y, out in entries if x <= p + EPS and q - EPS <= y), default=None) + if seen is None or seen + SEEN_GROWTH_FT < need: + if plan is not None and plan.covers((lo + hi) / 2, p, q, seen or 0.0, need_at): + continue + where = f"ground [{p:.2f}, {q:.2f}] observed" + if seen is None: + return f"{where}, none seen" + return f"{where} out to {need:.2f} ft, seen {seen:.2f} ft" + return None + + +def missing_evidence_problems(scene: dict, result: dict, rules: RuleSet | None = None) -> list[str]: + """When coverage is what stands in the way (reason unobserved_area), every check left unsure + for an unobserved area is named by a request, and a clearance check by a request of each + band it lacks around the chosen spot: a check that forgets one band leaves the next capture + still unsure. + + Without that reason, a chosen spot may still have checks unsure for an unobserved area only + where what they lack lies past an unexplored end the result asks to walk past (reason + unexplored_end, a past_end request, which names no checks). Anything else they lack is owed + the reason and its requests; leaving both out would never ask the homeowner for the view + that settles the check. Without a spot and without the reason, every position fails anyway, + no photo would change the answer, and C2 lets missing_evidence stay empty.""" + codes = {r["code"] for r in result.get("reasons", [])} + spot = result.get("spot") + unseen = unobserved_checks(result) + requests = result.get("missing_evidence", []) + if "unobserved_area" not in codes: + if spot is None or not unseen: + return [] + if rules is None: + if requests: + return [] + return [ + f"the chosen spot has checks unsure for an unobserved area ({', '.join(unseen)}) " + "but no reason unobserved_area and no missing_evidence" + ] + lo, hi = spot["span_ft"] + asked = with_past_ends_asked(scene, requests, rules) + return [ + f"check {c} is unsure and needs {gap}, not past an end the result asks to walk " + f"past, but there is no reason unobserved_area and no {band} request" + for c in unseen + if c in rules.needs + for band, gap in reach_gaps(asked, rules, c, spot["wall_id"], lo, hi) + ] + if not requests: + return ["reason unobserved_area but missing_evidence is empty"] + problems: list[str] = [] + named = {(r.get("band"), c) for r in requests for c in r.get("checks", [])} + problems += [ + f"check {c} is unsure (unobserved) but no missing_evidence entry names it" + for c in unseen + if not any(check == c for _, check in named) + ] + if rules is None or spot is None: + return problems + lo, hi = spot["span_ft"] + asked = with_past_ends_asked(scene, requests, rules) + for c in unobserved_checks(result): + if c not in rules.needs: + continue + for band, gap in reach_gaps(asked, rules, c, spot["wall_id"], lo, hi): + if (band, c) not in named: + problems.append( + f"check {c} is unsure and needs {gap}, but no {band} request names it" + ) + return problems + + +# How far a past_end request may sit from the chain's actual end and still name it. +END_TOL_FT = 0.1 + + +Point = tuple[float, float] + + +def segments_s(scene: dict, rules: RuleSet) -> list[tuple[str, float, float, Point, Point]] | None: + """Each straight baseline segment as (wall id, s at its start, s at its end, start point, + end point), with s measured from the meter's projection, or None without a meter wall. + + s runs along every wall. The space from one wall's end to the next one's start counts only + when it is a gap: wider than both walls' own errors together (no drift), capped at + sweep.wall_join_ft. Narrower, the walls meet and s skips the space (server/scene.py at + 7133283, `meets`).""" + if rules.wall_join_ft is None: + raise ValueError("laying out the walls needs the rules' sweep.wall_join_ft") + walls = scene.get("walls", []) + if not walls or "meter" not in scene: + return None + raw: list[tuple[str, float, float, Point, Point]] = [] + meter_s = None + mx, _, mz = scene["meter"]["pos"] + s, prev = 0.0, None + for wall in walls: + pts = [(float(pt[0]), float(pt[1])) for pt in wall["baseline"]] + if prev is not None: + space = math.dist(prev[0], pts[0]) + meets = min(rules.wall_join_ft, max(prev[1] + wall_error(wall, rules), SERVER_EPS)) + s += space if space > meets else 0.0 + for p, q in itertools.pairwise(pts): + seg = math.dist(p, q) + if wall["id"] == scene["meter"]["wall_id"] and seg > 0: + t = ((mx - p[0]) * (q[0] - p[0]) + (mz - p[1]) * (q[1] - p[1])) / seg**2 + t = min(1.0, max(0.0, t)) + d = math.dist((mx, mz), (p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1]))) + if meter_s is None or d < meter_s[0]: + meter_s = (d, s + t * seg) + raw.append((wall["id"], s, s + seg, p, q)) + s += seg + prev = (pts[-1], wall_error(wall, rules)) + if meter_s is None: + return None + return [(wid, a - meter_s[1], b - meter_s[1], p, q) for wid, a, b, p, q in raw] + + +def wall_spans_s(scene: dict, rules: RuleSet) -> dict[str, tuple[float, float]] | None: + """Each wall's stretch of s (see segments_s), or None without a meter wall.""" + segments = segments_s(scene, rules) + if segments is None: + return None + spans: dict[str, tuple[float, float]] = {} + for wid, a, b, _, _ in segments: + lo, hi = spans.get(wid, (a, b)) + spans[wid] = (min(lo, a), max(hi, b)) + return spans + + +class GroundPlan: + """The observed ground in plan: each entry's stretch of every segment it runs along, out to + its `out_ft` on the segment's outward side (the baseline turned 90 degrees clockwise seen + from above, scene.schema.json). A point in front of one wall can lie in a neighbouring + wall's band, as in an inside corner, where the second wall faces back over the first.""" + + # Sampling step for coverage tests. Samples are finite, so they cannot prove that every + # point of a region was seen: a thin gap shaped to avoid them can still be missed. + STEP_FT = 0.05 + + def __init__(self, scene: dict, rules: RuleSet) -> None: + self.segments = segments_s(scene, rules) or [] + self.strips = [] # (origin, along, outward, from s, to s, out) + for e in scene.get("coverage", {}).get("observed", []): + if e["band"] != "ground": + continue + a, b = sorted(e["span_ft"]) + for _, s0, s1, p, q in self.segments: + if min(b, s1) - max(a, s0) > EPS and s1 - s0 > EPS: + along, outward = self._frame(p, q) + strip = (p, along, outward, max(a, s0) - s0, min(b, s1) - s0) + self.strips.append((*strip, e.get("out_ft", 0.0))) + + @staticmethod + def _frame(p: Point, q: Point) -> tuple[Point, Point]: + length = math.dist(p, q) + along = ((q[0] - p[0]) / length, (q[1] - p[1]) / length) + return along, (-along[1], along[0]) + + def point(self, s_mid: float, s: float, out: float) -> Point | None: + """The plan point at s along the line of the segment under s_mid, `out` in front.""" + seg = next((g for g in self.segments if g[1] - EPS <= s_mid <= g[2] + EPS), None) + if seg is None: + return None + _, s0, _, p, q = seg + along, outward = self._frame(p, q) + t = s - s0 + return (p[0] + t * along[0] + out * outward[0], p[1] + t * along[1] + out * outward[1]) + + def seen(self, x: Point) -> bool: + for origin, along, outward, t0, t1, out in self.strips: + dx, dz = x[0] - origin[0], x[1] - origin[1] + t = dx * along[0] + dz * along[1] + n = dx * outward[0] + dz * outward[1] + grow = SEEN_GROWTH_FT + if t0 - grow <= t <= t1 + grow and -grow <= n <= out + grow: + return True + return False + + def required(self, x: Point) -> bool: + """Whether ground at x is yard a view must show (server 3baa338 unobserved_ground): in + front of some wall segment, or past either end of the chain. Past a corner, behind the + next wall, is neither: the house.""" + for i, (_, _, _, p, q) in enumerate(self.segments): + along, outward = self._frame(p, q) + dx, dz = x[0] - p[0], x[1] - p[1] + t = dx * along[0] + dz * along[1] + n = dx * outward[0] + dz * outward[1] + length = math.dist(p, q) + if -EPS <= t <= length + EPS and n >= -EPS: + return True + if (i == 0 and t < 0) or (i == len(self.segments) - 1 and t > length): + return True + return False + + def covers( + self, s_mid: float, p: float, q: float, out_lo: float, need: Callable[[float], float] + ) -> bool: + """Every sampled point from s = p to q, deeper than out_lo (seen by the stretch's own + band) and out to need(s) in front of the battery's segment, lies in some observed ground + band. + + Samples are each cell's edges and its centre, in s and out. Edges alone missed a gap + narrower than a cell between two bands (0.019 ft at 5a4b4f4): both its edges lie on a + band, its middle on none.""" + if not self.strips: + return False + for s in self._samples(p, q): + # Up to out_lo the stretch's own band already saw the ground. + deeper = [o for o in self._samples(out_lo, need(s)) if o > out_lo + SERVER_EPS] + for out in deeper: + x = self.point(s_mid, s, out) + if x is None or (self.required(x) and not self.seen(x)): + return False + return True + + @classmethod + def _samples(cls, a: float, b: float) -> list[float]: + n = max(1, math.ceil((b - a) / cls.STEP_FT)) + edges = [a + (b - a) * i / n for i in range(n + 1)] + centres = [a + (b - a) * (i + 0.5) / n for i in range(n)] + return sorted(edges + centres) + + +def chain_ends_s(scene: dict, rules: RuleSet) -> tuple[float, float] | None: + """s of the wall chain's left and right ends, or None without a meter wall.""" + spans = wall_spans_s(scene, rules) + if spans is None: + return None + return min(a for a, _ in spans.values()), max(b for _, b in spans.values()) + + +def declared_height_problems(scene: dict, result: dict, rules: RuleSet) -> list[str]: + """A battery backs onto a wall that declares `height_ft` only if the wall is taller than the + battery. The server fails wall_backing for a lower wall surely behind the battery and leaves + it unsure for one within the battery's error of it (server/solver.py at 7133283, + check_backing), so wall_backing passes only if every declared wall overlapping + [s0 - e, s1 + e] is taller than battery.height_ft.""" + declared = {w["id"]: w["height_ft"] for w in scene.get("walls", []) if "height_ft" in w} + if not declared or rules.battery_height_ft is None: + return [] + spans = wall_spans_s(scene, rules) or {} + height = rules.battery_height_ft + + def short_near(wall_id: str, lo: float, hi: float) -> str | None: + e = battery_error(scene, rules, wall_id, lo, hi) + for wid, h in declared.items(): + a, b = spans.get(wid, (math.inf, -math.inf)) + near = a < hi + e - SERVER_EPS and b > lo - e + SERVER_EPS + if near and h - height <= SERVER_EPS: + return f"wall {wid} ({h} ft) is within {e:.2f} ft and not taller than {height} ft" + return None + + problems = [] + spot = result.get("spot") + backing = next((c for c in result.get("checks", []) if c["id"] == "wall_backing"), None) + if spot is not None and backing is not None and backing["outcome"] == "pass": + short = short_near(spot["wall_id"], *spot["span_ft"]) + if short: + problems.append(f"check wall_backing passes but {short}") + for run in result.get("sweep", []): + if run["outcome"] != "pass": + continue + for start in run_starts(run["start_ft"], rules.step_ft): + short = short_near(run["wall_id"], start, start + rules.width_ft) + if short: + problems.append( + f"sweep pass for starts {run['start_ft']} but at {start:.2f} {short}" + ) + break + return problems + + +def with_past_ends_asked(scene: dict, requests: list[dict], rules: RuleSet) -> dict: + """The scene as if everything past each end the result asks to walk past were observed. + + Past an unexplored end the wall may turn, so a view along the same line settles nothing; the + server asks to keep walking (`past_end`) instead of for a band there, and that request covers + every band beyond the end. Only a request at the chain's actual end, on a side not marked + `limit`, earns that credit.""" + kinds = scene.get("coverage", {}).get("ends", {}) + past = [r for r in requests if r["kind"] == "past_end" and "span_ft" in r and "side" in r] + ends = chain_ends_s(scene, rules) if past else None + if not past or ends is None: + return scene + m = copy.deepcopy(scene) + observed_ = m.setdefault("coverage", {}).setdefault("observed", []) + for r in past: + side = r["side"] + end = ends[0] if side == "left" else ends[1] + if kinds.get(side, {}).get("kind") == "limit" or abs(r["span_ft"][0] - end) > END_TOL_FT: + continue + span = [end - 1e6, end] if side == "left" else [end, end + 1e6] + for band in ("wall", "facing", "overhead"): + observed_.append({"band": band, "span_ft": span}) + observed_.append({"band": "ground", "span_ft": span, "out_ft": 1e6}) + return m + + +# --- Case expectations ----------------------------------------------------------------------- + + +def assumption_mismatches(assumed: dict[str, float], result: dict) -> list[str]: + out = [] + for key, value in assumed.items(): + for check in result.get("checks", []): + threshold = check.get("threshold_ft") + if key in check["id"] and threshold is not None and abs(threshold - value) > EPS: + out.append( + f"case assumes {key} threshold {value}, server's {check['id']} uses " + f"{check['threshold_ft']}" + ) + return out + + +def expectation_problems(expect: dict, result: dict) -> list[str]: + problems: list[str] = [] + decision = result["decision"] + if "decision_in" in expect and decision not in expect["decision_in"]: + problems.append(f"decision {decision}, expected one of {expect['decision_in']}") + if decision in expect.get("decision_not", []): + problems.append(f"decision {decision} is ruled out for this case") + + if "spot" in expect: + want, spot = expect["spot"], result.get("spot") + if want is None and spot is not None: + problems.append(f"expected no spot, got {spot['wall_id']} {spot['span_ft']}") + if want is not None: + if spot is None: + problems.append("expected a spot, got none") + else: + a, b = want["span_within"] + s0, s1 = spot["span_ft"] + if spot["wall_id"] != want["wall_id"] or s0 < a - 1 / 12 or s1 > b + 1 / 12: + problems.append( + f"spot {spot['wall_id']} {spot['span_ft']} outside expected " + f"{want['wall_id']} {want['span_within']}" + ) + + for rule in expect.get("sweep_runs", []): + a, b = rule["start_ft"] + runs = [ + r + for r in result.get("sweep", []) + if r["wall_id"] == rule["wall_id"] and r["start_ft"][0] < b and r["start_ft"][1] > a + ] + if not runs and "outcome" in rule: + problems.append(f"no sweep run overlaps {rule['wall_id']} starts ({a}, {b})") + for run in runs: + if "outcome" in rule and run["outcome"] != rule["outcome"]: + problems.append( + f"sweep {run['wall_id']} starts {run['start_ft']} is {run['outcome']}, " + f"expected {rule['outcome']}" + ) + if "outcome_not" in rule and run["outcome"] == rule["outcome_not"]: + why = f" ({rule['reason']})" if "reason" in rule else "" + problems.append( + f"sweep {run['wall_id']} starts {run['start_ft']} is {run['outcome']}, " + f"which this case rules out{why}" + ) + for field, key in (("failing", "failing_match"), ("unsure", "unsure_match")): + if key in rule and not any(rule[key] in c for c in run[field]): + problems.append( + f"sweep {run['wall_id']} starts {run['start_ft']}: no {field} check " + f"matching {rule[key]!r} (has {run[field]})" + ) + + for rule in expect.get("checks", []): + matching = [c for c in result.get("checks", []) if rule["match"] in c["id"]] + if not matching: + problems.append(f"no check matching {rule['match']!r}") + for check in matching: + for field in ("outcome", "unsure_cause"): + if field in rule and check.get(field) != rule[field]: + problems.append( + f"check {check['id']}: {field} {check.get(field)}, expected {rule[field]}" + ) + # Pinned numbers catch a server that measures from somewhere else (for example + # another battery depth) and lands on the right outcome by accident. The server + # rounds to 6 decimals. + for field in ("measured_ft", "plus_minus_ft"): + got = check.get(field) + if field in rule and (got is None or abs(got - rule[field]) > NUMBER_TOL_FT): + problems.append(f"check {check['id']}: {field} {got}, expected {rule[field]}") + + for rule in expect.get("start_outcomes", []): + got = outcome_at(result, rule["wall_id"], rule["start_ft"]) + if got != rule["outcome"]: + why = f" ({rule['why']})" if "why" in rule else "" + problems.append( + f"start {rule['wall_id']} {rule['start_ft']} is {got or 'not evaluated'}, " + f"expected {rule['outcome']}{why}" + ) + + if "missing_evidence_empty" in expect: + empty = not result.get("missing_evidence") + if empty != expect["missing_evidence_empty"]: + problems.append( + "missing_evidence " + + ("is empty" if empty else "is not empty") + + f", expected {'empty' if expect['missing_evidence_empty'] else 'requests'}" + ) + return problems + + +# --- Transforms for black-box properties ----------------------------------------------------- + + +def _flip_span(span: list[float]) -> list[float]: + return [-span[1], -span[0]] + + +def mirror_scene(scene: dict) -> dict: + """Reflect the scene left to right (x -> -x) so s becomes -s. + + Baselines are reversed after reflecting so they still run left to right as seen from + outside, which keeps every wall's outward side. Keyframes, stills and heading are dropped, + since a reflected camera pose is not a rotation. + """ + m = copy.deepcopy(scene) + for key in ("keyframes", "stills", "heading"): + m.pop(key, None) + x, y, z = m["meter"]["pos"] + m["meter"]["pos"] = [-x, y, z] + m["walls"] = [ + wall | {"baseline": [[-px, pz] for px, pz in reversed(wall["baseline"])]} + for wall in reversed(m["walls"]) + ] + for obj in m.get("objects", []): + obj["span_ft"] = _flip_span(obj["span_ft"]) + if "footprint" in obj: + obj["footprint"] = [[-px, pz] for px, pz in obj["footprint"]] + for area in m.get("ground", []): + area["polygon"] = [[-px, pz] for px, pz in area["polygon"]] + for key in ("overheads", "facing"): + for item in m.get(key, []): + item["span_ft"] = _flip_span(item["span_ft"]) + coverage = m.get("coverage") + if coverage: + for entry in coverage.get("observed", []): + entry["span_ft"] = _flip_span(entry["span_ft"]) + ends = coverage.get("ends") + if ends: + coverage["ends"] = { + side: ends[other] + for side, other in (("left", "right"), ("right", "left")) + if other in ends + } + return m + + +def outcome_lengths(result: dict) -> dict[str, float]: + """Total length of battery start positions per outcome, from the sweep.""" + totals = {"pass": 0.0, "unsure": 0.0, "fail": 0.0} + for run in result.get("sweep", []): + totals[run["outcome"]] += run["start_ft"][1] - run["start_ft"][0] + return totals + + +def comparable(result: dict) -> dict: + """The result with the fields that may legitimately differ between identical runs removed.""" + r = copy.deepcopy(result) + r["stats"].pop("elapsed_ms", None) + return r + + +# --- Ordering properties --------------------------------------------------------------------- +# +# Comparisons between the answers to a scene and to a changed copy of it. They need no expected +# numbers: more measurement error or less coverage can only make the server less sure. + + +def outcome_at(result: dict, wall_id: str, start_ft: float) -> str | None: + """Outcome of the sweep run containing this battery start, or None if none contains it.""" + for run in result.get("sweep", []): + a, b = run["start_ft"] + if run["wall_id"] == wall_id and a - EPS <= start_ft <= b + EPS: + return run["outcome"] + return None + + +def probe_starts(*results: dict) -> list[tuple[str, float]]: + """Every run's ends and middle, from all results: where the outcomes can be compared.""" + points = set() + for result in results: + for run in result.get("sweep", []): + a, b = run["start_ft"] + points |= {(run["wall_id"], a), (run["wall_id"], b), (run["wall_id"], (a + b) / 2)} + return sorted(points) + + +def more_error_problems(before: dict, after: dict, change: str) -> list[str]: + """With more error, a start may only move toward UNSURE: UNSURE stays, PASS and FAIL stay + or become UNSURE.""" + problems = [] + for wall_id, s in probe_starts(before, after): + a, b = outcome_at(before, wall_id, s), outcome_at(after, wall_id, s) + if a is None or b is None or a == b or b == "unsure": + continue + problems.append(f"{change}: start {wall_id} {s:.3f} went from {a} to {b}") + if before["decision"] != "pass" and after["decision"] == "pass": + problems.append(f"{change}: the decision became pass") + return problems + + +def less_coverage_problems(before: dict, after: dict, change: str) -> list[str]: + """With less observed, no start may newly PASS.""" + problems = [ + f"{change}: start {wall_id} {s:.3f} went from {outcome_at(before, wall_id, s)} to pass" + for wall_id, s in probe_starts(before, after) + if outcome_at(after, wall_id, s) == "pass" + and outcome_at(before, wall_id, s) not in (None, "pass") + ] + if before["decision"] != "pass" and after["decision"] == "pass": + problems.append(f"{change}: the decision became pass") + return problems + + +def chain_length_ft(scene: dict) -> float: + """Length of the wall chain, gaps between walls included: no point on it is further than + this from the meter along the walls.""" + points = [tuple(p) for wall in scene["walls"] for p in wall["baseline"]] + return sum(math.dist(p, q) for p, q in itertools.pairwise(points)) + + +def with_more_error(scene: dict, rules: RuleSet, extra_ft: float = 0.5) -> dict: + """Every object, wall and the meter measured `extra_ft` less precisely than before. + + An explicit error replaces the default, and the default for walls and for tap and vlm + objects grows by drift_per_ft with distance from the meter. So an item left on its default + gets that default at the far end of the chain, plus `extra_ft`: at least as much as before + everywhere. + """ + m = copy.deepcopy(scene) + drift = rules.errors["drift_per_ft"] * chain_length_ft(scene) + for obj in m.get("objects", []): + if "plus_minus_ft" not in obj: + grows = obj["source"] in ("tap", "vlm") + obj["plus_minus_ft"] = rules.errors[obj["source"]] + (drift if grows else 0.0) + obj["plus_minus_ft"] += extra_ft + for wall in m["walls"]: + wall["plus_minus_ft"] = wall.get("plus_minus_ft", rules.errors["wall"] + drift) + extra_ft + meter = m["meter"] + meter["plus_minus_ft"] = meter.get("plus_minus_ft", rules.errors["meter"]) + extra_ft + return m + + +def tape_to_tap(scene: dict) -> dict | None: + """Objects measured by tape (and not given their own error) re-measured by AR tap.""" + m = copy.deepcopy(scene) + changed = False + for obj in m.get("objects", []): + if obj["source"] == "tape" and "plus_minus_ft" not in obj: + obj["source"] = "tap" + changed = True + return m if changed else None + + +def with_less_coverage(scene: dict, trim_ft: float = 0.5) -> dict | None: + """Every observed span shortened by `trim_ft` at each end and the ground seen less far out.""" + if not scene.get("coverage", {}).get("observed"): + return None + m = copy.deepcopy(scene) + kept = [] + for entry in m["coverage"]["observed"]: + a, b = sorted(entry["span_ft"]) + if b - a <= 2 * trim_ft: + continue + entry["span_ft"] = [a + trim_ft, b - trim_ft] + if "out_ft" in entry: + entry["out_ft"] = max(0.0, entry["out_ft"] - trim_ft) + kept.append(entry) + m["coverage"]["observed"] = kept + return m + + +def with_ground_short_of(scene: dict, rules: RuleSet, margin_ft: float = 0.1) -> dict | None: + """Ground seen out to just short of the largest clearance radius, where any correct server + must stop passing the check that needs it.""" + radius = max(n.radius for ns in rules.needs.values() for n in ns if n.band == "ground") + reach = rules.depth_ft + radius - margin_ft + grounds = [e for e in scene.get("coverage", {}).get("observed", []) if e["band"] == "ground"] + if not any(e.get("out_ft", 0.0) > reach for e in grounds): + return None + m = copy.deepcopy(scene) + for entry in m["coverage"]["observed"]: + if entry["band"] == "ground": + entry["out_ft"] = min(entry["out_ft"], reach) + return m + + +# Further out than any clearance looks: the largest in rules.yaml is 10 ft, plus a 1.8 ft +# battery and its position error. Requested ground is added out to here, and ground seen this +# far never needs asking for again. +GROUND_FAR_FT = 40.0 + + +def with_requests_captured(scene: dict, result: dict) -> dict | None: + """The scene as if the homeowner had shown every band the result asked for, exactly as far + as each request asks (its `out_ft`, which the result schema says to report back as the + observed entry's), never further. A ground request without one is read as far as the + redundancy check reads it, GROUND_FAR_FT.""" + + def reach(request: dict) -> dict: + if "out_ft" in request: + return {"out_ft": request["out_ft"]} + return {"out_ft": GROUND_FAR_FT} if request["band"] == "ground" else {} + + added = [ + {"band": r["band"], "span_ft": sorted(r["span_ft"])} | reach(r) + for r in result.get("missing_evidence", []) + if r["kind"] == "band" and "band" in r and "span_ft" in r + ] + if not added: + return None + m = copy.deepcopy(scene) + m.setdefault("coverage", {}).setdefault("observed", []).extend(added) + return m + + +def coverage_blocks(result: dict) -> bool: + """The result names an unobserved area as a reason, so photos could change it.""" + return any(r["code"] == "unobserved_area" for r in result.get("reasons", [])) + + +def unobserved_checks(result: dict) -> list[str]: + return [ + c["id"] + for c in result.get("checks", []) + if c["outcome"] == "unsure" and c.get("unsure_cause") == "unobserved" + ] diff --git a/verification/hsverify/scoreboard.py b/verification/hsverify/scoreboard.py new file mode 100644 index 000000000..3cca83fe9 --- /dev/null +++ b/verification/hsverify/scoreboard.py @@ -0,0 +1,820 @@ +"""Scoreboard for the morning meeting: every open PR and every overnight-plan metric. + + uv run python -m hsverify.scoreboard # fast probes; slow ones show their cache + uv run python -m hsverify.scoreboard --slow # also run slow command probes + uv run python -m hsverify.scoreboard --slow S2-1 # only this slow probe + +Writes verification/SCOREBOARD.md and verification/scoreboard/scoreboard.json and prints the +Markdown. PRs come from `gh pr list`; CI is summarised from each PR's status-check rollup. + +Metrics live in verification/scoreboard/metrics.yaml. Each names a git ref and exactly one +probe, which is evaluated against the ref's current SHA so the evidence is tied to a commit: + +- file_at_ref {path | paths}: met when every file exists at the ref. +- grep_at_ref {paths, pattern, min_matches, each}: `paths` are fnmatch globs over the files + at the ref (`*` also matches `/`). Without `each`, met when the pattern matches at least + `min_matches` times. With `each` (a list, or a mapping of label to regex fragment), the + fragment replaces `{each}` in the pattern and every one must match: met when all do, + partial when some do, gap when none do. +- command {run, cwd, timeout_s, slow}: runs in a detached worktree of the SHA under /tmp; + met on exit 0. Slow probes run only with --slow; otherwise the last cached result for the + same SHA is shown with status "not run". +- report {glob, key, equals | less_than | length_equals | length_at_least, + require_current_sha, sha_key, valid_key}: judges the newest matching report.json (by + mtime). `sha_key` (default "sha") names the report field holding the SHA the report was + measured at; an e2e report carries the server's as `sha` and the app's as `app_sha`. With + require_current_sha only a report whose SHA equals the ref's counts; an older one makes the + metric "stale". With `valid_key`, only a report whose value there is true counts, for + reports that record whether the run itself was evidence (a11yaudit's `ok`). The evidence + names the SHA the judged report was measured at. +- pr_check {name}: the named CI check on the ref's open PR. +- pr_body {pattern, min_matches}: the ref's open PR description. +- manual {status, evidence}: a judgment the lead records by editing the YAML. + +Any probe may set `max_status: partial` with a `cap_reason`, for probes that can only show +that something exists, not that it works or is complete. + +A ref that does not exist (branch not pushed) gives "ref missing" for every probe that needs +it; nothing about a missing ref stops the rest of the board. +""" + +from __future__ import annotations + +import argparse +import contextlib +import datetime as dt +import fnmatch +import glob as globlib +import json +import os +import re +import signal +import subprocess +import sys +from collections.abc import Callable, Iterator +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Protocol + +import yaml + +from hsverify import gitref + +GITHUB_REPO = "SamGu-NRX/house-scanning" +BLOB_URL = f"https://github.com/{GITHUB_REPO}/blob" +TREE_URL = f"https://github.com/{GITHUB_REPO}/tree" +PR_URL = f"https://github.com/{GITHUB_REPO}/pull" +VERIFICATION = Path(__file__).resolve().parents[1] +SCOREBOARD_DIR = VERIFICATION / "scoreboard" +METRICS_PATH = SCOREBOARD_DIR / "metrics.yaml" +CACHE_PATH = SCOREBOARD_DIR / ".cache.json" +JSON_PATH = SCOREBOARD_DIR / "scoreboard.json" +MARKDOWN_PATH = VERIFICATION / "SCOREBOARD.md" +COMMAND = "uv run python -m hsverify.scoreboard" +PR_FIELDS = "number,title,headRefName,headRefOid,isDraft,updatedAt,statusCheckRollup,body" + +STATUSES = ("met", "partial", "gap", "stale", "not run", "no evidence yet", "ref missing") +MANUAL_STATUSES = ("met", "partial", "gap") +COMPARATORS = ("equals", "less_than", "length_equals", "length_at_least") + +# Keys each probe accepts, with defaults for the optional ones (None marks a required key). +PROBE_KEYS: dict[str, dict[str, Any]] = { + "file_at_ref": {"path": "", "paths": []}, + "grep_at_ref": {"paths": None, "pattern": None, "min_matches": 1, "each": None}, + "command": {"run": None, "cwd": ".", "timeout_s": 600, "slow": True}, + "report": { + "glob": None, + "key": None, + **dict.fromkeys(COMPARATORS), + "require_current_sha": False, + "sha_key": "sha", + "valid_key": None, + }, + "pr_check": {"name": None}, + "pr_body": {"pattern": None, "min_matches": 1}, + "manual": {"status": None, "evidence": None}, +} +COMMON_PROBE_KEYS = {"max_status": None, "cap_reason": None} +METRIC_KEYS = {"id", "workstream", "ref", "text"} + +# CI rollup buckets. CheckRun entries carry status + conclusion, StatusContext entries state. +CHECK_CONCLUSIONS = { + "SUCCESS": "success", + "NEUTRAL": "success", + "SKIPPED": "skipped", + "STALE": "skipped", + "FAILURE": "failure", + "CANCELLED": "failure", + "TIMED_OUT": "failure", + "ACTION_REQUIRED": "failure", + "STARTUP_FAILURE": "failure", +} +CONTEXT_STATES = { + "SUCCESS": "success", + "FAILURE": "failure", + "ERROR": "failure", + "PENDING": "pending", + "EXPECTED": "pending", +} +CI_BUCKETS = ("success", "failure", "pending", "skipped") + + +class MetricsError(ValueError): + """metrics.yaml does not match the probe vocabulary.""" + + +# --- metrics file ---------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Metric: + id: str + workstream: str + ref: str + text: str + kind: str + probe: dict[str, Any] + + +def load_metrics(path: Path = METRICS_PATH) -> tuple[dict[str, str], list[Metric]]: + """(workstream id -> title, metrics). Raises MetricsError naming the offending metric.""" + data = yaml.safe_load(path.read_text()) + workstreams = data.get("workstreams") or {} + metrics = [parse_metric(raw, workstreams) for raw in data.get("metrics") or []] + ids = [m.id for m in metrics] + dupes = sorted({i for i in ids if ids.count(i) > 1}) + if dupes: + raise MetricsError(f"duplicate metric ids: {', '.join(dupes)}") + return {k: v["title"] for k, v in workstreams.items()}, metrics + + +def parse_metric(raw: dict, workstreams: dict) -> Metric: + mid = raw.get("id", "") + missing = METRIC_KEYS - raw.keys() + if missing: + raise MetricsError(f"{mid}: missing {', '.join(sorted(missing))}") + if raw["workstream"] not in workstreams: + raise MetricsError(f"{mid}: unknown workstream {raw['workstream']!r}") + kinds = [k for k in raw if k not in METRIC_KEYS] + if len(kinds) != 1 or kinds[0] not in PROBE_KEYS: + raise MetricsError( + f"{mid}: needs exactly one probe out of {', '.join(PROBE_KEYS)}; got {kinds}" + ) + kind = kinds[0] + given = raw[kind] or {} + allowed = PROBE_KEYS[kind] | COMMON_PROBE_KEYS + unknown = set(given) - allowed.keys() + if unknown: + raise MetricsError(f"{mid}: {kind} does not take {', '.join(sorted(unknown))}") + required = {k for k, v in PROBE_KEYS[kind].items() if v is None and k not in COMPARATORS} + required -= {"each", "valid_key"} + absent = required - given.keys() + if absent: + raise MetricsError(f"{mid}: {kind} needs {', '.join(sorted(absent))}") + probe = {k: v for k, v in allowed.items() if v is not None or k in given} | given + _check_probe(mid, kind, probe) + return Metric(mid, raw["workstream"], raw["ref"], raw["text"], kind, probe) + + +def _check_probe(mid: str, kind: str, probe: dict) -> None: + if kind == "file_at_ref" and bool(probe.get("path")) == bool(probe.get("paths")): + raise MetricsError(f"{mid}: file_at_ref needs exactly one of path, paths") + if kind == "report": + chosen = [c for c in COMPARATORS if probe.get(c) is not None] + if len(chosen) != 1: + raise MetricsError(f"{mid}: report needs exactly one of {', '.join(COMPARATORS)}") + if kind == "manual" and probe["status"] not in MANUAL_STATUSES: + raise MetricsError(f"{mid}: manual status must be one of {', '.join(MANUAL_STATUSES)}") + if kind == "grep_at_ref" and probe.get("each") and "{each}" not in probe["pattern"]: + raise MetricsError(f"{mid}: pattern must contain {{each}} when each is set") + if kind in ("grep_at_ref", "pr_body"): + for _, fragment in _each_items(probe) or [("", "")]: + try: + re.compile(probe["pattern"].replace("{each}", fragment)) + except re.error as e: + raise MetricsError(f"{mid}: bad pattern: {e}") from e + if probe.get("max_status") not in (None, "partial"): + raise MetricsError(f"{mid}: max_status can only be partial") + if probe.get("max_status") and not probe.get("cap_reason"): + raise MetricsError(f"{mid}: max_status needs a cap_reason") + + +def _each_items(probe: dict) -> list[tuple[str, str]]: + each = probe.get("each") + if not each: + return [] + if isinstance(each, dict): + return [(str(k), str(v)) for k, v in each.items()] + return [(str(v), re.escape(str(v))) for v in each] + + +# --- CI rollup ------------------------------------------------------------------------------- + + +@dataclass +class CISummary: + counts: dict[str, int] = field(default_factory=lambda: dict.fromkeys(CI_BUCKETS, 0)) + failing: list[str] = field(default_factory=list) + + def text(self) -> str: + parts = [f"{n} {b}" for b, n in self.counts.items() if n] + if not parts: + return "no checks" + text = ", ".join(parts) + return f"{text}; failing: {', '.join(self.failing)}" if self.failing else text + + +def classify_check(entry: dict) -> tuple[str, str] | None: + """(name, bucket) for one rollup entry, or None for entries with no name or outcome. + + A CheckRun that is not COMPLETED is pending; a completed one with no conclusion is + skipped entirely, as is any entry without a name. An unknown conclusion counts as a + failure so it shows up rather than vanishing. + """ + name = entry.get("name") or entry.get("context") + if not name: + return None + if entry.get("__typename") == "StatusContext" or "state" in entry: + state = entry.get("state") + if not state: + return None + return name, CONTEXT_STATES.get(state, "failure") + status = entry.get("status") + if status and status != "COMPLETED": + return name, "pending" + conclusion = entry.get("conclusion") + if not conclusion: + return None + return name, CHECK_CONCLUSIONS.get(conclusion, "failure") + + +def summarise_rollup(entries: list[dict] | None) -> CISummary: + summary = CISummary() + for entry in entries or []: + classified = classify_check(entry) + if classified is None: + continue + name, bucket = classified + summary.counts[bucket] += 1 + if bucket == "failure": + summary.failing.append(name) + return summary + + +# --- probes ---------------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Outcome: + status: str + evidence: str + links: tuple[tuple[str, str], ...] = () + + +class Tree(Protocol): + def files(self, sha: str) -> list[str]: ... + def show(self, sha: str, path: str) -> str | None: ... + + +class GitTree: + """Files at a commit, read through gitref without touching any working tree.""" + + def __init__(self) -> None: + self._files: dict[str, list[str]] = {} + + def files(self, sha: str) -> list[str]: + if sha not in self._files: + self._files[sha] = gitref.git("ls-tree", "-r", "--name-only", sha).splitlines() + return self._files[sha] + + def show(self, sha: str, path: str) -> str | None: + return gitref.show(sha, path) + + +@dataclass +class Context: + tree: Tree + prs: dict[str, dict] | None # branch -> PR; None when the PR list could not be read + slow: bool = False + slow_only: frozenset[str] = frozenset() # with slow: run just these ids; empty runs all + cache: dict[str, dict] = field(default_factory=dict) + worktree: Callable[[str], contextlib.AbstractContextManager[Path]] = gitref.detached_worktree + now: Callable[[], dt.datetime] = lambda: dt.datetime.now().astimezone() + + +def blob_link(sha: str, path: str) -> tuple[str, str]: + return path, f"{BLOB_URL}/{sha}/{path}" + + +def short_home(path: str | Path) -> str: + text = str(path) + home = str(Path.home()) + return "~" + text[len(home) :] if text.startswith(home + os.sep) else text + + +def probe_file(probe: dict, sha: str, ctx: Context) -> Outcome: + paths = probe["paths"] or [probe["path"]] + present = set(ctx.tree.files(sha)) + found = [p for p in paths if p in present] + missing = [p for p in paths if p not in present] + links = tuple(blob_link(sha, p) for p in found) + if missing: + status = "partial" if found else "gap" + return Outcome(status, f"missing at {sha[:8]}: {', '.join(missing)}", links) + return Outcome("met", f"present at {sha[:8]}", links) + + +def _matching_files(sha: str, globs: list[str], ctx: Context) -> list[str]: + return [f for f in ctx.tree.files(sha) if any(fnmatch.fnmatchcase(f, g) for g in globs)] + + +def probe_grep(probe: dict, sha: str, ctx: Context) -> Outcome: + files = _matching_files(sha, probe["paths"], ctx) + texts = {f: ctx.tree.show(sha, f) or "" for f in files} + need = probe["min_matches"] + + def search(pattern: str) -> tuple[int, list[str]]: + rx = re.compile(pattern, re.MULTILINE) + hits = {f: len(rx.findall(t)) for f, t in texts.items()} + return sum(hits.values()), [f for f, n in hits.items() if n] + + items = _each_items(probe) + if not items: + total, where = search(probe["pattern"]) + links = tuple(blob_link(sha, f) for f in where[:3]) + if total >= need: + return Outcome("met", f"{total} matches in {len(where)} files", links) + scope = f"{len(files)} files" if files else "no files match " + ", ".join(probe["paths"]) + return Outcome("gap", f"{total} of {need} matches needed, searched {scope}", links) + + found: list[str] = [] + missing: list[str] = [] + where_all: list[str] = [] + for label, fragment in items: + total, where = search(probe["pattern"].replace("{each}", fragment)) + (found if total >= need else missing).append(label) + where_all += [f for f in where if f not in where_all] + links = tuple(blob_link(sha, f) for f in where_all[:3]) + if not missing: + return Outcome("met", f"all {len(found)} found", links) + if not files: + return Outcome("gap", "no files match " + ", ".join(probe["paths"])) + evidence = f"missing {', '.join(missing)}" + if found: + return Outcome("partial", f"found {', '.join(found)}; {evidence}", links) + return Outcome("gap", f"none found in {len(files)} files; {evidence}", links) + + +def _tail(output: str, n: int = 3) -> str: + lines = [" ".join(line.split()) for line in output.splitlines() if line.strip()] + return " / ".join(lines[-n:]) or "(no output)" + + +def run_shell(run: str, cwd: Path, timeout_s: float) -> tuple[int | None, str]: + """(exit code, combined output); exit code None on timeout. Kills the whole process group, + since a shell's children (uv, pytest) would otherwise outlive it and hold the pipe open.""" + env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} + proc = subprocess.Popen( + run, + shell=True, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + try: + out, _ = proc.communicate(timeout=timeout_s) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + out, _ = proc.communicate() + return None, out + return proc.returncode, out + + +def probe_command(metric: Metric, sha: str, ctx: Context) -> Outcome: + probe = metric.probe + if probe["slow"] and not (ctx.slow and (not ctx.slow_only or metric.id in ctx.slow_only)): + cached = ctx.cache.get(metric.id) + if cached and cached.get("sha") == sha: + return Outcome( + "not run", + f"slow; last run {cached['ran_at']}: {cached['status']}, {cached['evidence']}", + ) + return Outcome("not run", "slow; run with --slow") + with ctx.worktree(sha) as root: + cwd = root / probe["cwd"] + if not cwd.is_dir(): + return Outcome("gap", f"no {probe['cwd']}/ at {sha[:8]}") + code, out = run_shell(probe["run"], cwd, probe["timeout_s"]) + if code is None: + outcome = Outcome("gap", f"timed out after {probe['timeout_s']} s: {_tail(out)}") + else: + outcome = Outcome("met" if code == 0 else "gap", f"exit {code}: {_tail(out)}") + ctx.cache[metric.id] = { + "sha": sha, + "ran_at": ctx.now().strftime("%Y-%m-%d %H:%M %Z"), + "status": outcome.status, + "evidence": outcome.evidence, + } + return outcome + + +def lookup(data: Any, dotted: str) -> Any: + """Value at a dotted path; list elements by integer index. Raises KeyError when absent.""" + for part in dotted.split("."): + if isinstance(data, dict) and part in data: + data = data[part] + elif isinstance(data, list) and re.fullmatch(r"-?\d+", part): + try: + data = data[int(part)] + except IndexError as e: + raise KeyError(dotted) from e + else: + raise KeyError(dotted) + return data + + +def compare(value: Any, probe: dict) -> tuple[bool, str]: + """(passed, description of the value) for the report's single comparator.""" + if probe.get("equals") is not None: + return value == probe["equals"], f"{json.dumps(value)} (want {probe['equals']!r})" + if probe.get("less_than") is not None: + ok = isinstance(value, int | float) and not isinstance(value, bool) + return ok and value < probe["less_than"], f"{value} (want < {probe['less_than']})" + if not isinstance(value, list | dict | str): + return False, f"{json.dumps(value)} has no length" + if probe.get("length_equals") is not None: + return len(value) == probe["length_equals"], ( + f"length {len(value)} (want {probe['length_equals']})" + ) + return len(value) >= probe["length_at_least"], ( + f"length {len(value)} (want at least {probe['length_at_least']})" + ) + + +def _read_report(path: Path) -> dict | None: + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _value_or_none(data: dict, key: str) -> Any: + try: + return lookup(data, key) + except KeyError: + return None + + +def probe_report(probe: dict, sha: str | None) -> Outcome: + stamped = [] + for p in map(Path, globlib.glob(os.path.expanduser(probe["glob"]))): + try: # other runs write and remove reports while the board is built + stamped.append((p.stat().st_mtime, str(p), p)) + except OSError: + continue + paths = [p for *_, p in sorted(stamped, reverse=True)] + if not paths: + return Outcome("no evidence yet", f"no report matches {probe['glob']}") + reports = [(p, _read_report(p)) for p in paths] + sha_key = probe["sha_key"] + + def measured_at(data: dict | None) -> str: + value = _value_or_none(data or {}, sha_key) + return f"{sha_key} {str(value)[:8]}" if value else f"no {sha_key}" + + if probe["require_current_sha"]: + if sha is None: + raise ValueError("require_current_sha needs the ref's SHA") + current = [ + (p, d) for p, d in reports if d is not None and _value_or_none(d, sha_key) == sha + ] + if not current: + # Cite the newest run that measured this SHA at all: a later run that did not (an + # e2e run without an app export) says nothing about which commit was checked. + measured = [(p, d) for p, d in reports if d and _value_or_none(d, sha_key)] + newest, data = (measured or reports)[0] + return Outcome( + "stale", + f"report for {measured_at(data)}, ref at {sha[:8]}: {short_home(newest)}", + ) + reports = current + if probe.get("valid_key"): + valid = [(p, d) for p, d in reports if d and _value_or_none(d, probe["valid_key"]) is True] + if not valid: + newest, data = reports[0] + return Outcome( + "gap", + f"{probe['valid_key']} is not true in the newest report ({measured_at(data)}): " + f"{short_home(newest)}", + ) + reports = valid + # A run that did not exercise this metric leaves its key null; use the newest that did. + with_value = [ + (p, d) for p, d in reports if d is not None and _value_or_none(d, probe["key"]) is not None + ] + if not with_value: + path, data = reports[0] + if data is None: + return Outcome("gap", f"unreadable report {short_home(path)}") + return Outcome( + "no evidence yet", f"no {probe['key']} in {short_home(path)} ({measured_at(data)})" + ) + path, data = with_value[0] + value = lookup(data, probe["key"]) + ok, described = compare(value, probe) + return Outcome( + "met" if ok else "gap", + f"{probe['key']} = {described} at {measured_at(data)}: {short_home(path)}", + ) + + +def _pr_for(ref: str, ctx: Context) -> tuple[dict | None, str]: + branch = ref.removeprefix("origin/") + if ctx.prs is None: + return None, "PR list unavailable" + pr = ctx.prs.get(branch) + return pr, "" if pr else f"no open PR for {branch}" + + +def probe_pr_check(probe: dict, ref: str, sha: str, ctx: Context) -> Outcome: + pr, why = _pr_for(ref, ctx) + if pr is None: + return Outcome("no evidence yet", why) + link = (f"#{pr['number']}", f"{PR_URL}/{pr['number']}") + buckets = [ + c[1] + for c in map(classify_check, pr.get("statusCheckRollup") or []) + if c and c[0] == probe["name"] + ] + if not buckets: + return Outcome("no evidence yet", f"no check named {probe['name']!r}", (link,)) + # A re-run leaves several entries under one name; any failure or pending one decides. + bucket = next((b for b in ("failure", "pending") if b in buckets), buckets[0]) + head = pr["headRefOid"] + if bucket == "success" and head != sha: + return Outcome("stale", f"{probe['name']} passed for {head[:8]}, ref at {sha[:8]}", (link,)) + status = {"success": "met", "failure": "gap", "pending": "not run"}.get(bucket, "gap") + return Outcome(status, f"{probe['name']}: {bucket} at {head[:8]}", (link,)) + + +def probe_pr_body(probe: dict, ref: str, ctx: Context) -> Outcome: + pr, why = _pr_for(ref, ctx) + if pr is None: + return Outcome("no evidence yet", why) + link = (f"#{pr['number']}", f"{PR_URL}/{pr['number']}") + n = len(re.findall(probe["pattern"], pr.get("body") or "", re.MULTILINE)) + need = probe["min_matches"] + return Outcome("met" if n >= need else "gap", f"{n} of {need} matches in the PR body", (link,)) + + +def needs_sha(metric: Metric) -> bool: + if metric.kind == "manual": + return False + if metric.kind == "report": + return bool(metric.probe["require_current_sha"]) + return True + + +def evaluate(metric: Metric, sha: str | None, ctx: Context) -> Outcome: + if sha is None and needs_sha(metric): + return Outcome("ref missing", f"{metric.ref} does not exist") + p = metric.probe + match metric.kind: + case "manual": + outcome = Outcome(p["status"], p["evidence"]) + case "file_at_ref": + outcome = probe_file(p, sha, ctx) + case "grep_at_ref": + outcome = probe_grep(p, sha, ctx) + case "command": + outcome = probe_command(metric, sha, ctx) + case "report": + outcome = probe_report(p, sha) + case "pr_check": + outcome = probe_pr_check(p, metric.ref, sha, ctx) + case "pr_body": + outcome = probe_pr_body(p, metric.ref, ctx) + case _: + raise MetricsError(f"{metric.id}: unknown probe {metric.kind}") + if p.get("max_status") == "partial" and outcome.status == "met": + return Outcome("partial", f"{outcome.evidence} ({p['cap_reason']})", outcome.links) + return outcome + + +# --- board ----------------------------------------------------------------------------------- + + +@dataclass +class Row: + id: str + text: str + ref: str + sha: str | None + status: str + evidence: str + links: list[list[str]] + + +@dataclass +class Board: + generated_at: str + command: str + slow: bool | list[str] # True, False, or the ids of the only slow probes run + prs: list[dict] | None + pr_error: str | None + workstreams: list[dict] + + +def pr_rows(prs: list[dict]) -> list[dict]: + rows = [] + for pr in sorted(prs, key=lambda p: -p["number"]): + ci = summarise_rollup(pr.get("statusCheckRollup")) + rows.append( + { + "number": pr["number"], + "title": pr["title"], + "branch": pr["headRefName"], + "head": pr["headRefOid"], + "draft": bool(pr["isDraft"]), + "updated": pr["updatedAt"], + "ci": {"counts": ci.counts, "failing": ci.failing, "text": ci.text()}, + } + ) + return rows + + +def build_board( + titles: dict[str, str], + metrics: list[Metric], + shas: dict[str, str | None], + prs: list[dict] | None, + ctx: Context, + pr_error: str | None = None, +) -> Board: + streams = [] + for ws, title in titles.items(): + rows = [] + for m in (m for m in metrics if m.workstream == ws): + sha = shas[m.ref] + outcome = evaluate(m, sha, ctx) + links = [list(link) for link in outcome.links] + rows.append(Row(m.id, m.text, m.ref, sha, outcome.status, outcome.evidence, links)) + refs = list(dict.fromkeys(r.ref for r in rows)) + streams.append( + { + "id": ws, + "title": title, + "refs": [{"ref": r, "sha": shas[r]} for r in refs], + "metrics": [asdict(r) for r in rows], + } + ) + return Board( + generated_at=ctx.now().isoformat(timespec="seconds"), + command=COMMAND, + slow=sorted(ctx.slow_only) if ctx.slow and ctx.slow_only else ctx.slow, + prs=pr_rows(prs) if prs is not None else None, + pr_error=pr_error, + workstreams=streams, + ) + + +def _cell(text: object) -> str: + return str(text).replace("|", "\\|").replace("\n", " ") + + +def _local(iso: str) -> str: + stamp = dt.datetime.fromisoformat(iso.replace("Z", "+00:00")) + return stamp.astimezone().strftime("%Y-%m-%d %H:%M %Z") + + +def render_markdown(board: Board) -> str: + if isinstance(board.slow, list): + slow = f"slow probes ran for {', '.join(board.slow)} only" + else: + slow = "slow probes ran" if board.slow else "slow probes not run (add ARGS=--slow)" + lines = [ + "# Scoreboard", + "", + f"Generated {_local(board.generated_at)} by `make scoreboard` (`{board.command}`) in " + f"`verification/`; {slow}. Probes are defined in `verification/scoreboard/metrics.yaml`.", + "", + "## Open pull requests", + "", + ] + if board.prs is None: + lines.append(f"Could not list PRs: {board.pr_error}") + elif not board.prs: + lines.append("No open pull requests.") + else: + lines += [ + "| # | Branch | Head | Draft | CI | Updated |", + "| --- | --- | --- | --- | --- | --- |", + ] + lines += [ + f"| [#{p['number']}]({PR_URL}/{p['number']}) | `{p['branch']}` | " + f"`{p['head'][:8]}` | {'yes' if p['draft'] else 'no'} | {_cell(p['ci']['text'])} | " + f"{_local(p['updated'])} |" + for p in board.prs + ] + for ws in board.workstreams: + refs = ", ".join( + f"`{r['ref']}` at [`{r['sha'][:8]}`]({TREE_URL}/{r['sha']})" + if r["sha"] + else f"`{r['ref']}` (missing)" + for r in ws["refs"] + ) + counts = dict.fromkeys(STATUSES, 0) + for m in ws["metrics"]: + counts[m["status"]] += 1 + tally = ", ".join(f"{n} {s}" for s, n in counts.items() if n) + lines += ["", f"## {ws['id']} {ws['title']}", "", f"{refs}. {tally}.", ""] + lines += ["| Metric | Text | Status | Evidence |", "| --- | --- | --- | --- |"] + for m in ws["metrics"]: + evidence = _cell(m["evidence"]) + if m["links"]: + evidence += " " + ", ".join(f"[{_cell(label)}]({url})" for label, url in m["links"]) + lines.append(f"| {m['id']} | {_cell(m['text'])} | {m['status']} | {evidence} |") + return "\n".join(lines) + "\n" + + +# --- I/O ------------------------------------------------------------------------------------- + + +# gh can wait indefinitely for the network or a login; the board is written without PRs instead. +PR_LIST_TIMEOUT_S = 60 + + +def list_prs() -> list[dict]: + out = subprocess.run( + ["gh", "pr", "list", "--repo", GITHUB_REPO, "--state", "open", "--json", PR_FIELDS], + check=True, + capture_output=True, + text=True, + timeout=PR_LIST_TIMEOUT_S, + ).stdout + return json.loads(out) + + +def resolve_refs(refs: list[str]) -> dict[str, str | None]: + return {r: gitref.resolve(r) if gitref.ref_exists(r) else None for r in refs} + + +@contextlib.contextmanager +def cached(path: Path) -> Iterator[dict]: + data = json.loads(path.read_text()) if path.exists() else {} + yield data + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--slow", + nargs="*", + metavar="ID", + help="also run slow command probes; with ids, only those", + ) + parser.add_argument("--no-fetch", action="store_true", help="skip `git fetch origin`") + args = parser.parse_args(argv) + + titles, metrics = load_metrics() + if not args.no_fetch: + gitref.fetch() + shas = resolve_refs(list(dict.fromkeys(m.ref for m in metrics))) + prs: list[dict] | None + pr_error = None + try: + prs = list_prs() + except ( + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + json.JSONDecodeError, + ) as e: + prs = None + # A timeout's stderr is bytes even with text=True; its own message says what happened. + stderr = getattr(e, "stderr", None) + pr_error = (stderr if isinstance(stderr, str) and stderr.strip() else str(e)).strip() + print(f"warning: could not list PRs: {pr_error}", file=sys.stderr) + with cached(CACHE_PATH) as cache: + ctx = Context( + tree=GitTree(), + prs={p["headRefName"]: p for p in prs} if prs is not None else None, + slow=args.slow is not None, + slow_only=frozenset(args.slow or ()), + cache=cache, + ) + board = build_board(titles, metrics, shas, prs, ctx, pr_error) + markdown = render_markdown(board) + MARKDOWN_PATH.write_text(markdown) + JSON_PATH.write_text(json.dumps(asdict(board), indent=2) + "\n") + print(markdown, end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verification/hsverify/simrun.py b/verification/hsverify/simrun.py new file mode 100644 index 000000000..235d96d8e --- /dev/null +++ b/verification/hsverify/simrun.py @@ -0,0 +1,673 @@ +"""Build the iOS app from a git ref, run it in a dedicated Simulator, screenshot every STATE. + + make sim-app # t3/ios-mvf on the ADVIO replay, uploading to a server started from t3/server + uv run python -m hsverify.simrun --ref --replay --autopilot --server-ref + +The app is launched with the contract C4 arguments (`-replay `, `-autopilot`, +`-serverURL `) and the runner follows its `STATE=` log markers. Each state gets +a screenshot once it has been on screen for `--settle` seconds; a state replaced sooner is +captured immediately and flagged `transient`, because its image may already show the next +state. The run ends at `--until`, after `--idle` seconds without a new state, or at +`--timeout`. A state named with `--require` that never appeared makes the run fail. If the app +logged a scan bundle, it is copied into the report as scan.zip (required with `--require-export`) +and named in report.json's +`app_export`, so the report folder can be replayed as an app export. Reports go outside git +(default ~/house-scanning-data/reports/sim/) because a replay can put dataset frames on screen. +""" + +from __future__ import annotations + +import argparse +import contextlib +import datetime as dt +import fcntl +import json +import os +import plistlib +import queue +import re +import shlex +import shutil +import subprocess +import sys +import threading +import time +from collections.abc import Iterable +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import TextIO + +from hsverify import gitref, report +from hsverify.e2e import server_from_ref +from hsverify.memory import peak_rss_mb +from hsverify.statelog import ( + LOG_PREDICATE, + RedactedStateError, + parse_bundle_line, + parse_ndjson_line, +) + +DEVICE_NAME = "HouseScan Verify" +DEVICE_TYPE = "com.apple.CoreSimulator.SimDeviceType.iPhone-17" +DEFAULT_REPORTS = Path.home() / "house-scanning-data" / "reports" / "sim" +DERIVED_DATA = Path("/tmp/hs-verify-derived-data") +BUILD_LOCK = Path("/tmp/hs-verify-xcodebuild.lock") +BUILT_APP_DIR = "built-app" # in the report folder, until the Simulator has installed it + + +@dataclass +class ShotRecord: + index: int + state: str + seconds_after_launch: float + screenshot: str + transient: bool + log_message: str + + +@dataclass +class RunReport: + ref: str + sha: str + started_at: str + command: list[str] + device: dict + build: dict + launch_arguments: list[str] + static_c4: dict + states: list[ShotRecord] = field(default_factory=list) + end_reason: str = "" + problems: list[str] = field(default_factory=list) + crash_reports: list[str] = field(default_factory=list) + final_screenshot: str | None = None + server: dict | None = None + peak_memory: dict | None = None + app_export: str | None = None # the app's scan bundle, copied into the report folder + + +def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs) + + +def simctl(*args: str) -> str: + return run(["xcrun", "simctl", *args]).stdout.strip() + + +# --- Simulator ------------------------------------------------------------------------------- + + +def latest_ios_runtime() -> str: + runtimes = json.loads(simctl("list", "runtimes", "-j"))["runtimes"] + ios = [r for r in runtimes if r.get("platform") == "iOS" and r.get("isAvailable")] + if not ios: + raise SystemExit("No available iOS Simulator runtime. Install one in Xcode > Settings.") + ios.sort(key=lambda r: tuple(int(p) for p in r["version"].split("."))) + return ios[-1]["identifier"] + + +def ensure_device(udid: str | None = None) -> dict: + """The runner's own Simulator, so it never disturbs devices other workers are using, or + the existing device `udid` when one has been handed over (creating a device costs disk).""" + if udid is not None: + for runtime, devices in json.loads(simctl("list", "devices", "-j"))["devices"].items(): + for device in devices: + if device["udid"] == udid: + simctl("bootstatus", udid, "-b") + return {"name": device["name"], "udid": udid, "runtime": runtime} + raise SystemExit(f"No Simulator with UDID {udid}; see `xcrun simctl list devices`.") + runtime = latest_ios_runtime() + devices = json.loads(simctl("list", "devices", "-j"))["devices"].get(runtime, []) + for device in devices: + if device["name"] == DEVICE_NAME and device.get("isAvailable", True): + udid = device["udid"] + break + else: + udid = simctl("create", DEVICE_NAME, DEVICE_TYPE, runtime) + simctl("bootstatus", udid, "-b") # boots the device if it isn't already booted + return {"name": DEVICE_NAME, "udid": udid, "runtime": runtime, "type": DEVICE_TYPE} + + +def prepare_display(udid: str, appearance: str, content_size: str | None) -> None: + # A fixed status bar keeps screenshots comparable between runs. + simctl( + "status_bar", + udid, + "override", + "--time", + "9:41", + "--batteryState", + "charged", + "--batteryLevel", + "100", + "--cellularBars", + "4", + "--wifiBars", + "3", + ) + simctl("ui", udid, "appearance", appearance) + simctl("ui", udid, "content_size", content_size or "large") + + +# --- Build ----------------------------------------------------------------------------------- + + +def compiling(args: str) -> bool: + """Whether an xcodebuild command line compiles. Running prebuilt tests does not.""" + return "xcodebuild" in args and "test-without-building" not in args + + +def other_builds_running() -> bool: + listing = subprocess.run(["ps", "-Ao", "args="], capture_output=True, text=True).stdout + return any( + compiling(line) + for line in listing.splitlines() + if line.split(" ")[0].endswith("xcodebuild") + ) + + +def wait_for_other_builds(max_wait_s: float) -> float: + """The Mac is shared: wait while another xcodebuild compiles, up to `max_wait_s`.""" + start = time.monotonic() + announced = False + while True: + if not other_builds_running(): + return time.monotonic() - start + if time.monotonic() - start > max_wait_s: + raise SystemExit(f"Another xcodebuild has run for over {max_wait_s:.0f} s; try later.") + if not announced: + print("Waiting for another xcodebuild to finish (shared Mac)...", flush=True) + announced = True + time.sleep(5) + + +def build_app( + tree: Path, out: Path, max_wait_s: float, project_path: str, scheme: str +) -> tuple[dict, Path | None]: + project = tree / project_path + if not project.exists(): + return {"ok": False, "error": f"{project.relative_to(tree)} not found"}, None + log_path = out / "build.log" + cmd = [ + "xcodebuild", + "-project", + str(project), + "-scheme", + scheme, + "-configuration", + "Debug", + "-sdk", + "iphonesimulator", + "-destination", + "generic/platform=iOS Simulator", + "-derivedDataPath", + str(DERIVED_DATA), + "ARCHS=arm64", + "ONLY_ACTIVE_ARCH=YES", + "CODE_SIGNING_ALLOWED=NO", + "build", + ] + BUILD_LOCK.touch() + with BUILD_LOCK.open() as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + waited = wait_for_other_builds(max_wait_s) + start = time.monotonic() + with log_path.open("w") as log: + code = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT).returncode + seconds = time.monotonic() - start + # DerivedData is shared: once the lock is released another run may rebuild over this + # app before it is installed, so this run installs its own copy. + built = DERIVED_DATA / "Build" / "Products" / "Debug-iphonesimulator" / f"{scheme}.app" + app = None + if code == 0 and built.exists(): + app = out / BUILT_APP_DIR / built.name + shutil.rmtree(app.parent, ignore_errors=True) + shutil.copytree(built, app, symlinks=True) + text = log_path.read_text(errors="replace") + warnings = sorted(set(re.findall(r"^.*: warning: .*$", text, re.M))) + errors = sorted(set(re.findall(r"^.*: error: .*$", text, re.M))) + result = { + "ok": code == 0, + "seconds": round(seconds, 1), + "waited_for_other_builds_s": round(waited, 1), + "command": shlex.join(cmd), + "warnings": [shorten(w, tree) for w in warnings], + "errors": [shorten(e, tree) for e in errors], + "log": log_path.name, + } + return result, app + + +def discard_app(app: Path) -> None: + """Remove this run's copy of the app once the Simulator has installed its own.""" + if app.parent.name == BUILT_APP_DIR: + shutil.rmtree(app.parent, ignore_errors=True) + + +def bundle_id_of(app: Path) -> str: + with (app / "Info.plist").open("rb") as f: + return plistlib.load(f)["CFBundleIdentifier"] + + +def shorten(line: str, tree: Path) -> str: + return line.replace(str(tree) + "/", "") + + +# --- Static contract check ------------------------------------------------------------------- + + +def static_c4_check(tree: Path) -> dict: + """Which C4 pieces appear in the app's Swift source. Presence is not proof they work.""" + sources = "\n".join( + p.read_text(errors="replace") for p in (tree / "ios").rglob("*.swift") if p.is_file() + ) + return { + "replay_argument": "-replay" in sources or '"replay"' in sources, + "autopilot_argument": "-autopilot" in sources or '"autopilot"' in sources, + "server_url_argument": "-serverURL" in sources or '"serverURL"' in sources, + "state_subsystem": "dev.housescanning.housescan" in sources, + "state_marker": "STATE=" in sources, + "public_privacy": "privacy: .public" in sources, + } + + +# --- Run ------------------------------------------------------------------------------------- + + +def log_stream_command(udid: str) -> list[str]: + return [ + "xcrun", + "simctl", + "spawn", + udid, + "log", + "stream", + "--style", + "ndjson", + "--level", + "debug", + "--predicate", + LOG_PREDICATE, + ] + + +def pump_log(lines: Iterable[str], events: queue.Queue, sink: TextIO) -> None: + """Copy `log stream` output to `sink` and queue what the runner acts on. + + The first line is the stream's "Filtering the log data" banner, printed once it is attached; + it is queued as ("ready", line) so the app is launched only after that, and no early STATE + is lost. Starting the process is not enough: it prints nothing until attached. + """ + ready = False + for line in lines: + sink.write(line) + sink.flush() + if not ready: + events.put(("ready", line.strip())) + ready = True + try: + event = parse_ndjson_line(line) + except RedactedStateError as exc: + events.put(("redacted", str(exc))) + continue + if event is not None: + events.put(("state", event)) + + +def wait_for_log_stream(events: queue.Queue, timeout_s: float) -> str | None: + """None once the stream's first line arrived; otherwise the problem to report.""" + try: + kind, _ = events.get(timeout=timeout_s) + except queue.Empty: + return f"log stream printed nothing within {timeout_s:.0f} s; the app was not launched" + if kind != "ready": + return f"log stream sent {kind!r} before its first line" + return None + + +def missing_required(required: list[str], states: list[ShotRecord]) -> list[str]: + seen = {s.state for s in states} + return [f"required state {name} never appeared" for name in required if name not in seen] + + +def logged_message(line: str) -> str: + """The message of one ndjson log line, or "" for the banner and anything unreadable.""" + try: + return json.loads(line).get("eventMessage") or "" + except (json.JSONDecodeError, AttributeError): + return "" + + +def copy_app_export(out: Path) -> tuple[str | None, list[str]]: + """Copy the scan bundle the app last logged into `out` as scan.zip. + + Returns (file name or None, problems). A run whose app never logged a bundle has no export + and no problem; a logged bundle that cannot be copied is a problem. + """ + log = out / "state.ndjson" + lines = log.read_text().splitlines() if log.exists() else [] + bundles = [b for line in lines if (b := parse_bundle_line(line))] + if not bundles: + if any(logged_message(line).startswith("bundle ") for line in lines): + return None, ["the app logged a bundle line this runner cannot read; see state.ndjson"] + return None, [] + path, keyframes = bundles[-1] + try: + shutil.copyfile(path, out / "scan.zip") + except OSError as exc: + return None, [ + f"the app logged bundle {path} with {keyframes} keyframes; copy failed: {exc}" + ] + return "scan.zip", [] + + +def app_export(out: Path, required: bool) -> tuple[str | None, list[str]]: + """copy_app_export, where a run that must keep the app's scan fails without one.""" + name, problems = copy_app_export(out) + if required and name is None and not problems: + problems = [ + "The run was required to keep the app's scan (--require-export), but the app logged " + "no bundle." + ] + return name, problems + + +def app_running(udid: str, bundle_id: str) -> bool: + out = subprocess.run( + ["xcrun", "simctl", "spawn", udid, "launchctl", "list"], capture_output=True, text=True + ).stdout + return f"UIKitApplication:{bundle_id}" in out + + +def screenshot(udid: str, path: Path) -> None: + simctl("io", udid, "screenshot", "--type=png", str(path)) + + +def seconds_since(launch_wall: float, log_timestamp: str, fallback: float) -> float: + """Seconds from launch to the log entry, by the log's own clock. + + Arrival time at the runner lags behind while a screenshot is being taken, so the log + timestamp is preferred; the arrival time is used only if the timestamp does not parse. + """ + try: + logged = dt.datetime.strptime(log_timestamp, "%Y-%m-%d %H:%M:%S.%f%z").timestamp() + except ValueError: + return round(fallback, 2) + return round(logged - launch_wall, 2) + + +def slug(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "-", text).strip("-")[:60] + + +def follow(args, udid: str, bundle_id: str, out: Path, rep: RunReport) -> None: + events: queue.Queue = queue.Queue() + proc = subprocess.Popen( + log_stream_command(udid), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True + ) + assert proc.stdout is not None + with (out / "state.ndjson").open("w") as sink: + reader = threading.Thread(target=pump_log, args=(proc.stdout, events, sink), daemon=True) + reader.start() + try: + problem = wait_for_log_stream(events, args.log_ready_timeout) + if problem: + rep.end_reason = "log stream did not start" + rep.problems.append(problem) + return + follow_launched(args, udid, bundle_id, out, rep, events) + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + reader.join(timeout=10) + + +def follow_launched( + args, udid: str, bundle_id: str, out: Path, rep: RunReport, events: queue.Queue +) -> None: + launch = [ + "xcrun", + "simctl", + "launch", + "--terminate-running-process", + f"--stdout={out / 'app-stdout.log'}", + f"--stderr={out / 'app-stderr.log'}", + udid, + bundle_id, + *rep.launch_arguments, + ] + run(launch) + launched = time.monotonic() + launched_wall = time.time() + last_event = launched + pending = None # (StateEvent, arrival time) waiting for its settled screenshot + redaction_noted = False + last_alive_check = launched + + def shoot(event, arrived: float, transient: bool) -> None: + index = len(rep.states) + 1 + name = f"{index:02d}-{slug(event.name)}.png" + screenshot(udid, out / name) + rep.states.append( + ShotRecord( + index, + event.name, + seconds_since(launched_wall, event.timestamp, arrived - launched), + name, + transient, + event.message, + ) + ) + + while True: + now = time.monotonic() + if now - launched > args.timeout: + rep.end_reason = f"timeout after {args.timeout:.0f} s" + break + wait = 0.25 + if pending is not None: + wait = max(0.0, min(wait, pending[1] + args.settle - now)) + try: + kind, payload = events.get(timeout=wait) + except queue.Empty: + kind, payload = None, None + + if kind == "redacted" and not redaction_noted: + rep.problems.append( + "STATE markers are logged as ; log the name with privacy: .public " + f"(saw: {payload!r})" + ) + redaction_noted = True + if kind == "state": + if pending is not None: + shoot(pending[0], pending[1], transient=True) + pending = (payload, time.monotonic()) + last_event = pending[1] + print(f" STATE={payload.name}", flush=True) + continue + + now = time.monotonic() + if pending is not None and now - pending[1] >= args.settle: + shoot(pending[0], pending[1], transient=False) + finished = args.until and pending[0].name in args.until + pending = None + if finished: + rep.end_reason = f"reached {rep.states[-1].state}" + break + if pending is None and now - last_event > args.idle: + rep.end_reason = f"no new state for {args.idle:.0f} s" + break + if pending is None and now - last_alive_check > 5: + last_alive_check = now + if not app_running(udid, bundle_id): + rep.end_reason = "app is no longer running" + rep.problems.append("The app exited or crashed during the run.") + break + + if pending is not None: + shoot(pending[0], pending[1], transient=False) + final = out / "final.png" + screenshot(udid, final) + rep.final_screenshot = final.name + if not rep.states: + rep.problems.append( + "No STATE markers arrived. Either C4 logging is not implemented on this ref or " + "the app never reached its first screen; final.png shows what was on screen." + ) + + +def collect_crashes(since: float, out: Path) -> list[str]: + found = [] + reports = Path.home() / "Library" / "Logs" / "DiagnosticReports" + for path in reports.glob("HouseScan*"): + if path.stat().st_mtime >= since: + target = out / path.name + target.write_bytes(path.read_bytes()) + found.append(path.name) + return found + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--ref", default="origin/main", help="git ref to build") + parser.add_argument("--project", default="ios/HouseScan.xcodeproj", help="path in the ref") + parser.add_argument("--scheme", default="HouseScan", help="also the .app name") + parser.add_argument("--replay", type=Path, help="replay session folder (contract C3)") + parser.add_argument("--autopilot", action="store_true") + server = parser.add_mutually_exclusive_group() + server.add_argument("--server-url", help="a running placement server") + server.add_argument( + "--server-ref", help="start the placement server from this ref for the run (S2 API)" + ) + parser.add_argument("--extra-arg", action="append", default=[], help="more launch args") + parser.add_argument("--until", action="append", default=[], help="state that ends the run") + parser.add_argument( + "--require", + action="append", + default=[], + metavar="STATE", + help="a state the run must reach; one that never appears is a problem (exit 1)", + ) + parser.add_argument( + "--require-export", + action="store_true", + help="fail unless the app logs a scan bundle, which is copied into the report as scan.zip", + ) + parser.add_argument( + "--log-ready-timeout", + type=float, + default=30.0, + help="seconds to wait for `log stream` to print its first line before launching", + ) + parser.add_argument("--settle", type=float, default=1.2, help="seconds before a screenshot") + parser.add_argument("--idle", type=float, default=25.0, help="stop after this long quiet") + parser.add_argument("--timeout", type=float, default=300.0) + parser.add_argument("--appearance", choices=["light", "dark"], default="light") + parser.add_argument( + "--content-size", help="Dynamic Type size, e.g. accessibility-extra-extra-extra-large" + ) + parser.add_argument("--out", type=Path, help="report folder (default under reports/sim)") + parser.add_argument("--label", default="", help="short name added to the report folder") + parser.add_argument("--keep-worktree", action="store_true") + parser.add_argument("--udid", help="run on this existing Simulator instead of the runner's own") + parser.add_argument("--keep-booted", action="store_true", help="leave the Simulator on") + parser.add_argument("--build-wait", type=float, default=1200.0) + 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, slug(args.ref.removeprefix("origin/")), sha[:8], slug(args.label)]) + ) + out = (args.out or DEFAULT_REPORTS / name).expanduser() + out.mkdir(parents=True, exist_ok=True) + + launch_args: list[str] = [] + if args.replay: + replay = args.replay.expanduser().resolve() + if not replay.exists(): + raise SystemExit(f"Replay not found: {replay}") + launch_args += ["-replay", str(replay)] + if args.autopilot: + launch_args.append("-autopilot") + if args.server_url: + launch_args += ["-serverURL", args.server_url] + launch_args += args.extra_arg + + print(f"Report: {out}\nRef {args.ref} at {sha[:12]}", flush=True) + with gitref.detached_worktree(sha, keep=args.keep_worktree) as tree: + static = static_c4_check(tree) + print("Building for the Simulator...", flush=True) + build, app = build_app(tree, out, args.build_wait, args.project, args.scheme) + print( + f" build ok={build['ok']} in {build.get('seconds')} s, " + f"{len(build.get('warnings', []))} warnings", + flush=True, + ) + + device = ensure_device(args.udid) + rep = RunReport( + ref=args.ref, + sha=sha, + started_at=dt.datetime.now().isoformat(timespec="seconds"), + command=[sys.executable, "-m", "hsverify.simrun", *(argv or sys.argv[1:])], + device=device | {"appearance": args.appearance, "content_size": args.content_size}, + build=build, + launch_arguments=launch_args, + static_c4=static, + ) + started = time.time() + services = contextlib.ExitStack() + try: + if app is None: + rep.end_reason = "build failed" + rep.problems.append("The app did not build; see build.log.") + else: + if args.server_ref: + server_sha = gitref.resolve(args.server_ref) + url, _ = services.enter_context(server_from_ref(server_sha, out / "server.log")) + launch_args += ["-serverURL", url] + rep.server = {"ref": args.server_ref, "sha": server_sha, "url": url} + print(f"Server {args.server_ref} at {server_sha[:12]} on {url}", flush=True) + bundle_id = bundle_id_of(app) + rep.device["bundle_id"] = bundle_id + udid = device["udid"] + prepare_display(udid, args.appearance, args.content_size) + subprocess.run(["xcrun", "simctl", "uninstall", udid, bundle_id], capture_output=True) + simctl("install", udid, str(app)) + discard_app(app) + # The Simulator has no camera, but granting access keeps the permission prompt from + # hiding the replay flow. The prompt itself is reviewed on a device. + subprocess.run( + ["xcrun", "simctl", "privacy", udid, "grant", "camera", bundle_id], + capture_output=True, + ) + print("Running...", flush=True) + follow(args, udid, bundle_id, out, rep) + rep.problems += missing_required(args.require, rep.states) + rep.app_export, export_problems = app_export(out, args.require_export) + rep.problems += export_problems + rep.crash_reports = collect_crashes(started, out) + if rep.crash_reports: + rep.problems.append(f"Crash reports: {', '.join(rep.crash_reports)}") + finally: + services.close() + rep.peak_memory = peak_rss_mb() + if not args.keep_booted: + subprocess.run(["xcrun", "simctl", "shutdown", device["udid"]], capture_output=True) + data = asdict(rep) + (out / "report.json").write_text(json.dumps(data, indent=2) + "\n") + report.write_sim_report(out, data) + print(f"{len(rep.states)} states; end: {rep.end_reason}") + for problem in rep.problems: + print(f"PROBLEM: {problem}") + print(f"Open {out / 'index.html'}") + return 0 if build["ok"] and not rep.problems else 1 + + +if __name__ == "__main__": + os.environ.setdefault("PYTHONUNBUFFERED", "1") + sys.exit(main()) diff --git a/verification/hsverify/statelog.py b/verification/hsverify/statelog.py new file mode 100644 index 000000000..b13f7ccd9 --- /dev/null +++ b/verification/hsverify/statelog.py @@ -0,0 +1,93 @@ +"""Reading the app's STATE markers (contract C4) out of `log stream --style ndjson` output. + +The app logs `STATE=` under subsystem `dev.housescanning.housescan`, category `state`. +Everything here is pure so the parsing rules can be tested without a Simulator. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass + +SUBSYSTEM = "dev.housescanning.housescan" +CATEGORY = "state" +# Every category of the app is streamed into the report (engine and autopilot lines explain what +# happened); only `state` lines are parsed as screens. +LOG_PREDICATE = f'subsystem == "{SUBSYSTEM}"' + +ENGINE_CATEGORY = "engine" +# The engine logs `bundle with keyframes` once it has written a scan bundle; the path +# is on the Mac's disk, inside the Simulator's app container. +# "bundle .zip with N keyframes", optionally followed by ",
    " (app 194f2eb adds +# depth and mesh counts); from app f14947e, "bundle .zip: packet 1.1 with N photos ...". +_BUNDLE_RE = re.compile( + r"^bundle (.+\.zip)(?: with (\d+) keyframes(?:,.*)?|: .*?\bwith (\d+) photos\b.*)$" +) + +# A state name is an identifier-like token. Anything after it on the line is detail. +_STATE_RE = re.compile(r"STATE=([A-Za-z0-9_.\-]+)") +_REDACTED = "" + + +@dataclass(frozen=True) +class StateEvent: + name: str + timestamp: str # as printed by `log stream`, kept verbatim for the report + message: str + + +class RedactedStateError(ValueError): + """The app logged the marker as a private string, so `log stream` shows ``. + + The fix is on the app side: log the name with `privacy: .public`. + """ + + +def _app_entry(line: str, category: str) -> dict | None: + line = line.strip() + if not line.startswith("{"): + return None + try: + entry = json.loads(line) + except json.JSONDecodeError: + return None + if entry.get("subsystem") != SUBSYSTEM or entry.get("category") != category: + return None + return entry + + +def parse_bundle_line(line: str) -> tuple[str, int] | None: + """(zip path, keyframe or photo count) from the engine's bundle line, or None for any other + line.""" + entry = _app_entry(line, ENGINE_CATEGORY) + match = _BUNDLE_RE.match((entry or {}).get("eventMessage") or "") + if not match: + return None + return match.group(1), int(match.group(2) or match.group(3)) + + +def parse_ndjson_line(line: str) -> StateEvent | None: + """Return the STATE event on one `log stream --style ndjson` line, or None. + + Lines that are not JSON (the stream prints a banner first), entries from other + subsystems or categories, and messages without a marker all return None. + Raises RedactedStateError when the message is the redaction placeholder, because + silently dropping it would make a working app look like it logs nothing. + """ + entry = _app_entry(line, CATEGORY) + if entry is None: + return None + message = entry.get("eventMessage") or "" + match = _STATE_RE.search(message) + if match is None: + if _REDACTED in message: + raise RedactedStateError(message) + return None + return StateEvent(name=match.group(1), timestamp=entry.get("timestamp", ""), message=message) + + +def parse_plain_line(line: str) -> str | None: + """Return the state name from a plain text line such as the app's stdout, or None.""" + match = _STATE_RE.search(line) + return match.group(1) if match else None diff --git a/verification/pyproject.toml b/verification/pyproject.toml new file mode 100644 index 000000000..6175f1785 --- /dev/null +++ b/verification/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "house-scanning-verification" +version = "0.1.0" +description = "Independent end-to-end checks: Simulator runs, server round trips, scoreboard" +requires-python = ">=3.12" +dependencies = [ + "jsonschema>=4.23", + "numpy>=2.5.3", + "pillow>=12.3.0", + "pyyaml>=6.0", +] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "ruff>=0.16.9", +] + +[tool.uv] +package = false +required-version = ">=0.11.15,<0.12" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = ["--strict-markers", "--strict-config"] diff --git a/verification/scenes/eth3d-facade/README.md b/verification/scenes/eth3d-facade/README.md new file mode 100644 index 000000000..26fe14937 --- /dev/null +++ b/verification/scenes/eth3d-facade/README.md @@ -0,0 +1,135 @@ +# ETH3D facade: a real-geometry test scene + +`scene.json` for the placement server, built from a real building: the south courtyard wing of the +ETH3D "facade" dataset (an ETH Zurich building, DSLR photos plus a metric laser scan). Doors, +windows and walls are real and measured. The electric meter is invented, because the building has +none where we need it. `case.json` says what a correct server must and must not answer, in the +format the e2e checker reads. + +## License + +ETH3D data is for research use only. Nothing from it is in this repo: no images, points or crops. +The script reads the dataset from outside the repo and writes its output there too. + +## Build + +Data: `facade_dslr_undistorted.7z` and `facade_dslr_scan_eval.7z` from eth3d.net, extracted to +`~/house-scanning-data/verify/eth3d/` (it creates `facade/`). Then, from `verification/`: + +``` +uv run python scenes/eth3d-facade/build_scene.py [--schema path/to/scene.schema.json] [--overlays] +``` + +This writes `~/house-scanning-data/verify/scenes/eth3d-facade/`: `scene.json`, `scene.zip` +(scene.json plus 7 keyframes at the top level), `bundle/`, and `build_report.json` with every fit +statistic. `--overlays` also draws the wall chain and the annotations on each keyframe. The run +takes about 10 s and is deterministic (fixed RANSAC seed). The output validates against +`server/schemas/scene.schema.json` from `origin/t3/server` (44607e0). + +## How each number is made + +- **Frame.** The laser scan and the COLMAP poses share one metric frame. I checked this by + projecting scan points into DSC_0419, where walls, window edges and the door line up. The ground + is a RANSAC plane fitted to scan points in the courtyard: 1.86 M inliers, 0.97 cm RMS. Its normal + becomes +y, so heights are above the local ground. The normal is 1.30 degrees off the scanner's + vertical, which is about the gravel's drainage slope, so this frame is not exactly gravity. + Scene x is scan x made horizontal, z = x cross y. All lengths are feet. The origin is the ground + point under the invented meter. +- **Walls.** Each wall's ground line is a RANSAC line fitted in plan to laser points 0.3-8 ft + high near a rough segment that I read off a top view (`annotations.json` `walls`). + Neighbouring lines meet at their intersection. The free ends are the last scanned points. I used + the laser scan rather than the COLMAP points because it is denser (millions of points on this + wing against 85 k for the whole SfM model) and it is the dataset's ground truth. Each wall's `plus_minus_ft` + is max(0.1, 3 x the fit's RMS). +- **Chain.** Left to right as seen from the courtyard: w1, a 52.0 ft (15.85 m) wall with five + ground-floor windows. Then w2, the 11.6 ft (3.54 m) east side of the entrance tower, which + projects 3.5 m into the courtyard. Then w3, the tower front with the entrance door, 16.2 ft + (4.95 m). Past w3 the wall steps back into w1's plane, but the scan and SfM points stop there, + so the chain ends. Both ends are `unexplored`. +- **Poses.** COLMAP gives world-to-camera `R, t` for an OpenCV camera (+y down, +z forward). + Camera-to-world is `R^T` and `C = -R^T t`. ARKit's camera has +y up and looks along -z, so + `R_c2w = A . R^T . diag(1, -1, -1)` and `C_scene = A (C - origin) / 0.3048`, where `A` rotates + scan axes into scene axes. The 16 numbers are that 4x4, column-major. Keyframes are the + undistorted images resized to 1/4, and fx, fy, cx, cy are scaled by the same factor. +- **Features.** For each image of a feature I clicked four points: one on the left jamb, one on + the right jamb, one on the sill (or door threshold) and the arch apex. Each point's ray + `((u-cx)/fx, -(v-cy)/fy, -1)` goes through the pose onto the wall's brick face. The brick face + sits 0.33 ft behind the ground line on w1, because the stone plinth stands proud. The hits give + `span_ft` (jamb positions along the chain) and `bottom_ft` / `top_ft` (heights). Every feature + uses 2 or 3 images, and the value is their mean. + `plus_minus_ft = max(0.1, largest disagreement between the images over the four values, + documented floor)`. Only the door has a floor (0.7 ft). It sits in a recess, and both photos + see it from the east, so their 0.19 ft agreement hides a shared bias that the scan exposes. +- **Coverage.** A stretch counts as observed for a band when one keyframe sees all of that band's + probe points. A point counts as seen when it is inside the image, on the camera side of its + wall, not behind another wall of the chain, and within 75 degrees of the wall's normal. The + 75-degree limit is my judgement, not a measured value. The probes are: wall, ground line and + battery height (3.29 ft); ground, out to 6 ft; overhead, 6.5 ft up at 0 and 3 ft out; facing, + 3 ft up, straight out. No camera sees w2 at better than grazing, so w2 is in no band. +- **Overheads and facing** come from the scan. An overhead is anything within 3 ft of the wall, + lower than 12 ft, whose underside is above 4 ft. With a 2 ft floor, the tops of the two bins by + the door read as overheads, because the scanner saw their tops but not the parts below. Facing is + the nearest point more than 1 ft out and 1-6 ft high. The 1 ft skips pilasters and sills. It + reports 60 ft, a lower bound, where the scan saw open ground that far out and nothing in between. + +## What is real and what is invented + +| Item | Real? | wall | span_ft (s) | bottom-top ft | +/- ft | laser-scan check | +|---|---|---|---|---|---|---| +| electric meter | **invented** | w1 | 0 (5 ft up) | | 0.3 | placed midway between windows 4 and 5 | +| window 1 | real | w1 | -40.08 to -34.97 | 3.00-11.12 | 1.29 | opening -40.20 to -34.60 | +| window 2 | real | w1 | -29.26 to -24.04 | 2.87-11.25 | 0.36 | -29.45 to -23.90 | +| window 3 | real | w1 | -18.59 to -13.26 | 3.14-11.58 | 0.33 | -18.80 to -13.20 | +| window 4 | real | w1 | -8.13 to -2.57 | 3.32-11.73 | 0.27 | -8.15 to -2.55 | +| window 5 | real | w1 | 2.54 to 7.92 | 3.52-11.98 | 0.40 | 2.55 to 8.05 | +| entrance door | real | w3 | 23.83 to 31.01 | 0.69-13.37 | 0.70 | 24.0 to 31.65 | + +The scan check column comes from gaps in the laser points on the wall face 4.5-6 ft up. Every +window edge from the photos lies within its stated +/- of the scan's edge. Scan sill heights come +out 0.1-0.5 ft lower than `bottom_ft`, because I clicked the top edge of the stone sill and the +scan measures the brick below it. There is no gas meter; any stretch it could constrain already +fails on a window. + +Present but not annotated: + +- A narrow window on w2, visible only edge-on in DSC_0419. w2 is unobserved, which is the point. +- A downspout in the w1/w2 corner. It shows up only as the 1.03 ft facing depth there and a 6.1 ft + overhead. +- Two bins beside the door. They show up as the 2.3-2.5 ft facing depths; `objects` has no type + for them. + +## Expected placement outcomes + +Rule values come from `server/rules.yaml` at 44607e0: `battery.width_ft` 2.5833 (31 in), +`battery.depth_ft` 1.8333, `clearances.opening_ft` 3.0 for `openings.types` [door, window] (no +height exemption: `exempt_bottom_above_ft` is null), `route.max_ft` 20.0 and +`route.corner_allowance_ft` 0.5 per corner. The solver measures opening clearance as the plan +distance from the footprint to the opening's stretch of wall line, so sill height does not enter. +`case.json` lists ranges of the battery's start position (its left edge in s, as the server's +`sweep` reports it), where start = centre - 1.2917. + +- **A window or door blocks every start within 3 ft of it.** For a span [a, b] the blocked starts + are (a - 2.58 - 3, b + 3). Window 4 gives (-8.13 - 5.58, -2.57 + 3) = (-13.71, 0.43). Window 5 + gives (-3.04, 10.92), clipped at the w1/w2 corner (9.9). The door gives (18.25, 34.01), of which + w3 holds [21.52, 34.01]. +- **Nothing on w1 can pass.** The gaps between windows are 5.71, 5.45, 5.13 and 5.11 ft. A battery + needs 2.58 + 3 + 3 = 8.58 ft, so each gap falls short by at least 2.87 ft. That is more than + twice the largest window error (1.29 ft), so the error bars can't rescue it. The window bands + overlap end to end and cover every start on w1, [-42.1, 9.9]. +- **Beyond reach.** Left of the meter the route runs to the battery's right edge, so it is + -(start + 2.58) > 20 when start < -22.58. On w2 the route is start + 0.5 (one corner), over 20 + past start 19.5. Every start on w3 has route >= 21.52 + 2 x 0.5 = 22.52. Measured in a straight + line, w3's start is only 15.1 ft from the meter, but the door blocks starts up to 34.01, and a + battery starting there is 25.0 ft away in a straight line. So w3 fails under either reading of + "within 20 ft". +- **w2 must never pass**, because no band observes it. +- **The decision must be `manual_review`.** It can't be `pass`, because no start passes. It can't + be `reject` either. w2 is in reach and unseen, and a battery on it starting at 12.93 or later + clears window 5 on the nominal numbers. Window 5's edge is 1.98 ft from the corner and the + battery stands 1.83 ft proud, so the gap is sqrt(d^2 + 0.15^2), where d = start - 9.9; that + reaches 3 at d = 3.0. An area that could hold a valid spot was not seen. Both chain ends lie + beyond reach, so only w2 blocks a reject. +- The server at 44607e0 answered `manual_review` (unsure_checks, unobserved_area) with its spot on + w2 starting at 12.405. There the gap to window 5 is 2.47 ft. The check allows window 5's ± plus + w2's ± (0.40 + 0.13 = 0.53), and 2.47 + 0.53 reaches 3.0, so the check is UNSURE, not FAIL. That + agrees with the numbers above. diff --git a/verification/scenes/eth3d-facade/annotations.json b/verification/scenes/eth3d-facade/annotations.json new file mode 100644 index 000000000..b85a23899 --- /dev/null +++ b/verification/scenes/eth3d-facade/annotations.json @@ -0,0 +1,415 @@ +{ + "about": "Hand annotations for the ETH3D facade test scene. Read README.md first. Pixel coordinates are in the saved keyframes (the dataset images downscaled by `keyframes.scale`), origin at the image's top-left corner.", + "roi_scan_xy_m": [ + [ + -6.0, + -34.0 + ], + [ + 26.0, + -5.0 + ] + ], + "ground_fit_scan_xy_m": [ + [ + -2.0, + -27.0 + ], + [ + 20.0, + -12.0 + ] + ], + "walls": [ + { + "id": "w1", + "rough_scan_xy_m": [ + [ + 20.3, + -24.84 + ], + [ + 4.9, + -28.0 + ] + ] + }, + { + "id": "w2", + "rough_scan_xy_m": [ + [ + 4.9, + -28.0 + ], + [ + 4.16, + -24.43 + ] + ] + }, + { + "id": "w3", + "rough_scan_xy_m": [ + [ + 4.16, + -24.43 + ], + [ + -0.05, + -25.3 + ] + ] + } + ], + "chain_ends": { + "left": { + "kind": "unexplored", + "note": "w1 meets the main building in an inside corner and the wall continues along that building; not modelled" + }, + "right": { + "kind": "unexplored", + "note": "past the tower's west pilaster the wall steps back; the laser scan and the SfM points stop here, so it is not modelled" + } + }, + "keyframes": { + "scale": 0.25, + "images": [ + "DSC_0359", + "DSC_0360", + "DSC_0361", + "DSC_0362", + "DSC_0363", + "DSC_0419", + "DSC_0420" + ] + }, + "meter": { + "wall_id": "w1", + "from_wall_left_end_ft": 42.1, + "height_ft": 5.0, + "plus_minus_ft": 0.3, + "note": "INVENTED: this building has no electric meter here. Placed midway in the gap between w1 windows 4 and 5." + }, + "min_plus_minus_ft": 0.1, + "single_view_plus_minus_ft": 0.5, + "features": [ + { + "label": "w1 window 1", + "type": "window", + "wall_id": "w1", + "left_is_bottom_left_corner_in": [ + "DSC_0359", + "DSC_0360" + ], + "points_px": { + "DSC_0359": { + "left": [ + 1104, + 911 + ], + "right": [ + 1180, + 914 + ], + "bottom": [ + 1142, + 912 + ], + "top": [ + 1135, + 776 + ] + }, + "DSC_0360": { + "left": [ + 660, + 905 + ], + "right": [ + 718, + 905 + ], + "bottom": [ + 690, + 905 + ], + "top": [ + 690, + 765 + ] + } + } + }, + { + "label": "w1 window 2", + "type": "window", + "wall_id": "w1", + "left_is_bottom_left_corner_in": [ + "DSC_0360", + "DSC_0361" + ], + "points_px": { + "DSC_0360": { + "left": [ + 789, + 907 + ], + "right": [ + 869, + 909 + ], + "bottom": [ + 830, + 908 + ], + "top": [ + 835, + 755 + ] + }, + "DSC_0361": { + "left": [ + 190, + 818 + ], + "right": [ + 292, + 818 + ], + "bottom": [ + 240, + 818 + ], + "top": [ + 264, + 645 + ] + } + } + }, + { + "label": "w1 window 3", + "type": "window", + "wall_id": "w1", + "left_is_bottom_left_corner_in": [ + "DSC_0361", + "DSC_0419" + ], + "points_px": { + "DSC_0361": { + "left": [ + 402, + 822 + ], + "right": [ + 508, + 822 + ], + "bottom": [ + 455, + 822 + ], + "top": [ + 465, + 645 + ] + }, + "DSC_0419": { + "left": [ + 234, + 848 + ], + "right": [ + 365, + 848 + ], + "bottom": [ + 300, + 848 + ], + "top": [ + 315, + 654 + ] + } + } + }, + { + "label": "w1 window 4", + "type": "window", + "wall_id": "w1", + "left_is_bottom_left_corner_in": [ + "DSC_0419", + "DSC_0420" + ], + "points_px": { + "DSC_0419": { + "left": [ + 487, + 848 + ], + "right": [ + 616, + 848 + ], + "bottom": [ + 550, + 848 + ], + "top": [ + 556, + 653 + ] + }, + "DSC_0420": { + "left": [ + 43, + 825 + ], + "right": [ + 218, + 824 + ], + "bottom": [ + 130, + 824 + ], + "top": [ + 140, + 605 + ] + }, + "DSC_0361": { + "left": [ + 614, + 823 + ], + "right": [ + 730, + 823 + ], + "bottom": [ + 672, + 823 + ], + "top": [ + 680, + 647 + ] + } + } + }, + { + "label": "w1 window 5", + "type": "window", + "wall_id": "w1", + "left_is_bottom_left_corner_in": [ + "DSC_0419", + "DSC_0420" + ], + "points_px": { + "DSC_0419": { + "left": [ + 733, + 842 + ], + "right": [ + 848, + 842 + ], + "bottom": [ + 790, + 842 + ], + "top": [ + 791, + 653 + ] + }, + "DSC_0420": { + "left": [ + 368, + 822 + ], + "right": [ + 505, + 818 + ], + "bottom": [ + 436, + 820 + ], + "top": [ + 440, + 622 + ] + }, + "DSC_0361": { + "left": [ + 833, + 826 + ], + "right": [ + 952, + 827 + ], + "bottom": [ + 892, + 826 + ], + "top": [ + 895, + 645 + ] + } + } + }, + { + "label": "w3 entrance door", + "type": "door", + "wall_id": "w3", + "points_px": { + "DSC_0419": { + "left": [ + 1020, + 700 + ], + "right": [ + 1226, + 700 + ], + "bottom": [ + 1150, + 958 + ], + "top": [ + 1122, + 567 + ] + }, + "DSC_0420": { + "left": [ + 666, + 720 + ], + "right": [ + 855, + 720 + ], + "bottom": [ + 760, + 919 + ], + "top": [ + 760, + 547 + ] + } + }, + "plus_minus_floor_ft": { + "value": 0.7, + "why": "The door sits 1.2 ft behind the tower face, with its brick jambs 0.5 ft back, and both photos view it from the east. They agree to 0.19 ft, but the laser scan puts the door's right edge 0.64 ft further right (s = 31.65 vs 31.01), so their agreement does not bound the error." + } + } + ] +} diff --git a/verification/scenes/eth3d-facade/build_scene.py b/verification/scenes/eth3d-facade/build_scene.py new file mode 100644 index 000000000..a5cb9f841 --- /dev/null +++ b/verification/scenes/eth3d-facade/build_scene.py @@ -0,0 +1,983 @@ +"""Build a real-geometry test scene.json from the ETH3D "facade" dataset. + +Reads the laser scans, the COLMAP poses of the undistorted DSLR images and the hand annotations in +annotations.json next to this file, then writes scene.json, the keyframe JPEGs it references and +scene.zip into the output folder. See README.md for what every number means. + + uv run python scenes/eth3d-facade/build_scene.py # from verification/ + uv run python scenes/eth3d-facade/build_scene.py --schema path/to/scene.schema.json + +Frames. The scan and the COLMAP poses share one metric frame (scan z is up to within a degree). +The scene frame is ARKit-like: y is the fitted ground normal, so the ground is y = 0, x and z are +horizontal, right-handed, and every length is in feet. The origin is the ground point under the +hypothetical meter. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import shutil +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path + +import jsonschema +import numpy as np +from PIL import Image, ImageDraw + +HERE = Path(__file__).resolve().parent +DEFAULT_DATA = Path.home() / "house-scanning-data/verify/eth3d/facade" +DEFAULT_OUT = Path.home() / "house-scanning-data/verify/scenes/eth3d-facade" + +M_PER_FT = 0.3048 # exact by definition +RNG_SEED = 7 # fixed so RANSAC, and therefore every number in README.md, is reproducible + +# Coverage: a surface point counts as seen by a keyframe only if it projects inside the image, is +# on the camera side of its wall, is not hidden behind another wall of the chain, and is viewed +# within this angle of the wall normal. 75 degrees is a judgement call, not a calibrated value: +# at 75 degrees one pixel spans about 4x its head-on width on the wall, and a box drawn there +# moves by feet for a few pixels of error. +MAX_VIEW_ANGLE_DEG = 75.0 +COVERAGE_STEP_FT = 0.25 +# Base Core height, docs/04 (What Base does today); the wall band's top probe +BATTERY_HEIGHT_FT = 39.5 / 12 +# NEC 110.26 headroom, docs/04 (Public rule values); the overhead band's top probe +HEADROOM_FT = 6.5 +GROUND_OUT_FT = 6.0 # how far out the ground band claims to have been seen +OVERHEAD_OUT_FT = 3.0 # overhead search depth in front of the wall +OVERHEAD_MAX_FT = 12.0 # anything higher than this is not reported as an overhead +OVERHEAD_MIN_FT = 4.0 # see scan_profiles +FACING_MAX_FT = 60.0 + + +# -------------------------------------------------------------------------------------------- +# Reading the dataset + + +def read_alignment(path: Path) -> dict[str, np.ndarray]: + txt = path.read_text() + out = { + m.group(1): np.array(m.group(2).split(), float).reshape(4, 4) + for m in re.finditer(r'filename="([^"]+)">\s*([^<]+)<', txt) + } + if len(out) != 3: + raise SystemExit(f"expected 3 scans in {path}, found {sorted(out)}") + return out + + +def load_scan_crop(scan_dir: Path, roi_xy: list[list[float]]) -> np.ndarray: + """Laser points inside the plan box roi_xy = [[xmin, ymin], [xmax, ymax]], scan frame, m.""" + (x0, y0), (x1, y1) = roi_xy + out = [] + for name, T in read_alignment(scan_dir / "scan_alignment.mlp").items(): + with open(scan_dir / name, "rb") as f: + header = b"" + while not header.endswith(b"end_header\n"): + line = f.readline() + if not line: + raise SystemExit(f"{name}: no end_header") + header += line + if b"binary_little_endian" not in header or not re.search( + rb"element vertex \d+\nproperty float x\nproperty float y\nproperty float z\nelement", + header, + ): + raise SystemExit(f"{name}: expected little-endian float x,y,z vertices first") + n = int(re.search(rb"element vertex (\d+)", header).group(1)) + X = np.memmap(scan_dir / name, dtype=" x0) & (x[:, 0] < x1) & (x[:, 1] > y0) & (x[:, 1] < y1) + out.append(x[keep]) + return np.concatenate(out) + + +def qvec_to_rot(q: np.ndarray) -> np.ndarray: + w, x, y, z = q / np.linalg.norm(q) + return np.array( + [ + [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], + [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], + [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)], + ] + ) + + +@dataclass +class ColmapImage: + name: str # stem, e.g. DSC_0419 + R: np.ndarray # world-to-camera rotation, OpenCV camera (+x right, +y down, +z forward) + t: np.ndarray + cam: np.ndarray # fx, fy, cx, cy of the full-size undistorted image + w: int + h: int + uv: np.ndarray # observed keypoints, full-size pixels + pid: np.ndarray # their 3D point ids, -1 when untriangulated + + +def read_colmap(calib: Path) -> dict[str, ColmapImage]: + cams = {} + for ln in (calib / "cameras.txt").read_text().splitlines(): + if ln.startswith("#") or not ln.strip(): + continue + p = ln.split() + if p[1] != "PINHOLE": + raise SystemExit( + f"camera {p[0]} is {p[1]}; this script only handles undistorted PINHOLE" + ) + cams[int(p[0])] = (int(p[2]), int(p[3]), np.array(p[4:8], float)) + lines = [ln for ln in (calib / "images.txt").read_text().splitlines() if not ln.startswith("#")] + out = {} + for head, pts in zip(lines[0::2], lines[1::2], strict=True): + p = head.split() + w, h, k = cams[int(p[8])] + a = np.array(pts.split(), float).reshape(-1, 3) if pts.strip() else np.zeros((0, 3)) + name = Path(p[9]).stem + out[name] = ColmapImage( + name, + qvec_to_rot(np.array(p[1:5], float)), + np.array(p[5:8], float), + k, + w, + h, + a[:, :2], + a[:, 2].astype(int), + ) + return out + + +def read_points3d(calib: Path) -> dict[int, np.ndarray]: + pts = {} + for ln in (calib / "points3D.txt").read_text().splitlines(): + if ln.startswith("#") or not ln.strip(): + continue + p = ln.split() + pts[int(p[0])] = np.array(p[1:4], float) + return pts + + +# -------------------------------------------------------------------------------------------- +# Geometry + + +def ransac_plane(X, thr, rng, iters=600, accept=lambda n: True): + Xs = X[rng.integers(0, len(X), min(len(X), 40000))] + best_k, best = -1, None + for _ in range(iters): + s = Xs[rng.integers(0, len(Xs), 3)] + n = np.cross(s[1] - s[0], s[2] - s[0]) + if np.linalg.norm(n) < 1e-9: + continue + n /= np.linalg.norm(n) + if not accept(n): + continue + k = int((np.abs((Xs - s[0]) @ n) < thr).sum()) + if k > best_k: + best_k, best = k, (n, s[0]) + if best is None: + raise SystemExit("plane RANSAC found no acceptable plane") + inl = np.abs((X - best[1]) @ best[0]) < thr + c = X[inl].mean(0) + n = np.linalg.svd(X[inl] - c, full_matrices=False)[2][-1] + return n, c, inl + + +def ransac_line2d(Q, thr, rng, iters=400): + """Line through 2D points: returns (point, unit direction, inlier mask).""" + best_k, best = -1, None + for _ in range(iters): + a, b = Q[rng.integers(0, len(Q), 2)] + d = b - a + if np.linalg.norm(d) < 0.2: + continue + d /= np.linalg.norm(d) + nrm = np.array([-d[1], d[0]]) + k = int((np.abs((Q - a) @ nrm) < thr).sum()) + if k > best_k: + best_k, best = k, (a, nrm) + inl = np.abs((Q - best[0]) @ best[1]) < thr + c = Q[inl].mean(0) + d = np.linalg.svd(Q[inl] - c, full_matrices=False)[2][0] + return c, d, inl + + +def intersect_lines(p1, d1, p2, d2): + A = np.array([d1, -d2]).T + t = np.linalg.solve(A, p2 - p1) + return p1 + t[0] * d1 + + +@dataclass +class Frame: + """Scan frame (m, z up) -> scene frame (ft, y = ground normal).""" + + A: np.ndarray # rows are the scene x, y, z axes expressed in scan coordinates + origin: np.ndarray # scan-frame point that becomes the scene origin + + def pts(self, X): + return (np.asarray(X) - self.origin) @ self.A.T / M_PER_FT + + def dirs(self, V): + return np.asarray(V) @ self.A.T + + +def make_frame(ground_n: np.ndarray, ground_c: np.ndarray) -> Frame: + y = ground_n / np.linalg.norm(ground_n) + x = np.array([1.0, 0, 0]) - y[0] * y # scan x made horizontal + x /= np.linalg.norm(x) + z = np.cross(x, y) # right-handed: x cross y = z + return Frame(np.array([x, y, z]), ground_c.copy()) + + +@dataclass +class Wall: + id: str + p0: np.ndarray # plan [x, z] ft, left end as seen from outside + p1: np.ndarray + s0: float = 0.0 # unrolled position of p0 along the chain before the meter shift + height_ft: float = 0.0 + fit_rms_ft: float = 0.0 + # Signed distance (along `outward`) from the baseline to the wall face 4-8 ft up. The baseline + # is fitted over 0.3-8 ft and lands on the stone plinth, which stands proud of the brick; the + # windows sit in the brick, so clicks are cast onto this face, not the baseline. + face_offset_ft: float = 0.0 + + @property + def length(self): + return float(np.linalg.norm(self.p1 - self.p0)) + + @property + def along(self): + return (self.p1 - self.p0) / self.length + + @property + def outward(self): + # scene.schema.json: outward is the baseline direction turned 90 degrees clockwise seen + # from +y. With y up, x right on screen puts +z down-screen, so (dx, dz) -> (-dz, dx). + dx, dz = self.along + return np.array([-dz, dx]) + + def s_of(self, q): # q: plan points (..., 2) + return self.s0 + (np.asarray(q) - self.p0) @ self.along + + def plan_at(self, s): + return self.p0 + np.multiply.outer(np.asarray(s) - self.s0, self.along) + + +# -------------------------------------------------------------------------------------------- +# Cameras in the scene frame + + +@dataclass +class Keyframe: + id: str + name: str + R_c2w: np.ndarray # ARKit camera axes (+x right, +y up, looks along -z) in scene axes + C: np.ndarray # camera centre, scene ft + K: np.ndarray # fx, fy, cx, cy for the saved (downscaled) image + w: int + h: int + + def project(self, X): + """ARKit convention: a camera-space point (x, y, z) with z < 0 maps to + u = cx + fx * x / -z, v = cy - fy * y / -z (v grows downward in the image).""" + Xc = (np.asarray(X) - self.C) @ self.R_c2w # = R_c2w^T (X - C), row form + z = -Xc[..., 2] + fx, fy, cx, cy = self.K + with np.errstate(divide="ignore", invalid="ignore"): + u = cx + fx * Xc[..., 0] / z + v = cy - fy * Xc[..., 1] / z + return u, v, z + + def ray(self, u, v): + """World direction of pixel (u, v): camera ray ((u-cx)/fx, -(v-cy)/fy, -1).""" + fx, fy, cx, cy = self.K + d = np.array([(u - cx) / fx, -(v - cy) / fy, -1.0]) + return self.R_c2w @ d + + def pose16(self): + T = np.eye(4) + T[:3, :3] = self.R_c2w + T[:3, 3] = self.C + return [round(float(v), 6) for v in T.flatten(order="F")] # column-major + + +OPENCV_TO_ARKIT_CAM = np.diag([1.0, -1.0, -1.0]) # flip y (down -> up) and z (forward -> back) + + +def make_keyframe(img: ColmapImage, frame: Frame, scale: float, kid: str) -> Keyframe: + w, h = round(img.w * scale), round(img.h * scale) + sx, sy = w / img.w, h / img.h + fx, fy, cx, cy = img.cam + # Pixel coordinates are continuous with (0, 0) at the image's top-left corner, so a resize + # scales focal lengths and principal point alike. + K = np.array([fx * sx, fy * sy, cx * sx, cy * sy]) + R_c2w_scan = img.R.T @ OPENCV_TO_ARKIT_CAM + C_scan = -img.R.T @ img.t + return Keyframe(kid, img.name, frame.A @ R_c2w_scan, frame.pts(C_scan), K, w, h) + + +# -------------------------------------------------------------------------------------------- +# Scene pieces + + +def fit_ground(P, roi_ground, rng): + (x0, y0), (x1, y1) = roi_ground + zmed = np.median(P[:, 2]) + m = (P[:, 0] > x0) & (P[:, 0] < x1) & (P[:, 1] > y0) & (P[:, 1] < y1) & (P[:, 2] < zmed) + n, c, inl = ransac_plane(P[m], 0.03, rng, accept=lambda n: abs(n[2]) > np.cos(np.radians(10))) + n = n if n[2] > 0 else -n + rms = float(np.sqrt(np.mean(((P[m][inl] - c) @ n) ** 2))) + return ( + n, + c, + { + "inliers": int(inl.sum()), + "rms_m": rms, + "tilt_from_scan_z_deg": float(np.degrees(np.arccos(n[2]))), + }, + ) + + +def fit_walls(P_scene, ann_walls, frame, rng): + """Fit each wall's plan line to scan points near its rough segment, then join neighbours.""" + lines, stats = [], [] + h = P_scene[:, 1] + plan = P_scene[:, [0, 2]] + for w in ann_walls: + # rough ends are plan points read off a top view; their scan z does not matter beyond + # a few centimetres because the scene y axis is within 1.2 degrees of scan z + a, b = frame.pts(np.c_[np.array(w["rough_scan_xy_m"]), np.zeros(2)])[:, [0, 2]] + d = (b - a) / np.linalg.norm(b - a) + nrm = np.array([-d[1], d[0]]) + t = (plan - a) @ d + L = np.linalg.norm(b - a) + trim = 1.0 # ft kept away from the rough ends so neighbouring walls do not leak in + m = (np.abs((plan - a) @ nrm) < 1.0) & (t > trim) & (t < L - trim) & (h > 0.3) & (h < 8.0) + if m.sum() < 1000: + raise SystemExit(f"wall {w['id']}: only {m.sum()} scan points near its rough segment") + c, dd, inl = ransac_line2d(plan[m], 0.1, rng) + if dd @ d < 0: + dd = -dd + res = (plan[m][inl] - c) @ np.array([-dd[1], dd[0]]) + near = (np.abs((plan - c) @ np.array([-dd[1], dd[0]])) < 0.15) & (t > trim) & (t < L - trim) + lines.append((c, dd, a, b)) + stats.append( + { + "rms_ft": float(np.sqrt(np.mean(res**2))), + "inliers": int(inl.sum()), + "height_ft": float(np.percentile(h[near], 99.5)), + } + ) + walls = [] + for i, (w, (c, d, a, b), st) in enumerate(zip(ann_walls, lines, stats, strict=True)): + if i > 0: + p0 = intersect_lines(*lines[i - 1][:2], c, d) + else: + p0 = extent_end(plan, h, c, d, a, side=0) + if i < len(lines) - 1: + p1 = intersect_lines(c, d, *lines[i + 1][:2]) + else: + p1 = extent_end(plan, h, c, d, b, side=1) + walls.append( + Wall(w["id"], p0, p1, height_ft=round(st["height_ft"], 1), fit_rms_ft=st["rms_ft"]) + ) + for w, st in zip(walls, stats, strict=True): + lat = (plan - w.p0) @ w.along + out = (plan - w.p0) @ w.outward + k = (lat > 1.0) & (lat < w.length - 1.0) & (h > 4.0) & (h < 8.0) & (np.abs(out) < 1.0) + hist, edges = np.histogram(out[k], bins=np.arange(-1.0, 1.0001, 0.02)) + w.face_offset_ft = float(edges[hist.argmax()] + 0.01) + st["face_offset_ft"] = w.face_offset_ft + s = 0.0 + for w in walls: + w.s0 = s + s += w.length + return walls, stats + + +def extent_end(plan, h, c, d, rough_end, side): + """Free chain end: the last scanned wall point along the fitted line near the rough end.""" + nrm = np.array([-d[1], d[0]]) + t_rough = (rough_end - c) @ d + m = ( + (np.abs((plan - c) @ nrm) < 0.15) + & (h > 0.3) + & (h < 8.0) + & (np.abs((plan - c) @ d - t_rough) < 4) + ) + t = (plan[m] - c) @ d + te = np.percentile(t, 99.8) if side == 1 else np.percentile(t, 0.2) + return c + te * d + + +def seg_cross(a, b, c, d): + def orient(p, q, r): + return np.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])) + + return orient(a, b, c) * orient(a, b, d) < 0 and orient(c, d, a) * orient(c, d, b) < 0 + + +def visible(kf: Keyframe, wall: Wall, X, walls) -> bool: + """X: scene point on or in front of `wall`. See MAX_VIEW_ANGLE_DEG for the criteria.""" + u, v, z = kf.project(X) + if not (z > 0.5 and 0 <= u < kf.w and 0 <= v < kf.h): + return False + to_cam = kf.C - X + n3 = np.array([wall.outward[0], 0.0, wall.outward[1]]) + cosang = (to_cam @ n3) / np.linalg.norm(to_cam) + if cosang < np.cos(np.radians(MAX_VIEW_ANGLE_DEG)): + return False + a, b = kf.C[[0, 2]], X[[0, 2]] + for w in walls: + if w is wall: + continue + # shrink the other wall slightly so a shared corner does not count as a crossing + e = 0.02 * (w.p1 - w.p0) + if seg_cross(a, b, w.p0 + e, w.p1 - e): + return False + return True + + +FACING_PROBE_MAX_FT = 20.0 # the facing probe looks no further out than this + + +def band_reach(band: str, facing_depth: float | None) -> float | None: + """How far a band's probes reach, and so the most a view that sees them all demonstrates: + up the wall and up above the ground (wall, overhead), out from the wall (ground, facing). + It becomes the entry's `out_ft`: without one, the server reads a wall view as reaching + headroom and facing and overhead views as reaching all the way, more than was checked.""" + if band == "wall": + return BATTERY_HEIGHT_FT + if band == "ground": + return GROUND_OUT_FT + if band == "overhead": + return HEADROOM_FT + if band == "facing": + return None if facing_depth is None else min(facing_depth, FACING_PROBE_MAX_FT) + raise ValueError(band) + + +def band_probes(band: str, wall: Wall, s: float, facing_depth: float | None): + q = wall.plan_at(s) + o = wall.outward + reach = band_reach(band, facing_depth) + + def at(out, y): + p = q + out * o + return np.array([p[0], y, p[1]]) + + if reach is None: + return None + if band == "wall": + return [at(0.0, 0.0), at(0.0, reach)] + if band == "ground": + return [at(0.0, 0.0), at(reach, 0.0)] + if band == "overhead": + return [at(0.0, reach), at(OVERHEAD_OUT_FT, reach), at(OVERHEAD_OUT_FT, 0.0)] + return [at(0.0, 3.0), at(reach, 3.0)] + + +def band_coverage(band, walls, facing, kfs, seen=visible): + """Observed entries for one band: stretches where some keyframe sees every probe, merged + across corners, each with the smallest reach demonstrated inside it as `out_ft`, rounded + down so it never claims more. `seen(kf, wall, X, walls)` is `visible`; tests replace it.""" + samples = [] # (s0, s1, reach) per sample seen + for w in walls: + for s in np.arange(w.s0 + COVERAGE_STEP_FT / 2, w.s0 + w.length, COVERAGE_STEP_FT): + fd = next((v for (wid, a, b, v) in facing if wid == w.id and a <= s < b), None) + probes = band_probes(band, w, s, fd) + if probes is not None and any(all(seen(k, w, X, walls) for X in probes) for k in kfs): + half = COVERAGE_STEP_FT / 2 + samples.append([s - half, s + half, band_reach(band, fd)]) + merged = [] # consecutive samples, also across a corner, keep the smaller reach + for a, b, reach in samples: + if merged and abs(merged[-1][1] - a) < 1e-6: + merged[-1][1] = b + merged[-1][2] = min(merged[-1][2], reach) + else: + merged.append([a, b, reach]) + entries = [ + { + "band": band, + "span_ft": [round(a, 2), round(b, 2)], + "out_ft": math.floor(reach * 100) / 100, + } + for a, b, reach in merged + ] + return entries, [[a, b] for a, b, _ in merged] + + +WALL_RELIEF_FT = 1.0 # pilasters, sills and downspouts stand out less than this; see scan_profiles +BIN_INSET_FT = 0.15 # keeps a neighbouring wall that meets this one at a corner out of the end bins + + +def scan_profiles(P_scene, walls): + """Per 1 ft of chain: the lowest thing overhead near the wall and the straight-out gap. + + Overhead: something with open space under it. Points within OVERHEAD_OUT_FT of the wall are + binned into 0.25 ft cells in plan; a cell whose lowest point is above OVERHEAD_MIN_FT does not + reach down, so its lowest point is a clearance. Cells that reach lower (a pilaster, the + neighbouring wall at a corner) are obstacles, not overheads. With a 2 ft floor the tops of + the two bins by the door and the downspout shoe read as 2.1-2.5 ft overheads, because the + scanner saw their tops but not their lower parts; the photos show nothing hanging over this + wall below the tower's bay window, so the floor is 4 ft. A real overhang lower than that + would be missed. + Facing: the nearest point more than WALL_RELIEF_FT out, between 1 and 6 ft high. The wall's + own relief is excluded so it does not read as a fence; the cost is that an obstacle within + 1 ft of the wall is not reported.""" + plan, h = P_scene[:, [0, 2]], P_scene[:, 1] + over, facing = [], [] + for w in walls: + lat = (plan - w.p0) @ w.along + out = (plan - w.p0) @ w.outward + near = ( + (out > 0.3) + & (out < OVERHEAD_OUT_FT) + & (h > 0.5) + & (h < OVERHEAD_MAX_FT) + & (lat > 0) + & (lat < w.length) + ) + cell = np.floor(np.c_[lat[near], out[near]] / 0.25).astype(int) + cell_min, cell_n = {}, {} + for c, hh in zip(map(tuple, cell), h[near], strict=True): + cell_min[c] = min(cell_min.get(c, np.inf), hh) + cell_n[c] = cell_n.get(c, 0) + 1 + for a in np.arange(0.0, w.length, 1.0): + b = min(a + 1.0, w.length) + k = (lat >= a + BIN_INSET_FT) & (lat < b - BIN_INSET_FT) + lows = [ + v + for c, v in cell_min.items() + if a <= (c[0] + 0.5) * 0.25 < b and v > OVERHEAD_MIN_FT and cell_n[c] >= 5 + ] + over.append((w.id, w.s0 + a, w.s0 + b, float(min(lows)) if lows else None)) + kf = k & (out > WALL_RELIEF_FT) & (out < FACING_MAX_FT) & (h > 1.0) & (h < 6.0) + if kf.sum() > 20: + depth = float(out[kf].min()) + elif ( + k & (np.abs(h) < 0.5) & (out > FACING_MAX_FT - 5) & (out < FACING_MAX_FT) + ).sum() > 20: + # the scan saw open ground that far out and nothing standing before it: report + # the cap, a lower bound, which is safe for an "at least" check + depth = FACING_MAX_FT + else: + depth = None + facing.append((w.id, w.s0 + a, w.s0 + b, depth)) + return over, facing + + +def merge_runs(rows, key): + """(wall_id, s_a, s_b, value) rows -> merged runs, grouped by key(value), keeping the min.""" + out = [] + for wid, a, b, v in rows: + if v is None: + continue + if out and out[-1][0] == wid and abs(out[-1][2] - a) < 1e-6 and key(out[-1][3]) == key(v): + out[-1] = (wid, out[-1][1], b, min(out[-1][3], v)) + else: + out.append((wid, a, b, v)) + return out + + +# -------------------------------------------------------------------------------------------- +# Annotations + + +def backproject(kf: Keyframe, wall: Wall, uv): + """Intersect pixel rays with the wall's vertical plane. Returns scene points (n, 3).""" + n3 = np.array([wall.outward[0], 0.0, wall.outward[1]]) + face = wall.p0 + wall.face_offset_ft * wall.outward + P0 = np.array([face[0], 0.0, face[1]]) + out = [] + for u, v in uv: + d = kf.ray(u, v) + den = d @ n3 + if abs(den) < 1e-6: + raise SystemExit(f"{kf.name}: ray parallel to wall {wall.id}") + t = ((P0 - kf.C) @ n3) / den + if t <= 0: + raise SystemExit(f"{kf.name}: wall {wall.id} is behind the camera at pixel {u, v}") + out.append(kf.C + t * d) + return np.array(out) + + +POINT_ROLES = ("left", "right", "bottom", "top") + + +def measure_feature(feat, kfs_by_name, walls_by_id): + """Each image gives [s_left, s_right, bottom, top]. Every value comes from one clicked point + cast onto the wall plane: a point anywhere on the left jamb gives s_left, on the right jamb + s_right, on the sill or threshold the bottom height, at the arch apex the top height. One point + per value means a hidden corner or a leaning vertical in a tilted photo does not matter.""" + wall = walls_by_id[feat["wall_id"]] + per_image = {} + for img, pts in feat["points_px"].items(): + if sorted(pts) != sorted(POINT_ROLES): + raise SystemExit( + f"{feat['label']} @ {img}: need exactly the points {POINT_ROLES}, got {sorted(pts)}" + ) + X = backproject(kfs_by_name[img], wall, [pts[r] for r in POINT_ROLES]) + s = wall.s_of(X[:, [0, 2]]) + s_left, s_right, bottom, top = s[0], s[1], X[2, 1], X[3, 1] + per_image[img] = np.array([s_left, s_right, bottom, top]) + if not (s_left < s_right and bottom < top): + raise SystemExit(f"{feat['label']} @ {img}: left/right or bottom/top came out swapped") + vals = np.array(list(per_image.values())) + mean = vals.mean(0) + spread = float(np.max(vals.max(0) - vals.min(0))) if len(vals) >= 2 else None + return wall, mean, spread, per_image + + +# -------------------------------------------------------------------------------------------- + + +def round3(a): + return [round(float(x), 3) for x in a] + + +# Everything this script writes into --out. It replaces only these, so a mistyped --out (say, +# ~/house-scanning-data) fails instead of losing whatever is there. +OUTPUTS = {"bundle", "scene.json", "scene.zip", "build_report.json", "overlays"} + + +def clear_outputs(out: Path) -> None: + """Remove this script's earlier outputs from `out`; refuse a folder holding anything else.""" + if not out.exists(): + return + if not out.is_dir(): + raise SystemExit(f"--out {out} is a file, not a folder") + foreign = sorted(p.name for p in out.iterdir() if p.name not in OUTPUTS) + if foreign: + raise SystemExit( + f"--out {out} holds files this script did not write ({', '.join(foreign[:5])}); " + "choose an empty or new folder" + ) + for p in out.iterdir(): + if p.is_dir() and not p.is_symlink(): + shutil.rmtree(p) + else: + p.unlink() + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--data", type=Path, default=DEFAULT_DATA, help="extracted ETH3D facade folder") + ap.add_argument("--out", type=Path, default=DEFAULT_OUT) + ap.add_argument("--annotations", type=Path, default=HERE / "annotations.json") + ap.add_argument("--schema", type=Path, help="validate scene.json against this JSON Schema") + ap.add_argument( + "--overlays", + action="store_true", + help="also write keyframes with the annotations and the wall chain drawn on", + ) + args = ap.parse_args() + + ann = json.loads(args.annotations.read_text()) + rng = np.random.default_rng(RNG_SEED) + report = {} + + # 1. Laser scan, ground plane, scene frame --------------------------------------------- + P = load_scan_crop(args.data / "dslr_scan_eval", ann["roi_scan_xy_m"]) + g_n, g_c, g_stats = fit_ground(P, ann["ground_fit_scan_xy_m"], rng) + frame = make_frame(g_n, g_c) + report["ground"] = g_stats + + # 2. Wall chain (first pass with a provisional origin) --------------------------------- + P_scene = frame.pts(P) + walls, wall_stats = fit_walls(P_scene, ann["walls"], frame, rng) + + # 3. Meter: hypothetical, placed on a wall at a documented distance from that wall's left end + mt = ann["meter"] + wm = next(w for w in walls if w.id == mt["wall_id"]) + if not 0 <= mt["from_wall_left_end_ft"] <= wm.length: + raise SystemExit(f"meter is off wall {wm.id} (length {wm.length:.2f} ft)") + foot_plan = wm.p0 + mt["from_wall_left_end_ft"] * wm.along + # move the origin to the ground point under the meter and redo everything in that frame + foot_scan = frame.origin + frame.A.T @ (np.array([foot_plan[0], 0.0, foot_plan[1]]) * M_PER_FT) + frame = Frame(frame.A, foot_scan) + P_scene = frame.pts(P) + rng = np.random.default_rng(RNG_SEED) + walls, wall_stats = fit_walls(P_scene, ann["walls"], frame, rng) + wm = next(w for w in walls if w.id == mt["wall_id"]) + s_meter = wm.s0 + mt["from_wall_left_end_ft"] + for w in walls: + w.s0 -= s_meter # s = 0 at the meter from here on + walls_by_id = {w.id: w for w in walls} + meter_pos = np.array([*(wm.p0 + mt["from_wall_left_end_ft"] * wm.along), mt["height_ft"]])[ + [0, 2, 1] + ] + report["walls"] = [ + { + "id": w.id, + "length_ft": w.length, + "length_m": w.length * M_PER_FT, + "s_range_ft": [w.s0, w.s0 + w.length], + "fit": st, + } + for w, st in zip(walls, wall_stats, strict=True) + ] + + # 4. Keyframes ------------------------------------------------------------------------ + colmap = read_colmap(args.data / "dslr_calibration_undistorted") + scale = ann["keyframes"]["scale"] + kfs = [ + make_keyframe(colmap[n], frame, scale, f"k{i + 1}") + for i, n in enumerate(ann["keyframes"]["images"]) + ] + kfs_by_name = {k.name: k for k in kfs} + for w in walls: + cams_in_front = [k.name for k in kfs if (k.C[[0, 2]] - w.p0) @ w.outward > 0] + if not cams_in_front: + raise SystemExit( + f"wall {w.id}: no keyframe on its outward side; baseline order is wrong" + ) + + # Convention check: COLMAP's own triangulated points, moved into the scene frame and + # projected with the ARKit formula, must land on the keypoints COLMAP observed them at. + pts3d = read_points3d(args.data / "dslr_calibration_undistorted") + conv = {} + for k in kfs: + im = colmap[k.name] + sel = im.pid >= 0 + X = frame.pts(np.array([pts3d[i] for i in im.pid[sel]])) + u, v, z = k.project(X) + sx, sy = k.w / im.w, k.h / im.h + err = np.hypot(u - im.uv[sel, 0] * sx, v - im.uv[sel, 1] * sy) + conv[k.name] = { + "points": int(sel.sum()), + "all_in_front": bool((z > 0).all()), + "median_px": float(np.median(err)), + "p95_px": float(np.percentile(err, 95)), + } + report["arkit_projection_check"] = conv + if any(c["median_px"] > 1.0 or not c["all_in_front"] for c in conv.values()): + raise SystemExit(f"pose convention check failed: {conv}") + + # 5. Objects -------------------------------------------------------------------------- + objects, feature_rows = [], [] + for feat in ann["features"]: + wall, mean, spread, per_image = measure_feature(feat, kfs_by_name, walls_by_id) + # plus_minus_ft = max(0.1 ft, largest disagreement between images over the four values, + # the feature's documented floor). The floor exists for cases where the images share a + # bias that their agreement cannot show (see the door in annotations.json). + pm = max( + ann["min_plus_minus_ft"], + spread if spread is not None else ann["single_view_plus_minus_ft"], + feat.get("plus_minus_floor_ft", {}).get("value", 0.0), + ) + obj = { + "type": feat["type"], + "wall_id": wall.id, + "span_ft": [round(float(mean[0]), 2), round(float(mean[1]), 2)], + "bottom_ft": round(max(0.0, float(mean[2])), 2), + "top_ft": round(float(mean[3]), 2), + "source": "tap", + "plus_minus_ft": round(float(pm), 2), + } + if "attrs" in feat: + obj["attrs"] = feat["attrs"] + objects.append(obj) + feature_rows.append( + { + "label": feat["label"], + **obj, + "per_image": {k: [round(float(x), 2) for x in v] for k, v in per_image.items()}, + "spread_ft": spread, + } + ) + + # 6. Overheads and facing from the scan, coverage from the keyframes ----------------------- + over, facing = scan_profiles(P_scene, walls) # walls are already in meter-relative s + over_runs = merge_runs(over, key=lambda v: round(v)) + # facing depths are grouped in 2 ft steps (everything past 20 ft in one group) so the list + # stays short; each run reports the smallest depth inside it + face_runs = merge_runs(facing, key=lambda v: min(int(v // 2), 10)) + + observed = [] + per_band = {} + for band in ("wall", "ground", "overhead", "facing"): + entries, per_band[band] = band_coverage(band, walls, facing, kfs) + observed += entries + + # 7. Assemble ---------------------------------------------------------------------------- + scene = { + "schema_version": "1.0", + "meter": { + "pos": round3(meter_pos), + "wall_id": wm.id, + "plus_minus_ft": mt["plus_minus_ft"], + }, + "walls": [ + { + "id": w.id, + "baseline": [round3(w.p0), round3(w.p1)], + "height_ft": w.height_ft, + "plus_minus_ft": round(max(0.1, 3 * w.fit_rms_ft), 2), + } + for w in walls + ], + "objects": objects, + "overheads": [ + { + "wall_id": wid, + "span_ft": [round(a, 2), round(b, 2)], + "clearance_ft": round(v, 2), + "plus_minus_ft": 0.1, + } + for wid, a, b, v in over_runs + ], + "facing": [ + { + "wall_id": wid, + "span_ft": [round(a, 2), round(b, 2)], + "depth_ft": round(v, 2), + "plus_minus_ft": 0.1, + } + for wid, a, b, v in face_runs + ], + "coverage": {"ends": ann["chain_ends"], "observed": observed}, + "keyframes": [ + { + "id": k.id, + "pose": k.pose16(), + "intrinsics": [round(float(x), 4) for x in k.K], + "w": k.w, + "h": k.h, + "img": f"{k.id}.jpg", + } + for k in kfs + ], + } + + # 8. Write ---------------------------------------------------------------------------------- + out = args.out + clear_outputs(out) + (out / "bundle").mkdir(parents=True) + img_dir = args.data / "images/dslr_images_undistorted" + for k in kfs: + im = Image.open(img_dir / f"{k.name}.JPG") + im.draft("RGB", (k.w, k.h)) + im.convert("RGB").resize((k.w, k.h), Image.Resampling.LANCZOS).save( + out / "bundle" / f"{k.id}.jpg", quality=90 + ) + (out / "bundle/scene.json").write_text(json.dumps(scene, indent=1) + "\n") + shutil.copy(out / "bundle/scene.json", out / "scene.json") + with zipfile.ZipFile(out / "scene.zip", "w", zipfile.ZIP_DEFLATED) as z: + for f in sorted((out / "bundle").iterdir()): + z.write(f, f.name) + + # Sanity check: a corner clicked in one image, cast onto the wall, projected into the other + # image, compared with where it was clicked there. Only features whose "left" point is the + # same physical corner (bottom-left, at the sill) in both images take part. + repro = [] + for feat in ann["features"]: + if feat.get("left_is_bottom_left_corner_in") is None: + continue + i1, i2 = feat["left_is_bottom_left_corner_in"] + wall = walls_by_id[feat["wall_id"]] + X = backproject(kfs_by_name[i1], wall, [feat["points_px"][i1]["left"]]) + k2 = kfs_by_name[i2] + u, v, _ = k2.project(X) + cu, cv = feat["points_px"][i2]["left"] + px_ft = k2.K[0] / float(-((X[0] - k2.C) @ k2.R_c2w)[2]) # pixels per foot at that depth + err_px = float(np.hypot(u[0] - cu, v[0] - cv)) + repro.append( + { + "label": feat["label"], + "from": i1, + "into": i2, + "predicted_px": [round(float(u[0]), 1), round(float(v[0]), 1)], + "clicked_px": [cu, cv], + "err_px": round(err_px, 1), + "err_ft_at_wall": round(err_px / px_ft, 2), + } + ) + report["reprojection"] = repro + report["coverage"] = per_band + report["features"] = feature_rows + report["meter_s_from_chain_start_ft"] = s_meter + (out / "build_report.json").write_text(json.dumps(report, indent=1, default=float) + "\n") + + if args.overlays: + draw_overlays(out, kfs, walls, ann, kfs_by_name, walls_by_id) + + if args.schema: + jsonschema.validate(scene, json.loads(args.schema.read_text())) + print(f"scene.json validates against {args.schema}") + + print( + json.dumps( + {k: report[k] for k in ("ground", "walls", "arkit_projection_check", "reprojection")}, + indent=1, + default=float, + ) + ) + print("coverage:", json.dumps(per_band)) + for r in feature_rows: + print( + f"{r['label']:<34} {r['type']:<10} {r['wall_id']} span {r['span_ft']} " + f"h {r['bottom_ft']}-{r['top_ft']} +-{r['plus_minus_ft']}" + ) + print(f"meter (INVENTED): wall {wm.id}, s = 0, pos {scene['meter']['pos']}") + print(f"wrote {out / 'scene.json'} and {out / 'scene.zip'}") + + +def draw_overlays(out, kfs, walls, ann, kfs_by_name, walls_by_id): + """Debug images outside the bundle: the chain at ground and battery height, feet ticks along + it, and each annotated outline plus its reprojection from the other image.""" + od = out / "overlays" + od.mkdir(exist_ok=True) + for k in kfs: + im = Image.open(out / "bundle" / f"{k.id}.jpg").convert("RGB") + d = ImageDraw.Draw(im) + for w in walls: + for y, col in ((0.0, (255, 0, 255)), (BATTERY_HEIGHT_FT, (0, 255, 255))): + ss = np.linspace(w.s0, w.s0 + w.length, 60) + q = w.plan_at(ss) + X = np.c_[q[:, 0], np.full(len(ss), y), q[:, 1]] + u, v, z = k.project(X) + pts = [(a, b) for a, b, c in zip(u, v, z, strict=True) if c > 0.5] + if len(pts) > 1: + d.line(pts, fill=col, width=2) + for s in np.arange(np.ceil(w.s0), w.s0 + w.length, 1.0): + q = w.plan_at(s) + u, v, z = k.project( + np.array([[q[0], 0.0, q[1]], [q[0], 1.0 if s % 5 else 2.0, q[1]]]) + ) + if (z > 0.5).all(): + d.line([(u[0], v[0]), (u[1], v[1])], fill=(255, 255, 0), width=2) + if s % 5 == 0: + d.text((u[1] + 2, v[1] - 12), f"{int(s)}", fill=(255, 255, 0)) + for feat in ann["features"]: + wall = walls_by_id[feat["wall_id"]] + for img, pts in feat["points_px"].items(): + # the measured rectangle on the wall, drawn green from this image's own clicks + # and red when measured in another image + _, _, _, pim = measure_feature( + {**feat, "points_px": {img: pts}}, kfs_by_name, walls_by_id + ) + sl, sr, yb, yt = pim[img] + q = wall.plan_at(np.array([sl, sr, sr, sl])) + X = np.c_[q[:, 0], [yt, yt, yb, yb], q[:, 1]] + u, v, z = k.project(X) + if (z > 0.5).all(): + d.polygon( + list(zip(u, v, strict=True)), + outline=(0, 255, 0) if img == k.name else (255, 60, 60), + ) + if img == k.name: + for r in POINT_ROLES: + a, b = pts[r] + d.ellipse([a - 3, b - 3, a + 3, b + 3], outline=(255, 255, 255)) + im.save(od / f"{k.name}.jpg", quality=85) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verification/scenes/eth3d-facade/case.json b/verification/scenes/eth3d-facade/case.json new file mode 100644 index 000000000..9cffd67cd --- /dev/null +++ b/verification/scenes/eth3d-facade/case.json @@ -0,0 +1,22 @@ +{ + "id": "eth3d-facade", + "real": true, + "scene_path": "~/house-scanning-data/verify/scenes/eth3d-facade/scene.zip", + "source": "ETH3D 'facade' (dslr_undistorted + dslr_scan_eval, research-only license), south courtyard wing; built by verification/scenes/eth3d-facade/build_scene.py from annotations.json. Walls, windows and door are real; the electric meter is invented.", + "description": "Three-wall chain: w1 (52.0 ft, five ground-floor windows), w2 (11.6 ft tower side, never observed), w3 (16.2 ft tower front with the entrance door). Meter invented on w1 between windows 4 and 5. No start position can pass: every w1 window gap (5.11-5.71 ft) is at least 2.87 ft short of the 8.58 ft a battery plus two 3 ft clearances needs, w2 is unobserved, and all of w3 is past the 20 ft route. Reject is also wrong because w2 is within reach and unseen. See README.md for the arithmetic.", + "rules_assumed": {"opening": 3.0, "route_length": 20.0, "corner_allowance": 0.5, "battery_width": 2.583333333333}, + "expect": { + "decision_in": ["manual_review"], + "sweep_runs": [ + {"wall_id": "w1", "start_ft": [-42.1, -31.97], "outcome_not": "pass", "reason": "window 1 [-40.08, -34.97]: footprint within clearances.opening_ft (3 ft); starts (a - width - 3, b + 3)"}, + {"wall_id": "w1", "start_ft": [-34.84, -21.04], "outcome_not": "pass", "reason": "window 2 [-29.26, -24.04] within 3 ft"}, + {"wall_id": "w1", "start_ft": [-24.17, -10.26], "outcome_not": "pass", "reason": "window 3 [-18.59, -13.26] within 3 ft"}, + {"wall_id": "w1", "start_ft": [-13.71, 0.43], "outcome_not": "pass", "reason": "window 4 [-8.13, -2.57] within 3 ft"}, + {"wall_id": "w1", "start_ft": [-3.04, 9.9], "outcome_not": "pass", "reason": "window 5 [2.54, 7.92] within 3 ft (band runs to 10.92, clipped at the w1/w2 corner)"}, + {"wall_id": "w1", "start_ft": [-42.1, -22.58], "outcome_not": "pass", "reason": "route from meter to the battery's near (right) edge = -(start + width) exceeds route.max_ft (20 ft)"}, + {"wall_id": "w2", "start_ft": [9.9, 21.52], "outcome_not": "pass", "reason": "w2 is in no coverage.observed band (seen only beyond 75 degrees from its normal)"}, + {"wall_id": "w3", "start_ft": [21.52, 37.76], "outcome_not": "pass", "reason": "route is at least 21.52 ft along the wall + 2 corners x route.corner_allowance_ft 0.5 = 22.52 ft, over route.max_ft 20"}, + {"wall_id": "w3", "start_ft": [21.52, 34.01], "outcome_not": "pass", "reason": "entrance door [23.83, 31.01] within 3 ft; doors also fail as route crossings (route.crossing.door)"} + ] + } +} diff --git a/verification/scoreboard/.gitignore b/verification/scoreboard/.gitignore new file mode 100644 index 000000000..b1a62cafa --- /dev/null +++ b/verification/scoreboard/.gitignore @@ -0,0 +1 @@ +.cache.json diff --git a/verification/scoreboard/metrics.yaml b/verification/scoreboard/metrics.yaml new file mode 100644 index 000000000..c73e9cc0c --- /dev/null +++ b/verification/scoreboard/metrics.yaml @@ -0,0 +1,328 @@ +# Overnight-plan metrics for `make scoreboard`. hsverify/scoreboard.py documents each probe kind. +# A probe that only shows something exists is capped at partial with a cap_reason. +# Where only a person can judge, the probe is `manual`; the lead edits status and evidence. +# +# e2e report keys (hsverify/e2e.py): `sha` is the server SHA and `app_sha` the SHA of the app +# build whose export was replayed; `latency_ms` is the slowest real scene's round trip; +# `app_export_scene_valid` and `app_export_passed` cover scenes the iOS app exported from a +# replay, so their metrics compare `app_sha` (sha_key) with the app branch. A key is null when +# the run did not exercise it; the probe then uses the newest report where it is set. + +workstreams: + S1: {title: Evals on real data} + S2: {title: Server and solver} + S3: {title: iOS capture app} + S4: {title: Verification} + M: {title: Maintenance} + +metrics: + # --- S1 -------------------------------------------------------------------------------------- + - id: S1-1 + workstream: S1 + ref: origin/t3/evals + text: One command regenerates every number from a clean checkout + manual: + status: partial + evidence: >- + 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. + + - id: S1-2 + workstream: S1 + ref: origin/t3/evals + text: Dataset URLs and sha256 recorded + # A README table row naming the dataset, with a URL and a 64-hex digest on the same row. + grep_at_ref: + paths: [experiments/evals/README.md] + pattern: '(?im)^\|[^\n]*{each}[^\n]*https?://[^\n]*\b[0-9a-f]{64}\b' + each: {ADVIO: ADVIO, ETH3D: ETH3D} + + - id: S1-3 + workstream: S1 + ref: origin/t3/evals + text: Metric code tested on hand-computed cases + command: + run: uv run pytest -q + cwd: experiments/evals + timeout_s: 600 + slow: true + max_status: partial + cap_reason: tests pass; whether the cases are hand-computed needs a read + + - id: S1-4 + workstream: S1 + ref: origin/t3/evals + text: ADVIO drift report, median and p90 inches after 3, 10, 20 and 30 ft + # A Markdown table under experiments/evals with a row per distance and a p90 column. + grep_at_ref: + paths: ['experiments/evals/*.md'] + pattern: '(?im)^\|[^\n]*(?- + ETH3D errors per model (median and p90 inches for 1-3 m and 3-10 m pairs, scale error %) + with a verdict per question + grep_at_ref: + paths: ['experiments/evals/*.md'] + pattern: '(?im)^\|[^\n]*(?:{each})' + each: + 1-3 m row: '1\s*(?:-|–|to)\s*3\s*m' + 3-10 m row: '3\s*(?:-|–|to)\s*10\s*m' + scale error: 'scale error' + verdict: 'verdict' + max_status: partial + cap_reason: the table exists; per-model numbers and verdicts need a read + + # --- S2 -------------------------------------------------------------------------------------- + - id: S2-1 + workstream: S2 + ref: origin/t3/server + text: pytest green, the property tests under CI's fixed Hypothesis examples + # CI=1 selects the server's "ci" profile (server/tests/conftest.py: derandomize=True), as + # its CI does. A default local run draws fresh examples and can fail where CI passes: at + # 3baa338, test_capturing_exactly_what_is_requested_settles_it_in_one_round did (#11). + command: {run: CI=1 uv run pytest -q, cwd: server, timeout_s: 600, slow: true} + + - id: S2-2 + workstream: S2 + ref: origin/t3/server + text: Every public golden test 01-14 of docs/research/t3-lane-c-review.md present + # Test names such as test_01_..., test_golden_01_... or an id like "golden 07". + grep_at_ref: + paths: ['server/tests/*.py'] + pattern: '(?i)def test_(?:golden_?)?{each}(?!\d)|golden[^0-9\n]{0,12}{each}(?!\d)' + each: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14'] + + - id: S2-3 + workstream: S2 + ref: origin/t3/server + text: >- + Property tests: missing coverage never passes, equality is unsure, left/right mirror, + larger clearance never turns pass into fail + # A Hypothesis test: @given(...) (two levels of nested parentheses), any further + # decorators, then a test whose name says which property it checks. + grep_at_ref: + paths: ['server/tests/*.py'] + pattern: '@given\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)\s*(?:@[^\n]*\n\s*)*def test_\w*(?i:{each})' + each: + missing coverage: 'coverage|unobserved|unseen' + equality unsure: 'equal' + mirror: 'mirror|symmetr' + # S2 names it for moving an obstacle away (test_more_space.py, since 22292a8). + larger clearance: 'monoton|larger_clearance|clearance|obstacle_away' + + - id: S2-4 + workstream: S2 + ref: origin/t3/server + text: Schemas published + file_at_ref: {paths: [server/schemas/scene.schema.json, server/schemas/result.schema.json]} + + - id: S2-5 + workstream: S2 + ref: origin/t3/server + text: Schemas validated in tests + grep_at_ref: + paths: ['server/tests/*.py'] + pattern: '{each}' + each: {scene schema: 'scene\.schema\.json', result schema: 'result\.schema\.json'} + + - id: S2-6 + workstream: S2 + ref: origin/t3/server + text: A real-derived scene returns a result in under 1 s + report: + glob: ~/house-scanning-data/reports/e2e/*/report.json + # Null unless a real scene got an HTTP 200 result that was checked. + key: latency_ms + less_than: 1000 + require_current_sha: true + + - id: S2-7 + workstream: S2 + ref: origin/t3/server + text: ruff clean + command: + run: uv run ruff check . && uv run ruff format --check . + cwd: server + timeout_s: 300 + slow: true + + - id: S2-8 + workstream: S2 + ref: origin/t3/server + text: >- + 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 + report: + glob: ~/house-scanning-data/reports/e2e/*/report.json + # True only when at least one scene was answered and checked and none broke the contract. + key: contract_ok + equals: true + require_current_sha: true + + # --- S3 -------------------------------------------------------------------------------------- + - id: S3-1 + workstream: S3 + ref: origin/t3/ios-mvf + text: CI build green + pr_check: {name: iOS build} + + - id: S3-2 + workstream: S3 + ref: origin/t3/ios-mvf + text: Built as Swift 6 with strict concurrency + grep_at_ref: + paths: [ios/project.yml] + pattern: '{each}' + each: + Swift 6: 'SWIFT_VERSION:\s*"?6' + strict concurrency: 'SWIFT_STRICT_CONCURRENCY:\s*complete' + + - id: S3-3 + workstream: S3 + ref: origin/t3/ios-mvf + text: Zero build warnings + report: + glob: ~/house-scanning-data/reports/sim/*t3-ios-mvf*/report.json + key: build.warnings + length_equals: 0 + require_current_sha: true + + - id: S3-4 + workstream: S3 + ref: origin/t3/ios-mvf + text: Unit tests for coverage, auto-capture and guidance + grep_at_ref: + paths: ['ios/HouseScanKit/Tests/*.swift', 'ios/HouseScanTests/*.swift'] + pattern: '(?i)(?:func test|@Test)[^\n]*(?:{each})' + each: {coverage: coverage, auto-capture: 'auto_?capture', guidance: guidance} + + - id: S3-5 + workstream: S3 + ref: origin/t3/ios-mvf + text: >- + UI tests run the whole flow from a real replay with no manual input, including one + gap-instruction-recapture loop + grep_at_ref: + paths: ['ios/HouseScanUITests/*.swift'] + pattern: '{each}' + each: {replay argument: '"-replay"', autopilot argument: '"-autopilot"', gap loop: gapRequest} + max_status: partial + cap_reason: the tests exist; passing is S3-1 + + - id: S3-6 + workstream: S3 + ref: origin/t3/ios-mvf + text: Accessibility audit on every screen + grep_at_ref: + paths: ['ios/HouseScanUITests/*.swift'] + pattern: 'performAccessibilityAudit' + max_status: partial + cap_reason: an audit exists; which screens it covers needs a read + + - id: S3-6b + workstream: S3 + ref: origin/t3/ios-mvf + text: >- + 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 + report: + glob: ~/house-scanning-data/reports/a11y/*t3-ios-mvf*/report.json + key: issue_count + equals: 0 + require_current_sha: true + # ok is false when the harness did not run, a screen errored or too few were audited. + valid_key: ok + + - id: S3-7 + workstream: S3 + ref: origin/t3/ios-mvf + text: Screenshots of each state in the PR + # Nine states: ScanContract.swift lists ten, and `unsupported` needs a device without LiDAR. + pr_body: {pattern: '!\[[^\]]*\]\([^)]+\)|- + 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 + + - id: S4-2 + workstream: S4 + # The screenshots are of the app, so the report must be for the app branch's head. + ref: origin/t3/ios-mvf + text: Screenshot report per app state + report: + glob: ~/house-scanning-data/reports/sim/*t3-ios-mvf*/report.json + key: states + length_at_least: 9 + require_current_sha: true + + - id: S4-3 + workstream: S4 + ref: origin/t3/verification + text: Product description written from the code and checked against screenshots + manual: + status: met + evidence: >- + 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. + + - id: S4-4 + workstream: S4 + ref: origin/t3/verification + text: Scoreboard of every PR + file_at_ref: {path: verification/SCOREBOARD.md} + + # --- M --------------------------------------------------------------------------------------- + - id: M-1 + workstream: M + ref: origin/t3/docs-cleanup + text: AGENTS.md shorter than on main + command: + run: >- + a=$(wc -l < AGENTS.md | tr -d ' '); b=$(git show origin/main:AGENTS.md | wc -l | tr -d ' '); + echo "AGENTS.md $a lines, main $b"; test "$a" -lt "$b" + timeout_s: 60 + slow: false + + - id: M-2 + workstream: M + ref: origin/t3/docs-cleanup + text: AGENTS.md accurate, every path and command real + manual: {status: gap, evidence: not yet reviewed} diff --git a/verification/scoreboard/scoreboard.json b/verification/scoreboard/scoreboard.json new file mode 100644 index 000000000..421e95602 --- /dev/null +++ b/verification/scoreboard/scoreboard.json @@ -0,0 +1,993 @@ +{ + "generated_at": "2026-09-27T09:49:34-05:00", + "command": "uv run python -m hsverify.scoreboard", + "slow": [ + "S2-1", + "S2-7" + ], + "prs": [ + { + "number": 172, + "title": "ios: beta gets the legend fix and the walk's in-motion audit, report-only", + "branch": "t3/ci-flake-fixes-beta-2", + "head": "c3aeabea41a557d5a50cfedae4b3b07fd52d9687", + "draft": false, + "updated": "2026-09-27T13:28:27Z", + "ci": { + "counts": { + "success": 5, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "5 success, 1 skipped" + } + }, + { + "number": 165, + "title": "ios tests: audit the walk strictly once held and in motion to report; triage of the UI test failures", + "branch": "t3/ci-flake-fixes", + "head": "4925f37fb70cb875b34b16fa03eff75218c1905f", + "draft": false, + "updated": "2026-09-27T13:58:06Z", + "ci": { + "counts": { + "success": 3, + "failure": 1, + "pending": 0, + "skipped": 1 + }, + "failing": [ + "iOS build" + ], + "text": "3 success, 1 failure, 1 skipped; failing: iOS build" + } + }, + { + "number": 158, + "title": "Draw the prototype's fog and live dots over the walk", + "branch": "t3/live-fog", + "head": "f57310cac5577fef61baadca86729c0b4b43b080", + "draft": false, + "updated": "2026-09-27T11:07:27Z", + "ci": { + "counts": { + "success": 5, + "failure": 1, + "pending": 0, + "skipped": 1 + }, + "failing": [ + "iOS build" + ], + "text": "5 success, 1 failure, 1 skipped; failing: iOS build" + } + }, + { + "number": 143, + "title": "ios: practice meter, a drawn sample meter for scanning without one", + "branch": "t3/practice-meter", + "head": "dd219a05072cff50439f5644705246af635db39f", + "draft": false, + "updated": "2026-09-27T14:45:49Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 134, + "title": "docs: rebuild the README around the demo, with quick start, data and limits", + "branch": "shrey/readme", + "head": "9ceeece324099a0135bdcee23b68fe0ce8a9ce14", + "draft": false, + "updated": "2026-09-27T14:45:02Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "4 success" + } + }, + { + "number": 124, + "title": "tools: viewer that films a capture reaching the server, as a labeled replay or read-only live view", + "branch": "t3/integration-viewer", + "head": "9c0680b664183d9414bb21f612d536f9da02575d", + "draft": false, + "updated": "2026-09-27T10:47:39Z", + "ci": { + "counts": { + "success": 5, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "5 success" + } + }, + { + "number": 123, + "title": "ios: House Scan Integration records each scan as a capture packet, uploads it during the scan with per-scan consent, and shows the test server's answer", + "branch": "t3/hunter-integration", + "head": "17b53baa21d6d7e4f513a65e8e91d3bc3f580ac8", + "draft": true, + "updated": "2026-09-27T12:17:47Z", + "ci": { + "counts": { + "success": 2, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "2 success, 1 skipped" + } + }, + { + "number": 122, + "title": "server: stop requests at the wall's ends, keep placeholder distances out of them, name things without ids", + "branch": "t3/41-server-requests-text", + "head": "7046c93b467cc04f5da0d6a9861bca1bce8e7ef3", + "draft": false, + "updated": "2026-09-27T13:10:02Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "4 success, 1 skipped" + } + }, + { + "number": 120, + "title": "ios: hold a walk aim until it is met or stalls, and ask for the meter ground only near the meter", + "branch": "t3/41-walk-task-hold", + "head": "30ba9b39971ddcf86732e2011a1f06bffc7634e0", + "draft": false, + "updated": "2026-09-27T13:24:49Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 119, + "title": "ios: log meter-anchor corrections so marks can be checked against drift", + "branch": "t3/41-anchored-marks", + "head": "f03430fef6159fdfd230e9df248a3cdfc0ed58e4", + "draft": false, + "updated": "2026-09-27T12:49:53Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "4 success, 1 skipped" + } + }, + { + "number": 109, + "title": "ios: gap-loop follow-ups, upload ticks, the nearer unseen end and request stretches (#39, #31, #83, #75)", + "branch": "t3/41-gap-loop-result", + "head": "ecc7a5768e7b8afedf66dbf8a4f65c1f5f78cd50", + "draft": false, + "updated": "2026-09-27T14:21:03Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 108, + "title": "ios: ground needs a plausible meter height and never rises; estimated meter taps are checked and re-fitted to the detected wall", + "branch": "t3/41-ground-and-meter-tap", + "head": "92c8f92f821dba2167045ba9810853a724a787fa", + "draft": false, + "updated": "2026-09-27T12:49:02Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 104, + "title": "ios: gate coaching keeps the task card, holds through dark flicker, and says \"Turn more slowly\" for turns", + "branch": "t3/41-gate-coaching", + "head": "5f8f8777e1f0b03400b88a7be7dbe2b314a9def5", + "draft": false, + "updated": "2026-09-27T13:53:48Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 102, + "title": "ios: card replies name the task, stay under gate coaching, and lock after a new card", + "branch": "t3/41-card-replies", + "head": "4183f61b516282a3e07728a857660e30f96ffc02", + "draft": false, + "updated": "2026-09-27T13:52:50Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 100, + "title": "ios: draw the AR result with the Canvas until RealityKit is seen drawing it", + "branch": "t3/41-ar-canvas-fallback", + "head": "0ada11b9dcb67452e311ee4e4e8e9ffb50807bbb", + "draft": false, + "updated": "2026-09-27T13:29:43Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 1, + "skipped": 0 + }, + "failing": [], + "text": "4 success, 1 pending" + } + }, + { + "number": 99, + "title": "ios: \"leaves out N ft you walked\" only on walk tasks, and on the end question", + "branch": "t3/41-end-preview", + "head": "f5a27c6428ba7fae05a06c2807d603bc24d9e7c1", + "draft": false, + "updated": "2026-09-27T14:33:10Z", + "ci": { + "counts": { + "success": 5, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "5 success" + } + }, + { + "number": 90, + "title": "build: include available experiment suites in root checks", + "branch": "t3/check-experiments", + "head": "9d66b31c40c940c5bab7baea0aef5c5a87be3c10", + "draft": false, + "updated": "2026-09-27T14:28:02Z", + "ci": { + "counts": { + "success": 6, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "6 success" + } + }, + { + "number": 88, + "title": "experiments: auto-detect lab for wall objects and the meter brand", + "branch": "t3/autodetect-lab", + "head": "538f60e1e9796b26ab80bec50004bcc2eb04d38e", + "draft": true, + "updated": "2026-09-27T10:49:21Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "3 success" + } + }, + { + "number": 87, + "title": "ios: deflate the text entries in the shared scan zip", + "branch": "t3/41-zip-deflate", + "head": "aa5999e040b37a93ddfc91065c6954cabef59c8e", + "draft": false, + "updated": "2026-09-27T13:24:44Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 1, + "skipped": 0 + }, + "failing": [], + "text": "4 success, 1 pending" + } + }, + { + "number": 86, + "title": "experiments: live dots, a macOS prototype of the holographic capture view", + "branch": "t3/experience", + "head": "b442f0a0436daf36f1f929d578a63791e1186637", + "draft": true, + "updated": "2026-09-27T10:48:07Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 skipped" + } + }, + { + "number": 61, + "title": "docs: app review \u2014 product description, UX review and friction audit", + "branch": "t3/app-review-docs", + "head": "c0b628b6b9ec2b314ae418db956a27bc579ee642", + "draft": false, + "updated": "2026-09-27T10:48:27Z", + "ci": { + "counts": { + "success": 5, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "5 success" + } + }, + { + "number": 60, + "title": "docs: build 4.1 field-test analyses (three runs)", + "branch": "t3/device-test-4.1-analysis", + "head": "c566490c5e3fa0d45803895bb463cb260d11a906", + "draft": false, + "updated": "2026-09-27T10:55:55Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "3 success" + } + }, + { + "number": 59, + "title": "experiments: signals lab round 1, which extra phone signals sharpen clearances", + "branch": "t3/signals-lab", + "head": "0cde05e30a7d07b01d8a458a0eeefbf2695ab2da", + "draft": true, + "updated": "2026-09-27T10:50:43Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "3 success" + } + }, + { + "number": 58, + "title": "ci(ios): drop the background Simulator boot and the duplicate t3/ios-mvf push run", + "branch": "t3/ios-ci-no-preboot", + "head": "7f1037e0cb2f32be1c52f0394a8ca2e47337d00b", + "draft": false, + "updated": "2026-09-27T12:02:46Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "4 success, 1 skipped" + } + }, + { + "number": 56, + "title": "Tests for #24, #28 and #29: synthetic walk-guidance cases modeled on field runs 1 and 2", + "branch": "t3/walk-guidance-gaps", + "head": "16b1c2492b36245493120fbb066314fa14bac525", + "draft": false, + "updated": "2026-09-27T13:24:48Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 55, + "title": "Ask for Motion & Fitness on the onboarding, where it is explained (#43)", + "branch": "t3/motion-permission", + "head": "b289c7dfa90c8760e0c34b96e5383a8c27a2bb8e", + "draft": false, + "updated": "2026-09-27T14:46:26Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "4 success, 1 skipped" + } + }, + { + "number": 52, + "title": "Mark an existing battery or another box on the wall (#27)", + "branch": "t3/battery-mark", + "head": "086ad94b7fdfccef09e9ddadffac908b63f974f4", + "draft": false, + "updated": "2026-09-27T14:31:26Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 1, + "skipped": 1 + }, + "failing": [], + "text": "3 success, 1 pending, 1 skipped" + } + }, + { + "number": 49, + "title": "ios: 'I can't get there' moves on to the next requested view (#39)", + "branch": "t3/gap-skip-continues", + "head": "6c3ac6252f9738e94b6ab10b16f0cfc4ee4a5d0d", + "draft": false, + "updated": "2026-09-27T13:47:11Z", + "ci": { + "counts": { + "success": 4, + "failure": 0, + "pending": 0, + "skipped": 1 + }, + "failing": [], + "text": "4 success, 1 skipped" + } + }, + { + "number": 46, + "title": "experiments: field test run 2 on build 3.1", + "branch": "t3/field-test-run2", + "head": "afd02c820bd025b1c077d8e238d3f3421e5d9f3a", + "draft": false, + "updated": "2026-09-27T10:48:15Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "3 success" + } + }, + { + "number": 23, + "title": "experiments: device field test runbook and run 1 of the TestFlight build", + "branch": "t3/device-field-test", + "head": "321ea7e2705b80f61eaac3b4c7c3ddbb6503c675", + "draft": false, + "updated": "2026-09-27T10:49:36Z", + "ci": { + "counts": { + "success": 3, + "failure": 0, + "pending": 0, + "skipped": 0 + }, + "failing": [], + "text": "3 success" + } + } + ], + "pr_error": null, + "workstreams": [ + { + "id": "S1", + "title": "Evals on real data", + "refs": [ + { + "ref": "origin/t3/evals", + "sha": "b4601ca59f2122a54d91c6981cdc74507248d6b1" + } + ], + "metrics": [ + { + "id": "S1-1", + "text": "One command regenerates every number from a clean checkout", + "ref": "origin/t3/evals", + "sha": "b4601ca59f2122a54d91c6981cdc74507248d6b1", + "status": "partial", + "evidence": "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.", + "links": [] + }, + { + "id": "S1-2", + "text": "Dataset URLs and sha256 recorded", + "ref": "origin/t3/evals", + "sha": "b4601ca59f2122a54d91c6981cdc74507248d6b1", + "status": "gap", + "evidence": "none found in 1 files; missing ADVIO, ETH3D", + "links": [] + }, + { + "id": "S1-3", + "text": "Metric code tested on hand-computed cases", + "ref": "origin/t3/evals", + "sha": "b4601ca59f2122a54d91c6981cdc74507248d6b1", + "status": "not run", + "evidence": "slow; run with --slow", + "links": [] + }, + { + "id": "S1-4", + "text": "ADVIO drift report, median and p90 inches after 3, 10, 20 and 30 ft", + "ref": "origin/t3/evals", + "sha": "b4601ca59f2122a54d91c6981cdc74507248d6b1", + "status": "met", + "evidence": "all 5 found", + "links": [ + [ + "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" + ] + ] + }, + { + "id": "S1-5", + "text": "ETH3D errors per model (median and p90 inches for 1-3 m and 3-10 m pairs, scale error %) with a verdict per question", + "ref": "origin/t3/evals", + "sha": "b4601ca59f2122a54d91c6981cdc74507248d6b1", + "status": "partial", + "evidence": "found 1-3 m row, 3-10 m row, scale error; missing verdict", + "links": [ + [ + "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" + ] + ] + } + ] + }, + { + "id": "S2", + "title": "Server and solver", + "refs": [ + { + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb" + } + ], + "metrics": [ + { + "id": "S2-1", + "text": "pytest green, the property tests under CI's fixed Hypothesis examples", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "met", + "evidence": "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)", + "links": [] + }, + { + "id": "S2-2", + "text": "Every public golden test 01-14 of docs/research/t3-lane-c-review.md present", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "met", + "evidence": "all 14 found", + "links": [ + [ + "server/tests/test_golden_public.py", + "https://github.com/SamGu-NRX/house-scanning/blob/6c7bea122371711284d5672410b6e1afde22b7bb/server/tests/test_golden_public.py" + ] + ] + }, + { + "id": "S2-3", + "text": "Property tests: missing coverage never passes, equality is unsure, left/right mirror, larger clearance never turns pass into fail", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "met", + "evidence": "all 4 found", + "links": [ + [ + "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" + ] + ] + }, + { + "id": "S2-4", + "text": "Schemas published", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "met", + "evidence": "present at 6c7bea12", + "links": [ + [ + "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" + ] + ] + }, + { + "id": "S2-5", + "text": "Schemas validated in tests", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "met", + "evidence": "all 2 found", + "links": [ + [ + "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" + ] + ] + }, + { + "id": "S2-6", + "text": "A real-derived scene returns a result in under 1 s", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "stale", + "evidence": "report for sha 3baa3387, ref at 6c7bea12: ~/house-scanning-data/reports/e2e/20260927-094443-3baa3387/report.json", + "links": [] + }, + { + "id": "S2-7", + "text": "ruff clean", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "met", + "evidence": "exit 0: Installed 33 packages in 40ms / All checks passed! / 37 files already formatted", + "links": [] + }, + { + "id": "S2-8", + "text": "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", + "ref": "origin/t3/server", + "sha": "6c7bea122371711284d5672410b6e1afde22b7bb", + "status": "stale", + "evidence": "report for sha 3baa3387, ref at 6c7bea12: ~/house-scanning-data/reports/e2e/20260927-094443-3baa3387/report.json", + "links": [] + } + ] + }, + { + "id": "S3", + "title": "iOS capture app", + "refs": [ + { + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a" + } + ], + "metrics": [ + { + "id": "S3-1", + "text": "CI build green", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "no evidence yet", + "evidence": "no open PR for t3/ios-mvf", + "links": [] + }, + { + "id": "S3-2", + "text": "Built as Swift 6 with strict concurrency", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "met", + "evidence": "all 2 found", + "links": [ + [ + "ios/project.yml", + "https://github.com/SamGu-NRX/house-scanning/blob/c820cf150a73cb97527428d580c330f631253f9a/ios/project.yml" + ] + ] + }, + { + "id": "S3-3", + "text": "Zero build warnings", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "met", + "evidence": "build.warnings = length 0 (want 0) at sha c820cf15: ~/house-scanning-data/reports/sim/20260927-093459-t3-ios-mvf-c820cf1-c820cf15-replay/report.json", + "links": [] + }, + { + "id": "S3-4", + "text": "Unit tests for coverage, auto-capture and guidance", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "partial", + "evidence": "found coverage, guidance; missing auto-capture", + "links": [ + [ + "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" + ] + ] + }, + { + "id": "S3-5", + "text": "UI tests run the whole flow from a real replay with no manual input, including one gap-instruction-recapture loop", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "partial", + "evidence": "all 3 found (the tests exist; passing is S3-1)", + "links": [ + [ + "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" + ] + ] + }, + { + "id": "S3-6", + "text": "Accessibility audit on every screen", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "partial", + "evidence": "3 matches in 2 files (an audit exists; which screens it covers needs a read)", + "links": [ + [ + "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" + ] + ] + }, + { + "id": "S3-6b", + "text": "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", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "stale", + "evidence": "report for sha a39d0a50, ref at c820cf15: ~/house-scanning-data/reports/a11y/20260926-071248-t3-ios-mvf-a39d0a50-head-sample/report.json", + "links": [] + }, + { + "id": "S3-7", + "text": "Screenshots of each state in the PR", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "no evidence yet", + "evidence": "no open PR for t3/ios-mvf", + "links": [] + }, + { + "id": "S3-8", + "text": "Exported scene.json validates against C1", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "met", + "evidence": "app_export_scene_valid = true (want True) at app_sha c820cf15: ~/house-scanning-data/reports/e2e/20260927-094443-3baa3387/report.json", + "links": [] + }, + { + "id": "S3-9", + "text": "Design self-review against frontend-design, emil-design-eng, holistic-ux, apple-design", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "gap", + "evidence": "not yet reviewed", + "links": [] + } + ] + }, + { + "id": "S4", + "title": "Verification", + "refs": [ + { + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a" + }, + { + "ref": "origin/t3/verification", + "sha": "b9f1601c0d0ea161eb346791c36157b231cfa309" + } + ], + "metrics": [ + { + "id": "S4-1", + "text": "End to end from a real replay to a placement result passes", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "partial", + "evidence": "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)", + "links": [] + }, + { + "id": "S4-2", + "text": "Screenshot report per app state", + "ref": "origin/t3/ios-mvf", + "sha": "c820cf150a73cb97527428d580c330f631253f9a", + "status": "met", + "evidence": "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", + "links": [] + }, + { + "id": "S4-3", + "text": "Product description written from the code and checked against screenshots", + "ref": "origin/t3/verification", + "sha": "b9f1601c0d0ea161eb346791c36157b231cfa309", + "status": "met", + "evidence": "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.", + "links": [] + }, + { + "id": "S4-4", + "text": "Scoreboard of every PR", + "ref": "origin/t3/verification", + "sha": "b9f1601c0d0ea161eb346791c36157b231cfa309", + "status": "met", + "evidence": "present at b9f1601c", + "links": [ + [ + "verification/SCOREBOARD.md", + "https://github.com/SamGu-NRX/house-scanning/blob/b9f1601c0d0ea161eb346791c36157b231cfa309/verification/SCOREBOARD.md" + ] + ] + } + ] + }, + { + "id": "M", + "title": "Maintenance", + "refs": [ + { + "ref": "origin/t3/docs-cleanup", + "sha": "3c0dd56c4e74ec872de7a1904c7d9a9a5b860705" + } + ], + "metrics": [ + { + "id": "M-1", + "text": "AGENTS.md shorter than on main", + "ref": "origin/t3/docs-cleanup", + "sha": "3c0dd56c4e74ec872de7a1904c7d9a9a5b860705", + "status": "gap", + "evidence": "exit 1: AGENTS.md 59 lines, main 59", + "links": [] + }, + { + "id": "M-2", + "text": "AGENTS.md accurate, every path and command real", + "ref": "origin/t3/docs-cleanup", + "sha": "3c0dd56c4e74ec872de7a1904c7d9a9a5b860705", + "status": "gap", + "evidence": "not yet reviewed", + "links": [] + } + ] + } + ] +} diff --git a/verification/tests/test_a11yaudit.py b/verification/tests/test_a11yaudit.py new file mode 100644 index 000000000..6013a20e3 --- /dev/null +++ b/verification/tests/test_a11yaudit.py @@ -0,0 +1,81 @@ +from hsverify.a11yaudit import audit_problems, audited_screens, parse_output, symbol_name_labels + + +def test_parse_output_reads_screens_and_issues_and_skips_noise(): + text = "\n".join( + [ + "Test Case started.", + 'A11Y_ISSUE={"screen": 2, "description": "Hit area is too small"}', + 'A11Y_SCREEN={"index": 2, "labels": ["text:hi"], "issues": 1}', + "A11Y_SCREEN={broken", + ] + ) + screens, issues = parse_output(text) + assert screens == [{"index": 2, "labels": ["text:hi"], "issues": 1}] + assert issues == [{"screen": 2, "description": "Hit area is too small"}] + + +def test_symbol_name_labels(): + screens = [ + { + "index": 1, + "labels": [ + "button:gearshape", + "button:arrow.left.circle.fill", + "button:camera", + "button:I'm here", + "text:xmark", + ], + } + ] + assert symbol_name_labels(screens) == [ + {"screen": 1, "label": "gearshape"}, + {"screen": 1, "label": "arrow.left.circle.fill"}, + ] + + +def screen(index, **extra): + return {"index": index, "labels": ["text:x"], "issues": 0} | extra + + +NINE = [screen(i) for i in range(1, 10)] + + +def test_nine_clean_screens_are_valid(): + assert audit_problems(NINE, harness_ran=True, min_screens=9) == [] + + +def test_zero_screens_is_not_evidence(): + assert audit_problems([], harness_ran=True, min_screens=9) == ["no screen was audited"] + + +def test_harness_that_did_not_run_is_not_evidence(): + problems = audit_problems(NINE, harness_ran=False, min_screens=9) + assert problems == ["the audit harness did not run (no TEST SUCCEEDED or TEST FAILED line)"] + + +def test_too_few_distinct_screens(): + repeated = [screen(1), screen(1), screen(2)] + assert audit_problems(repeated, harness_ran=True, min_screens=9) == [ + "2 screens audited, fewer than --min-screens 9" + ] + + +def test_screen_error_fails_and_that_screen_is_not_counted(): + # After a throw the harness prints an error entry and then a normal one for the same index. + screens = [*NINE, screen(9, error="audit threw"), {"index": 10, "error": "app not running"}] + assert audit_problems(screens, harness_ran=True, min_screens=9) == [ + "screen 9: audit threw", + "screen 10: app not running", + "8 screens audited, fewer than --min-screens 9", + ] + assert audited_screens(screens) == set(range(1, 9)) + + +def test_error_lines_from_the_harness_are_parsed_as_screens(): + text = 'A11Y_SCREEN={"error":"app not running","index":1}' + screens, _ = parse_output(text) + assert audit_problems(screens, harness_ran=True, min_screens=1) == [ + "screen 1: app not running", + "no screen was audited", + ] diff --git a/verification/tests/test_build_scene.py b/verification/tests/test_build_scene.py new file mode 100644 index 000000000..d2983bfb7 --- /dev/null +++ b/verification/tests/test_build_scene.py @@ -0,0 +1,94 @@ +"""The ETH3D facade scene builder replaces only its own outputs.""" + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "scenes" / "eth3d-facade" / "build_scene.py" +spec = importlib.util.spec_from_file_location("build_scene", SCRIPT) +build_scene = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = build_scene # dataclasses look their module up there +spec.loader.exec_module(build_scene) + + +def test_earlier_outputs_are_replaced(tmp_path): + (tmp_path / "bundle").mkdir() + (tmp_path / "bundle" / "k00001.jpg").write_bytes(b"old") + (tmp_path / "scene.json").write_text("{}") + (tmp_path / "overlays").mkdir() + build_scene.clear_outputs(tmp_path) + assert list(tmp_path.iterdir()) == [] + + +def test_a_folder_with_other_files_is_refused_and_left_alone(tmp_path): + (tmp_path / "scene.json").write_text("{}") + (tmp_path / "datasets").mkdir() + (tmp_path / "notes.txt").write_text("keep me") + with pytest.raises(SystemExit, match=r"did not write \(datasets, notes\.txt\)"): + build_scene.clear_outputs(tmp_path) + assert sorted(p.name for p in tmp_path.iterdir()) == ["datasets", "notes.txt", "scene.json"] + + +def test_a_missing_folder_is_fine_and_a_file_is_refused(tmp_path): + build_scene.clear_outputs(tmp_path / "new") + target = tmp_path / "a-file" + target.write_text("x") + with pytest.raises(SystemExit, match="is a file"): + build_scene.clear_outputs(target) + assert target.read_text() == "x" + + +def test_a_symlinked_output_is_unlinked_not_followed(tmp_path): + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (elsewhere / "precious").write_text("keep") + out = tmp_path / "out" + out.mkdir() + (out / "bundle").symlink_to(elsewhere, target_is_directory=True) + build_scene.clear_outputs(out) + assert (elsewhere / "precious").read_text() == "keep" + assert not (out / "bundle").exists() + + +def wall(wid: str, s0: float, length: float): + return build_scene.Wall(wid, np.array([s0, 0.0]), np.array([s0 + length, 0.0]), s0=s0) + + +def seen_everywhere(kf, w, X, walls): + return True + + +@pytest.mark.parametrize( + ("band", "out_ft"), + [ + ("wall", 3.29), # up to the battery's height, not the headroom an omitted view claims + ("ground", build_scene.GROUND_OUT_FT), + ("overhead", build_scene.HEADROOM_FT), # not clear all the way up + ], +) +def test_every_band_claims_only_the_reach_its_probes_showed(band, out_ft): + entries, spans = build_scene.band_coverage( + band, [wall("w", 0.0, 2.0)], [], ["kf"], seen_everywhere + ) + assert entries == [{"band": band, "span_ft": [0.0, 2.0], "out_ft": out_ft}] + assert spans == [[0.0, 2.0]] + + +def test_facing_claims_the_smallest_depth_probed_and_nothing_past_20_ft(): + walls = [wall("a", 0.0, 1.0), wall("b", 1.0, 1.0)] # meet at a corner: one merged entry + facing = [("a", 0.0, 1.0, 35.0), ("b", 1.0, 1.5, 12.345), ("b", 1.5, 2.0, 30.0)] + entries, _ = build_scene.band_coverage("facing", walls, facing, ["kf"], seen_everywhere) + assert entries == [{"band": "facing", "span_ft": [0.0, 2.0], "out_ft": 12.34}] + entries, _ = build_scene.band_coverage("facing", walls[:1], facing, ["kf"], seen_everywhere) + assert entries[0]["out_ft"] == 20.0 + + +def test_an_unseen_probe_leaves_the_stretch_out(): + def only_low(kf, w, X, walls): + return X[1] <= 1.0 # the top of the wall probe is never seen + + entries, _ = build_scene.band_coverage("wall", [wall("w", 0.0, 2.0)], [], ["kf"], only_low) + assert entries == [] diff --git a/verification/tests/test_e2e.py b/verification/tests/test_e2e.py new file mode 100644 index 000000000..13d70f552 --- /dev/null +++ b/verification/tests/test_e2e.py @@ -0,0 +1,486 @@ +import hashlib +import io +import json +import re +import threading +import time +import zipfile +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import ClassVar + +import pytest + +from hsverify import e2e +from hsverify.e2e import ( + Endpoint, + SceneInput, + bundle_zip, + discover_endpoint, + hostile_inputs, + judge, + judge_hostile, + load_app_export, + load_input, + zip_bomb, +) +from hsverify.resultcheck import Need, RuleSet + +RULES = RuleSet( + width_ft=31 / 12, + depth_ft=22 / 12, + needs={"ground_surface": (Need("ground", 0.0),), "gas_clearance": (Need("ground", 1.0),)}, + errors={ + "tap": 0.3, + "vlm": 1.5, + "tape": 0.05, + "wall": 0.3, + "mesh": 0.5, + "plane": 0.75, + "meter": 0.3, + "drift_per_ft": 0.16, + }, + step_ft=1 / 6, # the server's 2 in + wall_join_ft=0.6, + battery_height_ft=3.25, + headroom_ft=6.5, +) + +SCENE = { + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "w1"}, + "walls": [{"id": "w1", "baseline": [[-10.0, 0.0], [10.0, 0.0]]}], + "coverage": {"observed": [{"band": "wall", "span_ft": [-10.0, 10.0]}]}, +} + + +def openapi(paths: dict) -> dict: + return { + "paths": paths, + "components": { + "schemas": { + "Upload": {"properties": {"bundle": {"type": "string", "format": "binary"}}} + } + }, + } + + +def json_op() -> dict: + return {"post": {"requestBody": {"content": {"application/json": {"schema": {}}}}}} + + +def test_single_json_endpoint_is_found(): + assert discover_endpoint(openapi({"/place": json_op()})) == Endpoint( + "/place", "application/json" + ) + + +def test_multipart_endpoint_names_its_file_field(): + op = { + "post": { + "requestBody": { + "content": { + "multipart/form-data": {"schema": {"$ref": "#/components/schemas/Upload"}} + } + } + } + } + assert discover_endpoint(openapi({"/scenes": op})) == Endpoint( + "/scenes", "multipart/form-data", "bundle" + ) + + +def test_one_path_with_json_and_upload_uses_json_as_the_app_does(): + both = { + "post": { + "requestBody": { + "content": { + "application/json": {"schema": {}}, + "application/zip": {"schema": {"type": "string", "format": "binary"}}, + "multipart/form-data": {"schema": {"$ref": "#/components/schemas/Upload"}}, + } + } + } + } + assert discover_endpoint(openapi({"/v1/placements": both})) == Endpoint( + "/v1/placements", "application/json", None, accepts_zip=True + ) + + +def test_a_post_answering_with_svg_is_not_the_placement_endpoint(): + svg = json_op() + svg["post"]["responses"] = {"200": {"content": {"image/svg+xml": {}}}} + found = discover_endpoint(openapi({"/v1/placements": json_op(), "/v1/plan.svg": svg})) + assert found.path == "/v1/placements" + + +def test_a_scene_named_path_wins_among_several(): + found = discover_endpoint(openapi({"/login": json_op(), "/placement": json_op()})) + assert found.path == "/placement" + + +def test_ambiguity_is_loud(): + with pytest.raises(SystemExit, match="Pass --endpoint"): + discover_endpoint(openapi({"/a": json_op(), "/b": json_op()})) + with pytest.raises(SystemExit, match="Pass --endpoint"): + discover_endpoint(openapi({})) + assert discover_endpoint(openapi({"/a": json_op(), "/b": json_op()}), "/b").path == "/b" + + +def test_named_post_without_a_body_schema_takes_json(): + raw_body = {"post": {"responses": {}}} + found = discover_endpoint(openapi({"/v1/placements": raw_body}), "/v1/placements") + assert found == Endpoint("/v1/placements", "application/json") + with pytest.raises(SystemExit, match="not a POST"): + discover_endpoint(openapi({"/v1/placements": raw_body}), "/v1/other") + + +def test_case_file_is_loaded_with_its_expectations(tmp_path): + path = tmp_path / "c.json" + path.write_text( + json.dumps( + { + "id": "c1", + "scene": SCENE, + "expect": {"decision_not": ["pass"]}, + "rules_assumed": {"gas": 3.0}, + } + ) + ) + item = load_input(path) + assert item.name == "c1" + assert item.expect == {"decision_not": ["pass"]} + assert json.loads(item.raw) == SCENE + + +def test_case_pointing_at_a_scene_outside_git(tmp_path): + scene = tmp_path / "scene.json" + scene.write_text(json.dumps(SCENE)) + case = tmp_path / "case.json" + case.write_text( + json.dumps( + { + "id": "real-1", + "real": True, + "scene_path": str(scene), + "expect": {"decision_not": ["pass"]}, + } + ) + ) + item = load_input(case) + assert (item.name, item.real, item.expect) == ("real-1", True, {"decision_not": ["pass"]}) + scene.unlink() + assert load_input(case).skip_reason.endswith("not built here; see the case's README") + + +class FakeServer(BaseHTTPRequestHandler): + """Answers every scene with a consistent manual_review; `decide_pass` breaks C5.""" + + decide_pass = False + + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + if self.headers["Content-Type"].startswith("multipart/"): + start = body.index(b"PK") # the zip starts after the part headers + import io + import zipfile + + raw = zipfile.ZipFile(io.BytesIO(body[start:])).read("scene.json") + else: + raw = body + scene = json.loads(raw) + passing = self.decide_pass + result = { + "schema_version": "1.0", + "decision": "pass" if passing else "manual_review", + "summary": "", + "reasons": [{"code": "policy_not_approved", "message": ""}], + "policy": { + "id": None, + "version": None, + "auto_approve": passing, + "sources": [], + "rules_sha256": "0" * 64, + }, + "spot": None, + "route": None, + # Every check a server lists, passing with no numbers, so no check is left out. + "checks": [ + { + "id": i, + "label": i, + "outcome": "pass", + "reason": "", + "measured_ft": None, + "plus_minus_ft": None, + "threshold_ft": None, + "comparison": "at_least", + "rule": {"key": i, "source": "", "placeholder": False}, + } + for i in ( + "wall_backing", + "ground_surface", + "meter_working_space", + "gas_clearance", + "ac_clearance", + "drive_clearance", + "pool_clearance", + "opening_clearance", + "wall_equipment_above", + "facing_gap", + "headroom", + "route_path", + "route_length", + ) + ], + "missing_evidence": [], + "ends": {}, + "sweep": [ + { + "wall_id": scene["walls"][0]["id"], + "start_ft": [1.0, 2.0], + "outcome": "pass" if passing else "unsure", + "failing": [], + "unsure": [], + } + ], + "stats": { + "candidates": 1, + "pass": int(passing), + "unsure": int(not passing), + "fail": 0, + "elapsed_ms": 1.0, + "input_sha256": hashlib.sha256(raw).hexdigest(), + }, + } + payload = json.dumps(result).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + +@pytest.fixture +def fake_url(): + server = HTTPServer(("127.0.0.1", 0), FakeServer) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_port}" + server.shutdown() + FakeServer.decide_pass = False + + +SCHEMAS = {"scene": {}, "result": {}} + + +def item() -> SceneInput: + raw = json.dumps(SCENE).encode() + return SceneInput("fake", SCENE, raw, expect={"decision_not": ["pass"]}) + + +@pytest.mark.parametrize( + "endpoint", + [Endpoint("/place", "application/json"), Endpoint("/place", "multipart/form-data", "file")], +) +def test_consistent_server_passes(fake_url, endpoint): + record = judge(fake_url, endpoint, item(), SCHEMAS, RULES) + assert record["status"] == "pass", record["problems"] + assert record["decision"] == "manual_review" + + +def test_server_breaking_coverage_rule_fails(fake_url): + FakeServer.decide_pass = True + record = judge(fake_url, Endpoint("/place", "application/json"), item(), SCHEMAS, RULES) + assert record["status"] == "fail" + joined = "\n".join(record["problems"]) + assert "ruled out for this case" in joined + assert "no coverage: decision pass for a scene with no coverage at all" in joined + assert re.search(r"needs ground \[.*\] observed, none seen", joined) + + +def test_a_result_breaking_its_schema_counts_against_the_contract(fake_url): + strict = {"scene": {}, "result": {"required": ["nothing_has_this"]}} + record = judge(fake_url, Endpoint("/place", "application/json"), item(), strict, RULES) + assert record["status"] == "fail" + assert record["contract_problems"] and record["contract_problems"] == record["problems"] + + +def test_bad_input_is_not_blamed_on_the_server(fake_url): + strict = {"scene": {"required": ["nothing_has_this"]}, "result": {}} + record = judge(fake_url, Endpoint("/place", "application/json"), item(), strict, RULES) + assert record["status"] == "bad input" + + +class RefusingServer(BaseHTTPRequestHandler): + """Refuses every request with 422 after `delay` seconds.""" + + delay = 0.0 + content_types: ClassVar[list[str]] = [] + + def do_POST(self): + RefusingServer.content_types.append(self.headers["Content-Type"]) + self.rfile.read(int(self.headers["Content-Length"])) + time.sleep(self.delay) + self.send_response(422) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, *args): + pass + + +@pytest.fixture +def refusing_url(): + server = HTTPServer(("127.0.0.1", 0), RefusingServer) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{server.server_port}" + server.shutdown() + RefusingServer.delay = 0.0 + + +def test_a_quick_refusal_of_a_hostile_input_passes(refusing_url): + hostile = hostile_inputs()[3] + record = judge_hostile(refusing_url, Endpoint("/p", "application/json"), hostile, SCHEMAS) + assert record["status"] == "pass" and record["http_status"] == 422 + + +def test_a_slow_refusal_or_a_hang_fails(refusing_url, monkeypatch): + monkeypatch.setattr(e2e, "HOSTILE_BUDGET_MS", 100.0) + RefusingServer.delay = 0.3 + hostile = hostile_inputs()[3] + record = judge_hostile(refusing_url, Endpoint("/p", "application/json"), hostile, SCHEMAS) + assert record["status"] == "fail" and "budget 100" in record["problems"][0] + RefusingServer.delay = 6.0 # longer than budget + 5 s: the request times out + record = judge_hostile(refusing_url, Endpoint("/p", "application/json"), hostile, SCHEMAS) + assert record["status"] == "fail" and record["problems"][0].startswith("no answer within") + + +def test_hostile_inputs_are_small_to_send(): + items = hostile_inputs() + assert all(i.hostile for i in items) + assert max(len(bundle_zip(i)) for i in items) < 2_000_000 # the harness stays light + + +def test_the_compressed_scene_expands_to_its_size(): + with zipfile.ZipFile(io.BytesIO(zip_bomb(3))) as z: + assert z.getinfo("scene.json").file_size == 3 * 2**20 + len(b'{"meter": 1}') + + +def test_a_sim_report_folder_is_an_app_export_with_its_sha(tmp_path): + with zipfile.ZipFile(tmp_path / "scan.zip", "w") as z: + z.writestr("scene.json", json.dumps(SCENE)) + (tmp_path / "report.json").write_text(json.dumps({"sha": "abc123", "app_export": "scan.zip"})) + item = load_app_export(tmp_path) + assert (item.app_export, item.app_sha, item.real) == (True, "abc123", True) + (tmp_path / "report.json").write_text(json.dumps({"sha": "abc123"})) + with pytest.raises(SystemExit, match="never reached the upload"): + load_app_export(tmp_path) + + +def test_a_prebuilt_zip_goes_as_application_zip_when_the_endpoint_takes_it(refusing_url): + RefusingServer.content_types.clear() + bomb = hostile_inputs()[4] + json_only = Endpoint("/p", "application/json") + assert judge_hostile(refusing_url, json_only, bomb, SCHEMAS)["status"] == "skipped" + zip_too = Endpoint("/p", "application/json", accepts_zip=True) + assert judge_hostile(refusing_url, zip_too, bomb, SCHEMAS)["status"] == "pass" + assert RefusingServer.content_types == ["application/zip"] + + +# --- What a run may claim -------------------------------------------------------------------- + +META = { + "started_at": "2026-09-26T12:00:00-05:00", + "server_ref": "origin/t3/server", + "server_sha": "a" * 40, + "endpoint": "POST /v1/placements (application/json)", + "load_average_1_5_15": [1.0, 1.0, 1.0], + "peak_memory": {"harness_mb": 50.0}, +} +CHECKED = { + "name": "a", + "status": "pass", + "decision": "manual_review", + "http_status": 200, + "latency_ms": 120.0, + "real": True, + "contract_problems": [], +} + + +@pytest.mark.parametrize( + "records", + [ + [], + [{"name": "s", "status": "skipped", "problems": [], "skip_reason": "no data"}], + [{"name": "b", "status": "bad input", "problems": ["scene schema: x"]}], + [{"name": "h", "status": "pass", "hostile": True, "http_status": 422, "problems": []}], + ], + ids=["no scenes", "skipped only", "bad input only", "hostile only"], +) +def test_a_run_that_checked_no_answer_claims_nothing(tmp_path, records): + report = e2e.write_report(tmp_path, META, records) + assert (report["scenes_checked"], report["contract_ok"], report["all_passed"]) == ( + 0, + False, + False, + ) + + +def test_a_checked_scene_credits_the_contract(tmp_path): + report = e2e.write_report(tmp_path, META, [CHECKED]) + assert (report["scenes_checked"], report["contract_ok"], report["latency_ms"]) == ( + 1, + True, + 120.0, + ) + broken = CHECKED | {"status": "fail", "contract_problems": ["x"], "problems": ["x"]} + assert e2e.write_report(tmp_path, META, [broken])["contract_ok"] is False + + +def test_a_fast_refusal_of_a_real_scene_is_not_latency(tmp_path): + refused = { + "name": "r", + "status": "fail", + "real": True, + "http_status": 422, + "latency_ms": 8.0, + "problems": ["HTTP 422"], + "contract_problems": ["HTTP 422"], + } + report = e2e.write_report(tmp_path, META, [refused]) + assert report["latency_ms"] is None and report["scenes_answered"] == 0 + + +def test_an_oversized_bundle_is_refused_before_it_is_read(tmp_path): + archive = tmp_path / "big.zip" + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z, z.open("scene.json", "w") as f: + chunk = b" " * 2**20 + for _ in range(e2e.MAX_SCENE_BYTES // 2**20 + 1): + f.write(chunk) + with pytest.raises(SystemExit, match=r"declares \d+ bytes, limit"): + load_input(archive) + + +def test_a_bundle_with_too_many_entries_is_refused(tmp_path, monkeypatch): + monkeypatch.setattr(e2e, "MAX_BUNDLE_ENTRIES", 3) + archive = tmp_path / "many.zip" + with zipfile.ZipFile(archive, "w") as z: + for i in range(4): + z.writestr(f"k{i}.jpg", b"x") + with pytest.raises(SystemExit, match="4 entries, limit 3"): + load_input(archive) + + +def test_a_200_that_is_not_a_json_object_is_a_failure_not_a_crash(monkeypatch): + for body in (b"oops", b"[1, 2]"): + monkeypatch.setattr(e2e, "post", lambda *args, body=body, **kwargs: (200, body, 5.0)) + endpoint = Endpoint("/place", "application/json") + record = judge("http://unused", endpoint, item(), SCHEMAS, RULES) + assert record["status"] == "fail" + assert "not a JSON object" in record["contract_problems"][0] + hostile = judge_hostile("http://unused", endpoint, hostile_inputs()[3], SCHEMAS) + assert hostile["status"] == "fail" and "not a JSON object" in hostile["problems"][0] diff --git a/verification/tests/test_generate_cases.py b/verification/tests/test_generate_cases.py new file mode 100644 index 000000000..01c7cdfac --- /dev/null +++ b/verification/tests/test_generate_cases.py @@ -0,0 +1,41 @@ +"""Regenerating the e2e cases replaces only the files the generator owns.""" + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "e2e" / "cases" / "generate.py" +spec = importlib.util.spec_from_file_location("generate_cases", SCRIPT) +generate = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = generate # dataclasses look their module up there +spec.loader.exec_module(generate) + + +def test_hand_written_cases_survive_and_stale_generated_ones_go(tmp_path): + hand = tmp_path / "manually-added-regression.json" + hand.write_text(json.dumps({"id": "manually-added-regression", "scene": {}})) + stale = tmp_path / "renamed-away.json" + stale.write_text(json.dumps({"generated_by": generate.GENERATED_BY, "id": "renamed-away"})) + generate.main(tmp_path) + assert hand.exists() + assert not stale.exists() + written = {p.stem for p in tmp_path.glob("*.json")} - {hand.stem} + assert written == {c["id"] for c in generate.CASES} + + +def test_a_hand_written_file_with_a_generated_id_is_refused(tmp_path): + taken = generate.CASES[0]["id"] + (tmp_path / f"{taken}.json").write_text(json.dumps({"id": taken})) + with pytest.raises(SystemExit, match="written by hand"): + generate.main(tmp_path) + assert json.loads((tmp_path / f"{taken}.json").read_text()) == {"id": taken} + + +def test_the_committed_cases_are_what_the_generator_writes(tmp_path): + generate.main(tmp_path) + committed = SCRIPT.parent + for f in tmp_path.glob("*.json"): + assert f.read_text() == (committed / f.name).read_text(), f.name diff --git a/verification/tests/test_gitref.py b/verification/tests/test_gitref.py new file mode 100644 index 000000000..da2c1511a --- /dev/null +++ b/verification/tests/test_gitref.py @@ -0,0 +1,14 @@ +from hsverify import gitref + + +def test_two_uses_of_one_sha_get_separate_trees_and_both_clean_up(): + sha = gitref.resolve("HEAD") + with gitref.detached_worktree(sha) as a, gitref.detached_worktree(sha) as b: + assert a != b + assert (a / "verification").is_dir() and (b / "verification").is_dir() + assert not a.exists() and not b.exists() + + +def test_show_returns_none_for_a_missing_file(): + assert gitref.show("HEAD", "no/such/file") is None + assert gitref.show("HEAD", "verification/pyproject.toml") is not None diff --git a/verification/tests/test_memory.py b/verification/tests/test_memory.py new file mode 100644 index 000000000..c06a61dcd --- /dev/null +++ b/verification/tests/test_memory.py @@ -0,0 +1,64 @@ +import os +import signal +import subprocess +import sys +import time + +from hsverify.memory import TreeLimit, peak_rss_mb, sum_tree_mb, tree_rss_mb + + +def start(code: str) -> subprocess.Popen: + return subprocess.Popen( + [sys.executable, "-c", code], stdout=subprocess.PIPE, text=True, start_new_session=True + ) + + +def stop(child: subprocess.Popen) -> None: + """Kill the child's whole group and reap it, whatever state the test left it in.""" + if child.poll() is None: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=10) + child.stdout.close() + + +def test_peak_memory_is_reported_in_megabytes(): + peak = peak_rss_mb() + # A Python test process uses tens of MB, not bytes or gigabytes. + assert 5 < peak["harness_mb"] < 2000 + assert peak["largest_child_mb"] >= 0 + + +def test_the_tree_sums_a_process_and_all_its_descendants_only(): + # pid ppid rss_kb: 10 -> 11 -> 12, and 20 is unrelated. + listing = " 10 1 1024\n 11 10 2048\n 12 11 1024\n 20 1 9999\n" + assert sum_tree_mb(listing, 10) == 4.0 + assert sum_tree_mb(listing, 11) == 3.0 + assert sum_tree_mb(listing, 99) == 0.0 + + +def test_this_process_is_measured(): + # What the OS keeps resident moves with memory pressure on a shared Mac, so only the reading + # itself is checked here; the tree arithmetic is checked above on a fixed listing. + assert tree_rss_mb(os.getpid()) > 1 + + +def test_a_process_group_over_the_limit_is_killed(): + child = start("import time; time.sleep(60)") + try: + with TreeLimit(child, limit_mb=40, interval_s=0.05, measure=lambda pid: 100.0) as limit: + child.wait(timeout=5) + assert limit.killed and limit.peak_mb == 100.0 + assert child.returncode == -signal.SIGKILL + finally: + stop(child) + + +def test_a_process_group_under_the_limit_is_left_running(): + child = start("import time; time.sleep(60)") + try: + with TreeLimit(child, limit_mb=40, interval_s=0.05, measure=lambda pid: 10.0) as limit: + time.sleep(0.3) + assert not limit.killed and limit.peak_mb == 10.0 + assert child.poll() is None + finally: + stop(child) diff --git a/verification/tests/test_replaycheck.py b/verification/tests/test_replaycheck.py new file mode 100644 index 000000000..dd830e2e3 --- /dev/null +++ b/verification/tests/test_replaycheck.py @@ -0,0 +1,138 @@ +import json +import struct + +import numpy as np +import pytest + +from hsverify.replaycheck import ( + accuracy, + check_format, + jpeg_size, + pose_matrix, + rigid_problems, + yaw_translation_fit, +) + + +def yaw(deg: float) -> np.ndarray: + t = np.radians(deg) + return np.array([[np.cos(t), 0, np.sin(t)], [0, 1, 0], [-np.sin(t), 0, np.cos(t)]]) + + +def flat(m: np.ndarray) -> list[float]: + return [float(x) for x in m.T.reshape(-1)] # column by column + + +def pose(r: np.ndarray, t) -> np.ndarray: + m = np.eye(4) + m[:3, :3], m[:3, 3] = r, t + return m + + +def write_jpeg_header(path, w: int, h: int) -> None: + # SOI, then a baseline SOF0 segment with the size; enough for jpeg_size. + sof = b"\xff\xc0" + struct.pack(">HBHHB", 11, 8, h, w, 1) + b"\x01\x11\x00" + path.write_bytes(b"\xff\xd8" + sof + b"\xff\xd9") + + +def test_pose_is_read_column_by_column(): + m = pose(yaw(30), [1, 2, 3]) + assert np.allclose(pose_matrix(flat(m)), m) + + +def test_rigid_problems(): + assert rigid_problems(pose(yaw(10), [0, 0, 0])) == [] + bad = pose(yaw(10), [0, 0, 0]) + bad[:3, :3] *= 1.1 + assert rigid_problems(bad) + + +def test_yaw_fit_recovers_known_transform(): + rng = np.random.default_rng(0) + src = rng.normal(size=(20, 3)) * 5 + dst = src @ yaw(123).T + np.array([4.0, -1.0, 2.0]) + r, t = yaw_translation_fit(src, dst) + assert np.allclose(r, yaw(123), atol=1e-9) + assert np.allclose(t, [4.0, -1.0, 2.0], atol=1e-9) + + +def test_jpeg_size(tmp_path): + write_jpeg_header(tmp_path / "a.jpg", 1280, 720) + assert jpeg_size(tmp_path / "a.jpg") == (1280, 720) + + +def session_with(tmp_path, positions, spacing=0.5): + (tmp_path / "keyframes").mkdir() + keyframes = [] + for i, p in enumerate(positions, 1): + write_jpeg_header(tmp_path / "keyframes" / f"k{i}.jpg", 640, 480) + keyframes.append( + { + "id": f"k{i}", + "img": f"keyframes/k{i}.jpg", + "w": 640, + "h": 480, + "intrinsics": [500, 500, 320, 240], + "pose": flat(pose(yaw(0), p)), + "timestamp": float(i), + "tracking": "normal", + "reason": "motion", + } + ) + return { + "format": "measure-lab-session", + "formatVersion": 2, + "session": {}, + "gates": {"keyframeSpacingMeters": spacing, "keyframeSpacingDegrees": 15.0}, + "keyframes": keyframes, + } + + +def test_valid_session_has_no_format_errors(tmp_path): + s = session_with(tmp_path, [[0, 0, -0.6 * i] for i in range(5)]) + errors, summary = check_format(tmp_path, s) + assert errors == [] + assert summary["keyframes"] == 5 + + +def test_format_errors_are_specific(tmp_path): + s = session_with(tmp_path, [[0, 0, -0.6 * i] for i in range(3)]) + s["keyframes"][1]["w"] = 1920 # disagrees with the JPEG + s["keyframes"][2]["pose"] = flat(pose(yaw(0), [0, 0, -0.7])) # 0.1 m after the previous + (tmp_path / "keyframes" / "k1.jpg").unlink() + errors, _ = check_format(tmp_path, s) + joined = "\n".join(errors) + assert "k1: image keyframes/k1.jpg not found" in joined + assert "k2: JPEG is (640, 480)" in joined + assert "1 motion keyframes closer than the declared spacing gate" in joined + + +def test_malformed_fields_are_format_errors_not_crashes(tmp_path): + s = session_with(tmp_path, [[0, 0, -0.6 * i] for i in range(4)]) + del s["gates"]["keyframeSpacingDegrees"] + s["keyframes"][1]["intrinsics"] = [500, 500, 320] + s["keyframes"][2]["pose"] = s["keyframes"][2]["pose"][:15] + s["keyframes"][3]["intrinsics"] = [500, "500", 320, 240] + errors, _ = check_format(tmp_path, s) + assert "gates set keyframeSpacingMeters without keyframeSpacingDegrees" in errors + for name in ("k2", "k3", "k4"): + assert f"{name}: intrinsics must be 4 finite numbers and pose 16" in errors + + +@pytest.mark.parametrize("scale", [1.0, 0.64]) +def test_accuracy_separates_scale_from_alignment(tmp_path, scale): + truth_pos = [[0.0, 0.0, -float(i)] for i in range(40)] # straight walk along -z + s = session_with(tmp_path, [[p[0], p[1], p[2] * scale] for p in truth_pos]) + # Ground truth in its own world: turned 90 degrees and shifted. + truth = { + "keyframes": [ + {"keyframe": kf["id"], "pose": flat(pose(yaw(90), yaw(90) @ np.array(p) + [7, 0, 3]))} + for kf, p in zip(s["keyframes"], truth_pos, strict=True) + ] + } + result = accuracy(json.loads(json.dumps(s)), truth) + assert result["arkit_to_truth_length_ratio"] == pytest.approx(scale, abs=1e-3) + assert result["best_fit_error_after_scale_m"]["p90"] == pytest.approx(0, abs=1e-6) + assert result["max_vertical_axis_mismatch"] == pytest.approx(0, abs=1e-9) + expected_final = (1 - scale) * 39 + assert result["anchored_error_m_final"] == pytest.approx(expected_final, abs=1e-3) diff --git a/verification/tests/test_resultcheck.py b/verification/tests/test_resultcheck.py new file mode 100644 index 000000000..5846b7a9a --- /dev/null +++ b/verification/tests/test_resultcheck.py @@ -0,0 +1,1481 @@ +import copy +import hashlib +import json +from dataclasses import replace + +import pytest + +from hsverify.resultcheck import ( + GroundPlan, + Need, + RuleSet, + assumption_mismatches, + battery_error, + chain_ends_s, + comparable, + coverage_problems, + declared_height_problems, + expectation_problems, + expected_checks, + footprint_problems, + gaps_over, + invariant_problems, + less_coverage_problems, + margin_problem, + mirror_scene, + missing_evidence_problems, + more_error_problems, + observed, + outcome_at, + outcome_lengths, + reach_gaps, + run_starts, + straight_pieces, + tape_to_tap, + with_ground_short_of, + with_less_coverage, + with_more_error, + with_requests_captured, +) + +# Small radii keep the geometry checkable by hand: a battery 31 x 22 in, gas and openings 1 ft. +RULES = RuleSet( + width_ft=31 / 12, + depth_ft=22 / 12, + needs={ + "ground_surface": (Need("ground", 0.0),), + "gas_clearance": (Need("ground", 1.0),), + "opening_clearance": (Need("wall", 1.0, height=6.5),), + }, + errors={ + "tap": 0.3, + "vlm": 1.5, + "tape": 0.05, + "wall": 0.3, + "mesh": 0.5, + "plane": 0.75, + "meter": 0.3, + "drift_per_ft": 0.16, + }, + step_ft=1 / 6, # the server's 2 in + wall_join_ft=0.6, + battery_height_ft=3.25, + headroom_ft=6.5, +) + +SCENE = { + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0}, + # An explicit zero error keeps the reach arithmetic in these tests to the radii alone. + "walls": [{"id": "w1", "baseline": [[-12.0, 0.0], [12.0, 0.0]], "plus_minus_ft": 0.0}], + "objects": [ + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [-6.0, -5.0], + "source": "tap", + "footprint": [[-6.0, 0.5], [-5.0, 1.0]], + }, + ], + "coverage": { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "unexplored"}}, + "observed": [ + {"band": "wall", "span_ft": [-12.0, 10.0]}, + {"band": "ground", "span_ft": [-12.0, 10.0], "out_ft": 3.0}, + {"band": "ground", "span_ft": [10.0, 12.0], "out_ft": 1.0}, + ], + }, +} + + +def check(outcome="pass", measured=5.0, error=0.3, threshold=3.0, cmp="at_least", cause=None): + c = { + "id": "gas_clearance", + "label": "Gas", + "outcome": outcome, + "reason": "", + "measured_ft": measured, + "plus_minus_ft": error, + "threshold_ft": threshold, + "comparison": cmp, + "rule": {"key": "gas_clearance_ft", "source": "", "placeholder": False}, + } + if cause: + c["unsure_cause"] = cause + return c + + +# Every check a 3baa338 result lists (battery_clearance only when the scene or rules call for it). +SERVER_CHECKS = ( + "wall_backing", + "ground_surface", + "meter_working_space", + "gas_clearance", + "ac_clearance", + "drive_clearance", + "pool_clearance", + "opening_clearance", + "wall_equipment_above", + "facing_gap", + "headroom", + "route_path", + "route_length", +) + + +def result(decision="manual_review", spot=True, checks=None, sweep=None, sent=b"{}", complete=True): + """A result listing `checks` (default: gas passing); with `complete`, every other check a + server lists is added as a pass with no numbers, so a test is about the checks it names.""" + checks = [check()] if checks is None else list(checks) + if complete: + named = {c["id"] for c in checks} + checks += [passing(i) for i in SERVER_CHECKS if i not in named] + s = None + if spot: + s = { + "outcome": "pass", + "wall_id": "w1", + "segment": 0, + "span_ft": [1.0, 1.0 + 31 / 12], + "width_ft": 31 / 12, + "depth_ft": 22 / 12, + "height_ft": 3.3, + "footprint": [[1, 0], [3.58, 0], [3.58, 1.83], [1, 1.83]], + "center": [2.29, 0.92], + "along": [1, 0], + "outward": [0, 1], + "meter_offset_ft": [2.29, 0.92], + "route_length_ft": 1.0, + } + return { + "schema_version": "1.0", + "decision": decision, + "summary": "", + "reasons": [] if decision == "pass" else [{"code": "policy_not_approved", "message": ""}], + "policy": { + "id": None, + "version": None, + "auto_approve": decision != "manual_review", + "sources": ["public"], + "rules_sha256": "0" * 64, + }, + "spot": s, + "route": None if s is None else {"outcome": "pass"}, + "checks": checks, + "missing_evidence": [], + "ends": {}, + "sweep": sweep + if sweep is not None + else [ + { + "wall_id": "w1", + "start_ft": [1.0, 1.0], + "outcome": "pass", + "failing": [], + "unsure": [], + }, + ], + "stats": { + "candidates": 1, + "pass": 1, + "unsure": 0, + "fail": 0, + "elapsed_ms": 3.0, + "input_sha256": hashlib.sha256(sent).hexdigest(), + }, + } + + +def test_consistent_result_has_no_problems(): + assert invariant_problems(SCENE, result(), sent=b"{}", rules=RULES) == [] + + +@pytest.mark.parametrize( + ("cmp", "measured", "error", "threshold", "expected"), + [ + ("at_least", 3.31, 0.3, 3.0, "pass"), + ("at_least", 3.3, 0.3, 3.0, "unsure"), # margin equals the error + ("at_least", 2.7, 0.3, 3.0, "unsure"), + ("at_least", 2.69, 0.3, 3.0, "fail"), + ("at_most", 19.69, 0.3, 20.0, "pass"), + ("at_most", 19.7, 0.3, 20.0, "unsure"), + ("at_most", 20.31, 0.3, 20.0, "fail"), + ], +) +def test_margin_rule(cmp, measured, error, threshold, expected): + for outcome in ("pass", "fail", "unsure"): + problem = margin_problem(check(outcome, measured, error, threshold, cmp)) + assert (problem is None) == (outcome == expected), (outcome, problem) + + +def test_unsure_for_another_cause_may_sit_on_passing_numbers_but_not_failing_ones(): + assert margin_problem(check("unsure", 5.9, 0.6, 3.0, cause="unobserved")) is None + assert margin_problem(check("unsure", 3.1, 0.6, 3.0, cause="unknown_attribute")) is None + problem = margin_problem(check("unsure", 1.0, 0.6, 3.0, cause="unobserved")) + assert problem is not None and "should be fail" in problem + # A margin-caused unsure must really be inside the band. + assert margin_problem(check("unsure", 5.9, 0.6, 3.0, cause="margin")) is not None + + +def route(outcome, measured, error=0.0, cause=None): + c = check(outcome, measured, error, 20.0, "at_most", cause) + c["review_threshold_ft"] = 15.0 + return c + + +def test_review_band_blocks_a_pass_and_is_not_a_margin(): + assert margin_problem(route("pass", 14.0)) is None + assert "should be unsure" in margin_problem(route("pass", 16.0)) + assert margin_problem(route("unsure", 16.0, cause="rule_requires_review")) is None + assert "not within its error of any line" in margin_problem( + route("unsure", 16.0, cause="margin") + ) + # Within error of either line, margin is the right cause. + assert margin_problem(route("unsure", 15.2, 0.3, cause="margin")) is None + assert margin_problem(route("unsure", 19.9, 0.3, cause="margin")) is None + assert margin_problem(route("unsure", 15.0, cause="margin")) is None # on the line + assert margin_problem(route("fail", 20.4, 0.3)) is None + + +def test_margin_rule_skips_checks_without_numbers(): + assert margin_problem(check("unsure", measured=None, cause="unobserved")) is None + + +def test_decision_consistency(): + bad_pass = result("pass", checks=[check("unsure", 3.2, cause="margin")]) + msgs = invariant_problems(SCENE, bad_pass) + assert any("checks not passing" in m for m in msgs) + reject_with_spot = result("reject") + assert any("reject but a spot" in m for m in invariant_problems(SCENE, reject_with_spot)) + auto_off = result("pass") + auto_off["policy"]["auto_approve"] = False + assert any("auto_approve" in m for m in invariant_problems(SCENE, auto_off)) + + +def test_unsure_cause_bookkeeping(): + msgs = invariant_problems(SCENE, result(checks=[check("unsure", 3.2)])) + assert any("unsure without unsure_cause" in m for m in msgs) + msgs = invariant_problems(SCENE, result(checks=[check("pass", cause="margin")])) + assert any("unsure_cause on a pass check" in m for m in msgs) + + +def test_counts_hash_and_offset(): + r = result() + r["stats"]["fail"] = 2 + r["spot"]["meter_offset_ft"] = [2.0, 0.92] + msgs = invariant_problems(SCENE, r, sent=b"other bytes") + assert any(m.startswith("stats: pass") for m in msgs) + assert any("input_sha256" in m for m in msgs) + assert any("meter_offset_ft" in m for m in msgs) + + +def test_observed_merges_and_filters_ground_depth(): + assert observed(SCENE, "wall") == [(-12.0, 10.0)] + assert observed(SCENE, "ground", min_out_ft=2.0) == [(-12.0, 10.0)] + assert observed(SCENE, "ground", min_out_ft=0.5) == [(-12.0, 12.0)] + assert observed({"meter": {}}, "wall") == [] + + +def with_ground(out_ft: float, span: list[float]) -> dict: + scene = copy.deepcopy(SCENE) + scene["coverage"]["observed"][1:] = [{"band": "ground", "span_ft": span, "out_ft": out_ft}] + return scene + + +def test_a_pass_needs_ground_out_to_depth_plus_each_radius(): + # Start 1 covers [1, 3.58]. Gas (radius 1) needs ground out to 1.83 + 1 = 2.83 ft, in front + # and just past its ends; the first stretch short of it is [0, 1]. + msgs = invariant_problems(with_ground(2.5, [-12.0, 10.0]), result(), rules=RULES) + needs = "gas_clearance needs ground [0.00, 1.00] observed out to 2.83 ft, seen 2.50 ft" + assert any(needs in m for m in msgs) + assert invariant_problems(with_ground(2.84, [-12.0, 10.0]), result(), rules=RULES) == [] + + +def test_past_the_battery_the_ground_needed_shrinks_with_distance(): + # d past the battery's left end at 1, gas (radius 1) needs ground out to D + sqrt(1 - d^2): + # 1.83 + 0.87 = 2.70 for [0, 0.5], and D + 1 = 2.83 for [0.5, 1]. + scene = with_ground(2.84, [1.0, 10.0]) + scene["coverage"]["observed"] += [ + {"band": "ground", "span_ft": [0.0, 0.5], "out_ft": 2.71}, + {"band": "ground", "span_ft": [0.5, 1.0], "out_ft": 2.84}, + ] + assert invariant_problems(scene, result(), rules=RULES) == [] + scene["coverage"]["observed"][-1]["out_ft"] = 2.8 + msgs = invariant_problems(scene, result(), rules=RULES) + assert any("ground [0.50, 1.00] observed out to 2.83 ft, seen 2.80 ft" in m for m in msgs) + + +def test_a_pass_needs_ground_along_the_wall_to_each_radius_without_slack(): + # Ground from 0.1: the 1 ft gas radius around [1, 3.58] reaches back to 0. + msgs = invariant_problems(with_ground(3.0, [0.1, 10.0]), result(), rules=RULES) + assert any("gas_clearance needs ground [0.00, 0.10] observed, none seen" in m for m in msgs) + + +def test_a_passing_opening_check_needs_the_wall_to_its_radius(): + scene = copy.deepcopy(SCENE) + scene["coverage"]["observed"][0]["span_ft"] = [0.0, 4.0] # radius needs [0, 4.58] + r = result( + checks=[ + check(), + {**check(), "id": "opening_clearance", "measured_ft": None, "plus_minus_ft": None}, + ] + ) + msgs = invariant_problems(scene, r, rules=RULES) + needs = "check opening_clearance passes but needs wall [0.00, 4.58] observed" + assert any(needs in m for m in msgs) + + +def test_pass_whose_route_crosses_unobserved_wall_is_flagged(): + scene = copy.deepcopy(SCENE) + scene["coverage"]["observed"][0]["span_ft"] = [0.5, 10.0] # the wall at s = [0, 0.5] unseen + msgs = invariant_problems(scene, result(), rules=RULES) + assert any("wall and cable route" in m for m in msgs) + + +def test_no_coverage_never_passes(): + scene = {k: v for k, v in SCENE.items() if k != "coverage"} + msgs = invariant_problems(scene, result("pass"), rules=RULES) + assert any("no coverage at all" in m for m in msgs) + + +def test_photo_request_for_an_observed_area_is_flagged(): + # The wall was seen with no out_ft, so to headroom height: a request up to it is redundant. + r = result() + r["missing_evidence"] = [ + {"kind": "band", "band": "wall", "span_ft": [-3.0, -1.0], "out_ft": 6.5, "message": ""} + ] + assert any("lists as observed" in m for m in invariant_problems(SCENE, r, rules=RULES)) + + +def test_every_unobserved_check_needs_a_request_naming_it(): + unseen = check("unsure", None, cause="unobserved") + r = result(checks=[unseen]) + r["reasons"] = [{"code": "unobserved_area", "message": ""}] + assert missing_evidence_problems(SCENE, r) == [ + "reason unobserved_area but missing_evidence is empty" + ] + r["missing_evidence"] = [ + {"kind": "band", "band": "wall", "span_ft": [11, 12], "checks": ["x"], "message": ""} + ] + assert missing_evidence_problems(SCENE, r) == [ + "check gas_clearance is unsure (unobserved) but no missing_evidence entry names it" + ] + r["missing_evidence"] = [ + { + "kind": "band", + "band": "ground", + "span_ft": [11, 12], + "checks": ["gas_clearance"], + "message": "", + } + ] + assert missing_evidence_problems(SCENE, r) == [] + + +def test_the_reach_widens_by_the_battery_position_error(): + # Default wall error at the far edge 3.58: 0.3 + 0.16 x 3.58 = 0.87; gas reach 1.87 ft, so + # ground out to 1.83 + 1.87 = 3.71 ft in front. + scene = with_ground(3.6, [-12.0, 10.0]) + del scene["walls"][0]["plus_minus_ft"] + msgs = invariant_problems(scene, result(), rules=RULES) + assert any("gas_clearance needs ground" in m and "out to 3.71 ft" in m for m in msgs) + scene["coverage"]["observed"][1]["out_ft"] = 3.72 + assert invariant_problems(scene, result(), rules=RULES) == [] + + +def test_a_clearance_is_named_under_each_band_it_lacks(): + two_band = replace( + RULES, + needs=RULES.needs | {"gas_clearance": (Need("ground", 1.0), Need("wall", 1.0, height=6.5))}, + ) + scene = copy.deepcopy(SCENE) + scene["coverage"]["observed"] = [] # nothing seen: both bands missing around the spot + r = result(checks=[check("unsure", None, cause="unobserved")]) + r["reasons"] = [{"code": "unobserved_area", "message": ""}] + ground_only = {"kind": "band", "band": "ground", "span_ft": [0, 5], "checks": ["gas_clearance"]} + r["missing_evidence"] = [ground_only | {"message": ""}] + assert missing_evidence_problems(scene, r, two_band) == [ + "check gas_clearance is unsure and needs wall [0.00, 4.58] observed higher than 6.5 ft, " + "but no wall request names it" + ] + r["missing_evidence"].append(ground_only | {"band": "wall", "message": ""}) + assert missing_evidence_problems(scene, r, two_band) == [] + + +def test_rules_come_from_rules_yaml_and_a_missing_one_is_loud(): + text = """ +battery: {width_ft: {value: 2.5}, depth_ft: {value: 1.5}, height_ft: {value: 3.25}} +errors: {tap_ft: {value: 0.3}, vlm_ft: {value: 1.5}, tape_ft: {value: 0.05}, + wall_ft: {value: 0.3}, mesh_ft: {value: 0.5}, plane_ft: {value: 0.75}, + meter_ft: {value: 0.3}, drift_per_ft: {value: 0.16}} +clearances: {gas_ft: {value: 3}, ac_ft: {value: 3}, battery_ft: {value: 3}, drive_ft: {value: 5}, + pool_ft: {value: 10}, opening_ft: {value: 3}, wall_equipment_ft: {value: 0}} +openings: {exempt_bottom_above_ft: null} +facing: {min_ft: {value: 3}} +headroom: {min_ft: {value: 6.5}} +route: {height_ft: {value: 1}} +sweep: {step_ft: {value: 0.166666666667}, wall_join_ft: {value: 0.6}} +""" + rules = RuleSet.from_yaml(text) + assert rules.needs["pool_clearance"] == (Need("ground", 10.0),) + assert rules.needs["gas_clearance"] == (Need("ground", 3.0), Need("wall", 3.0, 6.5)) + assert rules.needs["battery_clearance"] == (Need("ground", 3.0), Need("wall", 3.0, 6.5)) + assert rules.needs["opening_clearance"] == (Need("wall", 3.0, 6.5),) + assert rules.needs["wall_backing"] == (Need("wall", 0.0, 3.25),) + assert rules.needs["facing_gap"] == (Need("facing", 0.0, 4.5, along=True),) + assert rules.needs["headroom"] == (Need("overhead", 0.0, 6.5, along=True),) + assert (rules.wall_join_ft, rules.battery_height_ft, rules.headroom_ft) == (0.6, 3.25, 6.5) + assert (rules.width_ft, rules.errors["plane"], rules.route_height_ft) == (2.5, 0.75, 1.0) + lower = RuleSet.from_yaml( + text.replace("exempt_bottom_above_ft: null", "exempt_bottom_above_ft: 2") + ) + assert lower.needs["opening_clearance"] == (Need("wall", 3.0, 2.0),) + with pytest.raises(ValueError, match=r"rules\.yaml has no clearances\.pool_ft"): + RuleSet.from_yaml(text.replace("pool_ft: {value: 10}, ", "")) + + +def test_expectations(): + r = result( + complete=False, # no route check, which `bad` below expects + sweep=[ + { + "wall_id": "w1", + "start_ft": [-9.0, -4.0], + "outcome": "fail", + "failing": ["gas_clearance"], + "unsure": [], + }, + { + "wall_id": "w1", + "start_ft": [1.0, 1.0], + "outcome": "pass", + "failing": [], + "unsure": [], + }, + ], + ) + ok = { + "decision_not": ["pass"], + "spot": {"wall_id": "w1", "span_within": [0.0, 4.0]}, + "sweep_runs": [ + {"wall_id": "w1", "start_ft": [-8, -5], "outcome": "fail", "failing_match": "gas"} + ], + "checks": [{"match": "gas", "outcome": "pass"}], + "missing_evidence_empty": True, + } + assert expectation_problems(ok, r) == [] + bad = { + "decision_in": ["reject"], + "spot": None, + "sweep_runs": [{"wall_id": "w1", "start_ft": [0, 2], "outcome": "fail"}], + "checks": [{"match": "route", "outcome": "pass"}], + } + msgs = expectation_problems(bad, r) + assert len(msgs) == 4, msgs + + +def test_outcome_not_rules_out_one_outcome_and_tolerates_no_runs(): + r = result( + sweep=[ + { + "wall_id": "w1", + "start_ft": [0.0, 2.0], + "outcome": "unsure", + "failing": [], + "unsure": [], + }, + { + "wall_id": "w1", + "start_ft": [2.1, 3.0], + "outcome": "pass", + "failing": [], + "unsure": [], + }, + ] + ) + ok = { + "sweep_runs": [ + {"wall_id": "w1", "start_ft": [0.5, 1.5], "outcome_not": "pass"}, + {"wall_id": "w9", "start_ft": [0, 9], "outcome_not": "pass"}, + ] + } + assert expectation_problems(ok, r) == [] + bad = { + "sweep_runs": [ + {"wall_id": "w1", "start_ft": [0.5, 2.5], "outcome_not": "pass", "reason": "window"} + ] + } + assert expectation_problems(bad, r) == [ + "sweep w1 starts [2.1, 3.0] is pass, which this case rules out (window)" + ] + + +def test_pinned_check_numbers(): + r = result() + assert expectation_problems({"checks": [{"match": "gas", "measured_ft": 5.0000004}]}, r) == [] + msgs = expectation_problems({"checks": [{"match": "gas", "measured_ft": 4.5}]}, r) + assert msgs == ["check gas_clearance: measured_ft 5.0, expected 4.5"] + + +def test_assumption_mismatch(): + assert assumption_mismatches({"gas": 3.0}, result()) == [] + assert assumption_mismatches({"gas": 4.0}, result()) + + +def test_mirror_negates_s_and_keeps_baselines_left_to_right(): + m = mirror_scene(SCENE) + assert m["walls"][0]["baseline"] == [[-12.0, 0.0], [12.0, 0.0]] + assert m["objects"][0]["span_ft"] == [5.0, 6.0] + assert m["objects"][0]["footprint"] == [[6.0, 0.5], [5.0, 1.0]] + assert m["coverage"]["observed"][0]["span_ft"] == [-10.0, 12.0] + assert m["coverage"]["ends"] == {"left": {"kind": "unexplored"}, "right": {"kind": "limit"}} + assert mirror_scene(m) == json.loads(json.dumps(SCENE)) # an involution + assert SCENE["objects"][0]["span_ft"] == [-6.0, -5.0] # input untouched + + +def test_mirror_reverses_a_wall_chain(): + scene = { + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "w1"}, + "walls": [ + {"id": "w1", "baseline": [[-4.0, 0.0], [4.0, 0.0]]}, + {"id": "w2", "baseline": [[4.0, 0.0], [4.0, 12.0]]}, + ], + } + m = mirror_scene(scene) + assert [w["id"] for w in m["walls"]] == ["w2", "w1"] + assert m["walls"][0]["baseline"] == [[-4.0, 12.0], [-4.0, 0.0]] + assert m["walls"][1]["baseline"] == [[-4.0, 0.0], [4.0, 0.0]] + # Chain stays connected: w2 ends where w1 starts. + assert m["walls"][0]["baseline"][-1] == m["walls"][1]["baseline"][0] + + +def test_outcome_lengths_and_comparable(): + r = result( + sweep=[ + { + "wall_id": "w1", + "start_ft": [0.0, 2.0], + "outcome": "pass", + "failing": [], + "unsure": [], + }, + { + "wall_id": "w1", + "start_ft": [2.0, 3.0], + "outcome": "fail", + "failing": [], + "unsure": [], + }, + ] + ) + assert outcome_lengths(r) == {"pass": 2.0, "unsure": 0.0, "fail": 1.0} + other = copy.deepcopy(r) + other["stats"]["elapsed_ms"] = 99.0 + assert comparable(r) == comparable(other) + + +def run(a, b, outcome, wall="w1"): + return {"wall_id": wall, "start_ft": [a, b], "outcome": outcome, "failing": [], "unsure": []} + + +def swept(*runs, decision="manual_review"): + r = result(decision=decision, sweep=list(runs)) + return r + + +def test_start_outcomes_expectation(): + r = swept(run(0.0, 2.0, "fail"), run(2.1667, 5.0, "unsure")) + assert outcome_at(r, "w1", 1.0) == "fail" and outcome_at(r, "w1", 2.1) is None + ok = {"start_outcomes": [{"wall_id": "w1", "start_ft": 3.0, "outcome": "unsure"}]} + assert expectation_problems(ok, r) == [] + bad = { + "start_outcomes": [ + {"wall_id": "w1", "start_ft": 1.0, "outcome": "unsure", "why": "inside the error band"} + ] + } + assert expectation_problems(bad, r) == [ + "start w1 1.0 is fail, expected unsure (inside the error band)" + ] + + +@pytest.mark.parametrize( + ("before", "after", "allowed"), + [ + ("pass", "unsure", True), + ("fail", "unsure", True), + ("unsure", "unsure", True), + ("unsure", "fail", False), + ("unsure", "pass", False), + ("fail", "pass", False), + ("pass", "fail", False), + ], +) +def test_more_error_only_moves_toward_unsure(before, after, allowed): + msgs = more_error_problems(swept(run(0, 4, before)), swept(run(0, 4, after)), "more error") + assert (msgs == []) == allowed, msgs + + +def test_less_coverage_never_creates_a_pass(): + assert less_coverage_problems(swept(run(0, 4, "pass")), swept(run(0, 4, "unsure")), "x") == [] + msgs = less_coverage_problems(swept(run(0, 4, "unsure")), swept(run(0, 4, "pass")), "x") + assert msgs and all("went from unsure to pass" in m for m in msgs) + + +def test_transforms(): + scene = copy.deepcopy(SCENE) + scene["objects"].append({"type": "ac", "wall_id": "w1", "span_ft": [5, 6], "source": "tape"}) + more = with_more_error(scene, RULES) + # A 24 ft chain: a tap default can drift to 0.3 + 0.16 * 24 = 4.14; tape does not drift. + assert [o["plus_minus_ft"] for o in more["objects"]] == pytest.approx([4.64, 0.55]) + assert more["walls"][0]["plus_minus_ft"] == 0.5 + default_wall = copy.deepcopy(scene) + del default_wall["walls"][0]["plus_minus_ft"] + assert with_more_error(default_wall, RULES)["walls"][0]["plus_minus_ft"] == pytest.approx(4.64) + assert more["meter"]["plus_minus_ft"] == 0.5 # SCENE's meter is exact + assert [o["source"] for o in tape_to_tap(scene)["objects"]] == ["tap", "tap"] + assert tape_to_tap(SCENE) is None + less = with_less_coverage(scene) + assert less["coverage"]["observed"][0]["span_ft"] == [-11.5, 9.5] + assert less["coverage"]["observed"][1]["out_ft"] == 2.5 + short = with_ground_short_of(scene, RULES) # largest ground radius 1: out to 2.73 + assert [e.get("out_ft") for e in short["coverage"]["observed"]] == [ + None, + pytest.approx(22 / 12 + 1 - 0.1), + 1.0, + ] + assert with_ground_short_of(short, RULES) is None # nothing left beyond the radius + r = result() + r["missing_evidence"] = [ + {"kind": "band", "band": "ground", "span_ft": [12, 11], "message": ""}, + {"kind": "past_end", "side": "left", "message": ""}, + ] + captured = with_requests_captured(scene, r) + assert captured["coverage"]["observed"][-1] == { + "band": "ground", + "span_ft": [11, 12], + "out_ft": 40.0, + } + assert with_requests_captured(scene, result()) is None + + +def test_a_ground_request_is_redundant_only_as_far_out_as_it_asks(): + r = result() + ask = {"kind": "band", "band": "ground", "span_ft": [-3.0, -1.0], "message": ""} + r["missing_evidence"] = [ask | {"out_ft": 2.5}] # SCENE saw this ground 3 ft out + assert any("lists as observed" in m for m in invariant_problems(SCENE, r, rules=RULES)) + r["missing_evidence"] = [ask | {"out_ft": 3.5}] + assert not any("lists as observed" in m for m in invariant_problems(SCENE, r, rules=RULES)) + + +# --- Every band's reach, and the wall's source --------------------------------------------------- + +PASS_RUN = {"wall_id": "w1", "start_ft": [1.0, 1.0], "outcome": "pass", "failing": [], "unsure": []} + + +def passing(check_id: str) -> dict: + return {**check(), "id": check_id, "measured_ft": None, "plus_minus_ft": None} + + +def with_wall_seen(out_ft: float | None) -> dict: + scene = copy.deepcopy(SCENE) + wall = scene["coverage"]["observed"][0] + wall.pop("out_ft", None) + if out_ft is not None: + wall["out_ft"] = out_ft + return scene + + +def test_a_wall_seen_too_low_does_not_settle_an_opening(): + r = result(checks=[check(), passing("opening_clearance")]) + msgs = invariant_problems(with_wall_seen(1.0), r, rules=RULES) + needs = "check opening_clearance passes but needs wall [0.00, 4.58] observed higher than 6.5 ft" + assert any(needs in m for m in msgs) + assert invariant_problems(with_wall_seen(7.0), r, rules=RULES) == [] + assert invariant_problems(with_wall_seen(None), r, rules=RULES) == [] # seen to headroom + + +def test_a_request_to_see_higher_than_the_view_reached_is_not_redundant(): + r = result() + r["missing_evidence"] = [ + {"kind": "band", "band": "wall", "span_ft": [-3.0, -1.0], "out_ft": 6.6, "message": ""} + ] + low = invariant_problems(with_wall_seen(1.0), r, rules=RULES) + assert not any("lists as observed" in m for m in low) + # A view with no out_ft reached headroom (6.5), not 6.6. + default = invariant_problems(with_wall_seen(None), r, rules=RULES) + assert not any("lists as observed" in m for m in default) + full = invariant_problems(with_wall_seen(7.0), r, rules=RULES) + assert any("lists as observed" in m for m in full) + + +BAND_RULES = replace( + RULES, + needs=RULES.needs + | { + "facing_gap": (Need("facing", 0.0, 22 / 12 + 3.0),), + "headroom": (Need("overhead", 0.0, 6.5),), + }, +) +FACING_RULES = replace(RULES, needs=RULES.needs | {"facing_gap": BAND_RULES.needs["facing_gap"]}) +HEADROOM_RULES = replace(RULES, needs=RULES.needs | {"headroom": BAND_RULES.needs["headroom"]}) + + +def test_facing_and_headroom_passes_need_their_bands(): + r = result(checks=[check(), passing("facing_gap"), passing("headroom")]) + msgs = invariant_problems(SCENE, r, rules=BAND_RULES) + assert any( + "facing_gap passes but needs facing [1.00, 3.58] observed, none seen" in m for m in msgs + ) + assert any( + "headroom passes but needs overhead [1.00, 3.58] observed, none seen" in m for m in msgs + ) + + +def test_a_short_facing_view_needs_a_measurement(): + r = result(checks=[check(), passing("facing_gap")]) + scene = copy.deepcopy(SCENE) + scene["coverage"]["observed"].append({"band": "facing", "span_ft": [0.0, 5.0], "out_ft": 3.0}) + msgs = invariant_problems(scene, r, rules=FACING_RULES) + assert any("out to 4.83 ft or measured, seen 3.00 ft" in m for m in msgs) + scene["facing"] = [{"wall_id": "w1", "span_ft": [0.0, 5.0], "depth_ft": 8.0}] + assert invariant_problems(scene, r, rules=FACING_RULES) == [] + + +def test_an_overhead_seen_clear_all_the_way_up_settles_headroom(): + r = result(checks=[check(), passing("headroom")]) + scene = copy.deepcopy(SCENE) + scene["coverage"]["observed"].append({"band": "overhead", "span_ft": [0.0, 5.0]}) + assert invariant_problems(scene, r, rules=HEADROOM_RULES) == [] + scene["coverage"]["observed"][-1]["out_ft"] = 5.0 # clear only to 5 ft, under 6.5 + assert any( + "overhead [1.00, 3.58]" in m for m in invariant_problems(scene, r, rules=HEADROOM_RULES) + ) + + +def test_battery_clearance_is_needed_when_its_rule_reaches_past_gas_or_a_battery_is_marked(): + # BAND_RULES set battery_clearance's radius (100) above gas's (1): the server evaluates it, + # so a result that leaves it out is flagged and its coverage is needed anyway. + wide = replace(RULES, needs=RULES.needs | {"battery_clearance": (Need("ground", 100.0),)}) + r = result(checks=[check()], sweep=[PASS_RUN]) + msgs = invariant_problems(SCENE, r, rules=wide) + assert "the result leaves out check battery_clearance, which the rules define" in msgs + assert any("battery_clearance needs ground" in m for m in msgs) + # With its radius at gas's and no battery marked, the server doesn't evaluate it. + level = replace(RULES, needs=RULES.needs | {"battery_clearance": (Need("ground", 1.0),)}) + assert expected_checks(SCENE, level) == set(level.needs) - {"battery_clearance"} + marked = copy.deepcopy(SCENE) + marked["objects"].append({"type": "battery", "wall_id": "w1", "span_ft": [8.0, 9.0]}) + assert "battery_clearance" in expected_checks(marked, level) + + +@pytest.mark.parametrize( + ("wall", "error"), + [ + ({}, 0.3), + ({"source": "tap"}, 0.3), + ({"source": "mesh"}, 0.5), + ({"source": "plane"}, 0.75), + ({"source": "mesh", "plus_minus_ft": 0.2}, 0.2), + ], +) +def test_the_wall_source_sets_the_default_error(wall, error): + meter = {"pos": [0.0, 4.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0} + scene = {"meter": meter, "walls": [{"id": "w1", "baseline": [[0, 0], [9, 0]], **wall}]} + drift = 0.0 if "plus_minus_ft" in wall else 0.16 * 1.0 # far edge 1 ft from the meter + assert battery_error(scene, RULES, "w1", 0.0, 1.0) == pytest.approx(error + drift) + + +def test_a_request_to_walk_past_the_real_end_covers_what_lies_beyond_it(): + wide = replace( + RULES, + needs=RULES.needs | {"gas_clearance": (Need("ground", 1.0), Need("wall", 3.0, height=6.5))}, + ) + scene = copy.deepcopy(SCENE) + scene["walls"][0]["baseline"] = [[-1.0, 0.0], [12.0, 0.0]] # the chain's left end is s = -1 + scene["coverage"]["ends"]["left"] = {"kind": "unexplored"} + scene["coverage"]["observed"][0]["span_ft"] = [-1.0, 10.0] + r = result(checks=[check("unsure", None, cause="unobserved")]) + r["reasons"] = [{"code": "unobserved_area", "message": ""}] + r["missing_evidence"] = [ + { + "kind": "band", + "band": "ground", + "span_ft": [0, 5], + "checks": ["gas_clearance"], + "message": "", + } + ] + # The spot [1, 3.58] needs the wall over [-2, 6.58]; [-2, -1] lies past the end. + assert any("no wall request names it" in m for m in missing_evidence_problems(scene, r, wide)) + + def past_end(at: float) -> dict: + return {"kind": "past_end", "side": "left", "span_ft": [at, at], "message": ""} + + asked = r | {"missing_evidence": [*r["missing_evidence"], past_end(-1.0)]} + assert missing_evidence_problems(scene, asked, wide) == [] + elsewhere = r | {"missing_evidence": [*r["missing_evidence"], past_end(1.0)]} + assert missing_evidence_problems(scene, elsewhere, wide) != [] # not the chain's end + scene["coverage"]["ends"]["left"] = {"kind": "limit"} + assert missing_evidence_problems(scene, asked, wide) != [] # nothing to walk past + + +def test_chain_ends_are_measured_from_the_meter(): + scene = { + "meter": {"pos": [2.0, 4.0, 0.3], "wall_id": "b"}, + "walls": [ + {"id": "a", "baseline": [[-5.0, 3.0], [-5.0, 0.0]]}, + {"id": "b", "baseline": [[-5.0, 0.0], [5.0, 0.0]]}, + ], + } + assert chain_ends_s(scene, RULES) == pytest.approx((-10.0, 3.0)) + + +def test_a_view_that_only_reaches_the_needed_height_is_not_higher(): + r = result(checks=[check(), passing("opening_clearance")]) + msgs = invariant_problems(with_wall_seen(6.5), r, rules=RULES) + assert any("observed higher than 6.5 ft" in m for m in msgs) + assert invariant_problems(with_wall_seen(6.51), r, rules=RULES) == [] + + +def test_a_facing_view_that_only_reaches_the_needed_depth_is_not_beyond(): + r = result(checks=[check(), passing("facing_gap")]) + scene = copy.deepcopy(SCENE) + need = 22 / 12 + 3.0 + scene["coverage"]["observed"].append({"band": "facing", "span_ft": [0.0, 5.0], "out_ft": need}) + assert any("facing [1.00, 3.58]" in m for m in invariant_problems(scene, r, rules=FACING_RULES)) + scene["coverage"]["observed"][-1]["out_ft"] = need + 0.01 + assert invariant_problems(scene, r, rules=FACING_RULES) == [] + + +def test_each_start_in_a_sweep_run_needs_only_its_own_reach(): + # A pool radius of 10 ft with the default wall error. Start 1's battery [1, 3.58] is 0.87 ft + # uncertain, so it needs ground out to 1.83 + 10 + 0.87 = 12.71 ft; start 10's is 2.31 ft + # uncertain. As one battery at start 10's error, the run would ask 14.15 ft in front of start 1. + pool = replace(RULES, needs={"pool_clearance": (Need("ground", 10.0),)}) + scene = { + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0}, + "walls": [{"id": "w1", "baseline": [[-40.0, 0.0], [40.0, 0.0]]}], + "objects": [], + "coverage": { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-40.0, 40.0]}, + {"band": "ground", "span_ft": [-40.0, 2.0], "out_ft": 13.5}, + {"band": "ground", "span_ft": [2.0, 40.0], "out_ft": 25.0}, + ], + }, + } + run = {"wall_id": "w1", "start_ft": [1.0, 10.0], "outcome": "pass", "failing": [], "unsure": []} + r = result(spot=False, checks=[check() | {"id": "pool_clearance"}], sweep=[run]) + assert coverage_problems(scene, r, pool) == [] + scene["coverage"]["observed"][1]["out_ft"] = 12.5 + assert any( + "at start 1.00 pool_clearance needs ground" in m and "out to 12.71 ft" in m + for m in coverage_problems(scene, r, pool) + ) + + +def test_sweep_run_starts_are_sampled_at_the_rules_step_plus_the_ends(): + assert run_starts([1.0, 1.5], 1 / 6) == pytest.approx([1.0, 7 / 6, 8 / 6, 1.5]) + assert run_starts([2.0, 2.0], 1 / 6) == [2.0] + with pytest.raises(ValueError, match=r"sweep\.step_ft"): + run_starts([1.0, 2.0], None) + + +def test_facing_and_headroom_need_the_band_past_the_battery_by_the_wall_error(): + # The spot [1, 3.58] on a wall with 0.3 ft of error needs facing seen over [0.7, 3.88]. + r = result(checks=[passing("facing_gap")]) + scene = copy.deepcopy(SCENE) + scene["walls"][0]["plus_minus_ft"] = 0.3 + scene["coverage"]["observed"][1]["out_ft"] = 3.2 # gas: 1.83 + 1.3 in front and past ends + scene["coverage"]["observed"].append({"band": "facing", "span_ft": [1.0, 3.6]}) + assert any("facing [0.70, 1.00]" in m for m in invariant_problems(scene, r, rules=FACING_RULES)) + scene["coverage"]["observed"][-1]["span_ft"] = [0.7, 3.9] + assert invariant_problems(scene, r, rules=FACING_RULES) == [] + + +def test_facing_and_headroom_widen_by_the_default_error_with_drift(): + # The caretaker's repro: no explicit wall error, views over [5.7, 8.88] out 9 ft. At start 6 + # the battery [6, 8.58] is 0.3 + 0.16 x 8.58 = 1.67 ft uncertain, so both bands are needed + # over [4.33, 10.25]; the error without drift (0.3) would ask only [5.7, 8.88]. + r = result(checks=[passing("facing_gap"), passing("headroom")], spot=False) + r["sweep"][0]["start_ft"] = [6.0, 6.0] + scene = copy.deepcopy(SCENE) + del scene["walls"][0]["plus_minus_ft"] + for band in ("facing", "overhead"): + scene["coverage"]["observed"].append( + {"band": band, "span_ft": [5.7, 6 + 31 / 12 + 0.3], "out_ft": 9.0} + ) + problems = coverage_problems(scene, r, BAND_RULES) + assert any("facing [4.33, 5.70] observed, none seen" in m for m in problems) + assert any("overhead [4.33, 5.70] observed, none seen" in m for m in problems) + + +@pytest.mark.parametrize( + ("errors", "right_end"), + [ + ((None, None), 5.0), + ((0.1, 0.1), 5.4), + ((0.3, 0.2), 5.0), + ((0.3, 0.0), 5.4), + ((0.0, 0.0), 5.4), + ], +) +def test_walls_meet_across_a_space_within_both_errors(errors, right_end): + # 0.4 ft between the walls. Within both walls' own errors (0.3 + 0.3 by default, capped at + # 0.6) they meet and s skips the space; wider, it is a gap that s counts. Exact walls keep + # any gap (server/scene.py at 7133283). + scene = { + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "a"}, + "walls": [ + {"id": "a", "baseline": [[-5.0, 0.0], [0.0, 0.0]]}, + {"id": "b", "baseline": [[0.4, 0.0], [5.4, 0.0]]}, + ], + } + for wall, error in zip(scene["walls"], errors, strict=True): + if error is not None: + wall["plus_minus_ft"] = error + assert chain_ends_s(scene, RULES) == pytest.approx((-5.0, right_end)) + + +def test_the_join_uses_the_walls_own_error_not_drift(): + # 0.8 ft apart, 20 ft from the meter: drift would make the errors exceed it, but the join + # uses the default 0.3 + 0.3 capped at 0.6, so it is a gap. + scene = { + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "a"}, + "walls": [ + {"id": "a", "baseline": [[0.0, 0.0], [20.0, 0.0]]}, + {"id": "b", "baseline": [[20.8, 0.0], [25.0, 0.0]]}, + ], + } + assert chain_ends_s(scene, RULES) == pytest.approx((0.0, 25.0)) + + +def short_wall_scene(height: float, span: list[list[float]]) -> dict: + scene = copy.deepcopy(SCENE) + scene["walls"] = [ + {"id": "w1", "baseline": [[-12.0, 0.0], span[0]], "plus_minus_ft": 0.3}, + {"id": "low", "baseline": span, "plus_minus_ft": 0.3, "height_ft": height}, + ] + return scene + + +@pytest.mark.parametrize(("height", "flagged"), [(3.0, True), (3.25, True), (3.26, False)]) +def test_backing_passes_only_if_every_declared_wall_within_error_is_taller(height, flagged): + # The spot [1, 3.58] on w1, which ends at 3.7; the low wall starts there, within 0.3 ft. + scene = short_wall_scene(height, [[3.7, 0.0], [12.0, 0.0]]) + r = result(checks=[check(), passing("wall_backing")]) + msgs = declared_height_problems(scene, r, RULES) + assert bool(msgs) == flagged + if flagged: + assert "check wall_backing passes but wall low" in msgs[0] + + +def test_a_short_wall_beyond_the_error_does_not_matter(): + scene = short_wall_scene(2.0, [[4.0, 0.0], [12.0, 0.0]]) + r = result(checks=[check(), passing("wall_backing")], sweep=[]) + assert declared_height_problems(scene, r, RULES) == [] + + +@pytest.mark.parametrize( + ("height", "out_ft", "settled"), + [ + (6.5, None, True), # no out_ft: seen to headroom, inclusively + (6.5 + 1e-10, None, True), # within the server's float tolerance + (6.51, None, False), # more than headroom needs an explicit view + (6.5, 6.5, False), # an explicit view must pass the height + (6.5, 6.51, True), + (1.0, None, True), + ], +) +def test_a_wall_view_without_out_ft_reached_headroom_height(height, out_ft, settled): + scene = copy.deepcopy(SCENE) + entry = {"band": "wall", "span_ft": [-12.0, 10.0]} + if out_ft is not None: + entry["out_ft"] = out_ft + scene["coverage"]["observed"][0] = entry + seen = observed(scene, "wall", height, beyond=True, wall_default_ft=RULES.headroom_ft) + assert (seen == [(-12.0, 10.0)]) == settled + + +def test_a_pass_needing_the_wall_above_headroom_is_flagged_without_out_ft(): + tall = replace(RULES, needs=RULES.needs | {"opening_clearance": (Need("wall", 1.0, 7.0),)}) + r = result(checks=[check(), passing("opening_clearance")]) + assert any("observed higher than 7.0 ft" in m for m in invariant_problems(SCENE, r, rules=tall)) + + +def test_without_a_spot_or_the_reason_no_photo_is_owed(): + # Every position fails anyway: no spot, no unobserved_area reason, nothing to capture. + unseen = check("unsure", None, cause="unobserved") + r = result(decision="reject", spot=False, checks=[unseen]) + assert missing_evidence_problems(SCENE, r, RULES) == [] + + +def test_a_spot_with_an_unobserved_check_needs_the_reason_and_the_request(): + # The server marks the spot's check unsure for lack of coverage but drops both the reason + # and the request: the homeowner would never be asked for the view that settles it. + scene = copy.deepcopy(SCENE) + scene["coverage"]["observed"] = [scene["coverage"]["observed"][0]] # no ground seen + r = result(checks=[check("unsure", None, cause="unobserved")]) + msgs = missing_evidence_problems(scene, r, RULES) + assert msgs and all("no reason unobserved_area and no ground request" in m for m in msgs) + assert any("no reason unobserved_area" in m for m in invariant_problems(scene, r, rules=RULES)) + r["reasons"].append({"code": "unobserved_area", "message": ""}) + r["missing_evidence"] = [ + { + "kind": "band", + "band": "ground", + "span_ft": [-1, 5], + "out_ft": 3.0, + "checks": ["gas_clearance"], + "message": "", + } + ] + assert missing_evidence_problems(scene, r, RULES) == [] + + +def test_what_lies_past_an_unexplored_end_needs_only_the_walk_past_it(): + # As the server answers once every band view is in: reason unexplored_end, a past_end + # request naming no checks, and checks unsure only for what lies beyond the end. + wide = replace( + RULES, needs=RULES.needs | {"gas_clearance": (Need("ground", 1.0), Need("wall", 3.0, 6.5))} + ) + scene = copy.deepcopy(SCENE) + scene["walls"][0]["baseline"] = [[-1.0, 0.0], [12.0, 0.0]] # the chain's left end is s = -1 + scene["coverage"]["ends"]["left"] = {"kind": "unexplored"} + scene["coverage"]["observed"][0]["span_ft"] = [-1.0, 10.0] + r = result(checks=[check("unsure", None, cause="unobserved")]) + r["reasons"] = [{"code": "unexplored_end", "message": ""}] + assert any("wall [-2.00, 6.58]" in m for m in missing_evidence_problems(scene, r, wide)) + r["missing_evidence"] = [ + {"kind": "past_end", "side": "left", "span_ft": [-1.0, -1.0], "message": ""} + ] + assert missing_evidence_problems(scene, r, wide) == [] + + +def test_a_view_at_headroom_does_not_settle_a_request_just_past_it(): + # App f14947e reports the wall seen to exactly 6.5 ft; the server then asks for a view past + # headroom, out_ft 6.500001. That request is not redundant. + r = result() + r["missing_evidence"] = [ + {"kind": "band", "band": "wall", "span_ft": [-3.0, -1.0], "out_ft": 6.500001, "message": ""} + ] + msgs = invariant_problems(with_wall_seen(6.5), r, rules=RULES) + assert not any("lists as observed" in m for m in msgs) + msgs = invariant_problems(with_wall_seen(6.500001), r, rules=RULES) + assert any("lists as observed" in m for m in msgs) + + +def test_a_captured_request_claims_only_the_reach_it_asked_for(): + # Review 4114352249: a ground request out 0.5 ft was captured as seen 40 ft out. + r = result() + r["missing_evidence"] = [ + {"kind": "band", "band": "ground", "span_ft": [0, 5], "out_ft": 0.5, "message": ""}, + {"kind": "band", "band": "wall", "span_ft": [2, 3], "out_ft": 6.500001, "message": ""}, + {"kind": "band", "band": "facing", "span_ft": [2, 3], "message": ""}, + ] + added = with_requests_captured(SCENE, r)["coverage"]["observed"][-3:] + assert added == [ + {"band": "ground", "span_ft": [0, 5], "out_ft": 0.5}, + {"band": "wall", "span_ft": [2, 3], "out_ft": 6.500001}, + {"band": "facing", "span_ft": [2, 3]}, + ] + + +def test_a_sweep_run_shorter_than_a_step_keeps_its_first_start(): + # Review 4114352251: run_starts([0, 0.4], 1) returned only 0.4. + assert run_starts([0.0, 0.4], 1.0) == [0.0, 0.4] + + +def test_a_passing_spot_must_pass_in_the_sweep_too(): + # Review 4114352261: the spot passes but its start is in a failing run, stats consistent. + run = { + "wall_id": "w1", + "start_ft": [1.0, 1.0], + "outcome": "fail", + "failing": ["gas_clearance"], + "unsure": [], + } + r = result(sweep=[run]) + r["stats"] |= {"pass": 0, "fail": 1} + assert any( + "spot passes but the sweep has start 1.00 on w1 as fail" in m + for m in invariant_problems(SCENE, r, rules=RULES) + ) + + +def test_wall_backing_needs_the_wall_seen_past_the_battery_by_its_error(): + # Server 4a9fa44: the wall behind every place the battery may sit, [s0 - e, s1 + e]. + backing = replace(RULES, needs={"wall_backing": (Need("wall", 0.0, 3.25),)}) + scene = copy.deepcopy(SCENE) + scene["walls"][0]["plus_minus_ft"] = 0.3 + scene["coverage"]["observed"][0] = {"band": "wall", "span_ft": [1.0, 3.6], "out_ft": 4.0} + r = result(checks=[passing("wall_backing")]) + assert any("wall [0.70, 3.88]" in m for m in coverage_problems(scene, r, backing)) + scene["coverage"]["observed"][0]["span_ft"] = [0.0, 3.9] + assert coverage_problems(scene, r, backing) == [] + + +def inside_corner(w2_out: float) -> dict: + # g05: w1 runs +x with its outside at +z; w2 turns up +z from its end, facing back over w1. + return { + "meter": {"pos": [-6.0, 4.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0}, + "walls": [ + {"id": "w1", "baseline": [[-7.0, 0.0], [4.0, 0.0]], "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4.0, 0.0], [4.0, 12.0]], "plus_minus_ft": 0.0}, + ], + "objects": [], + "coverage": { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-1.0, 22.0]}, + {"band": "ground", "span_ft": [-7.0, 10.0], "out_ft": 10.5}, + {"band": "ground", "span_ft": [10.0, 22.0], "out_ft": w2_out}, + ], + }, + } + + +def test_ground_in_front_of_one_wall_may_be_seen_from_the_next_in_an_inside_corner(): + # The battery at s 4.04 (x -1.96 to 0.62) needs a 10 ft pool radius, ground out to 11.83 in + # front and within 10 ft of its corners; w1's band reaches 10.5. From x = 4, w2's band + # reaching 12 covers the rest. + pool = replace(RULES, needs={"pool_clearance": (Need("ground", 10.0),)}) + run = { + "wall_id": "w1", + "start_ft": [4.04, 4.04], + "outcome": "pass", + "failing": [], + "unsure": [], + } + r = result(spot=False, checks=[check() | {"id": "pool_clearance"}], sweep=[run]) + assert coverage_problems(inside_corner(12.0), r, pool) == [] + short = coverage_problems(inside_corner(4.0), r, pool) # w2's band stops at x = 0 + assert any("pool_clearance needs ground" in m and "out to 11.83" in m for m in short) + + +def test_a_ground_gap_narrower_than_the_sampling_step_is_not_counted_seen(): + # 5a4b4f4 passed a spot over ground seen [-40, 7.5] and [7.519, 40], out 30 ft: both edges + # of the 0.019 ft gap lie on a band, but its middle, s = 7.5095, was never seen. + rules = replace(RULES, needs={"gas_clearance": (Need("ground", 1.0),)}) + scene = copy.deepcopy(SCENE) + scene["walls"][0]["baseline"] = [[-40.0, 0.0], [40.0, 0.0]] + scene["coverage"]["observed"] = [ + {"band": "wall", "span_ft": [-40.0, 40.0]}, + {"band": "ground", "span_ft": [-40.0, 7.5], "out_ft": 30.0}, + {"band": "ground", "span_ft": [7.519, 40.0], "out_ft": 30.0}, + ] + r = result(checks=[check()], sweep=[]) + r["spot"]["span_ft"] = [6.0, 6.0 + 31 / 12] + msgs = coverage_problems(scene, r, rules) + assert any("ground [7.50, 7.52] observed, none seen" in m for m in msgs) + assert GroundPlan(scene, rules).seen((7.5095, 1.0)) is False + scene["coverage"]["observed"][2]["span_ft"] = [7.5, 40.0] + assert coverage_problems(scene, r, rules) == [] + + +@pytest.mark.parametrize("outcome", ["pass", "unsure"]) +def test_a_reject_must_have_every_start_failing(outcome): + # Review 4114676165: decision reject, no spot, nothing asked, but the sweep has a start + # that is not failing and stats that agree with it. + run = { + "wall_id": "w1", + "start_ft": [1.0, 1.0], + "outcome": outcome, + "failing": [], + "unsure": [] if outcome == "pass" else ["gas_clearance"], + } + r = result(decision="reject", spot=False, checks=[check("fail", 2.0)], sweep=[run]) + r["stats"] |= { + "candidates": 1, + "pass": int(outcome == "pass"), + "unsure": int(outcome == "unsure"), + "fail": 0, + } + assert any( + f"decision reject but the sweep has {outcome} starts" in m + for m in invariant_problems(SCENE, r, rules=RULES) + ) + + +def broadly_seen() -> dict: + scene = copy.deepcopy(SCENE) + scene["coverage"]["ends"] = {"left": {"kind": "limit"}, "right": {"kind": "limit"}} + scene["coverage"]["observed"] = [ + {"band": "wall", "span_ft": [-100.0, 100.0]}, + {"band": "ground", "span_ft": [-100.0, 100.0], "out_ft": 30.0}, + ] + return scene + + +def test_a_passing_spot_must_stand_on_the_scanned_wall(): + # Review 4114676173: a pass at start 11, 31 in wide, on a wall that ends at s = 12. + r = result( + decision="pass", + sweep=[ + { + "wall_id": "w1", + "start_ft": [11.0, 11.0], + "outcome": "pass", + "failing": [], + "unsure": [], + } + ], + ) + r["spot"]["span_ft"] = [11.0, 11.0 + 31 / 12] + msgs = invariant_problems(broadly_seen(), r, rules=RULES) + assert any("spot [11.00, 13.58] runs past its straight wall [-12.00, 12.00]" in m for m in msgs) + assert any("sweep pass start 11.00 runs past its straight wall" in m for m in msgs) + + +def test_walls_meeting_in_a_straight_line_are_one_stretch_and_a_corner_is_not(): + scene = broadly_seen() + scene["walls"] = [ + {"id": "w1", "baseline": [[-12.0, 0.0], [5.0, 0.0]], "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[5.0, 0.0], [12.0, 0.0]], "plus_minus_ft": 0.0}, + ] + assert straight_pieces(scene, RULES) == [(-12.0, 12.0)] + r = result(sweep=[]) + r["spot"]["span_ft"] = [4.0, 4.0 + 31 / 12] # across the join + assert footprint_problems(scene, r, RULES) == [] + scene["walls"][1]["baseline"] = [[5.0, 0.0], [5.0, 7.0]] # a corner at s = 5 + assert straight_pieces(scene, RULES) == [(-12.0, 5.0), (5.0, 12.0)] + assert footprint_problems(scene, r, RULES) == [ + "spot [4.00, 6.58] runs past its straight wall [-12.00, 5.00]" + ] + r["spot"]["span_ft"] = [5.0, 5.0 + 31 / 12] # starts exactly at the corner, on the next wall + assert footprint_problems(scene, r, RULES) == [] + + +def test_a_pass_by_the_smallest_reported_margin_is_a_pass(): + # 3baa338 puts a spot at the edge of its working-space pass: 1.444446 +/- 1.444445 against + # 0. The server rounds to 6 decimals, which keeps order, so a reported margin above the + # error proves the true one is; equal reported numbers do not. + c = check("pass", 1.444446, 1.444445, 0.0) | {"id": "meter_working_space"} + assert margin_problem(c) is None + assert "should be unsure" in margin_problem(c | {"measured_ft": 1.444445}) + at_most = check("pass", 19.999999, 0.0, 20.0, cmp="at_most") + assert margin_problem(at_most) is None + assert "should be unsure" in margin_problem(at_most | {"measured_ft": 20.0}) + + +# Final Opus review of 255bc84 (.local/final-opus-review-probes/pr13), findings 1 to 5. + +D_FT = 22 / 12 +POOL = replace(RULES, needs={"pool_clearance": (Need("ground", 10.0),)}) + + +def open_wall(ground_out: float, meter_pm: float | None = None) -> dict: + """The probes' scene: one 60 ft wall, error 0.3, everything but the ground seen.""" + meter = {"pos": [0.0, 4.0, 0.0], "wall_id": "w1"} + if meter_pm is not None: + meter["plus_minus_ft"] = meter_pm + return { + "meter": meter, + "walls": [{"id": "w1", "baseline": [[-30.0, 0.0], [30.0, 0.0]], "plus_minus_ft": 0.3}], + "objects": [], + "coverage": { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-30.0, 30.0]}, + {"band": "ground", "span_ft": [-30.0, 30.0], "out_ft": ground_out}, + ], + }, + } + + +def pass_run(a: float, b: float, wall_id: str = "w1") -> dict: + return {"wall_id": wall_id, "start_ft": [a, b], "outcome": "pass", "failing": [], "unsure": []} + + +def test_the_battery_error_includes_the_meters_error_in_plan(): + # meter_gap.py: ground seen to D + 10 + 0.3 + 0.15. With the meter's default 0.3 the pool + # needs D + 10 + 0.6 (server 3baa338 _errors: wall at the far edge plus the meter's). + scene = open_wall(D_FT + 10.45) + r = result(spot=False, checks=[check() | {"id": "pool_clearance"}], sweep=[pass_run(2.0, 2.0)]) + assert any( + "observed out to 12.43 ft, seen 12.28 ft" in m for m in coverage_problems(scene, r, POOL) + ) + assert battery_error(scene, RULES, "w1", 2.0, 2.0 + 31 / 12) == pytest.approx(0.6) + + +def test_the_battery_error_includes_the_slide_round_a_corner(): + # Round a right angle from the meter, the meter's 0.3 slides the battery by 0.3 x sqrt(2), + # more than its 0.3 in plan (server 3baa338 _slide). + scene = open_wall(20.0, meter_pm=0.3) + scene["walls"] = [ + {"id": "w1", "baseline": [[-30.0, 0.0], [10.0, 0.0]], "plus_minus_ft": 0.3}, + {"id": "w2", "baseline": [[10.0, 0.0], [10.0, -20.0]], "plus_minus_ft": 0.3}, + ] + assert battery_error(scene, RULES, "w2", 15.0, 17.58) == pytest.approx( + 0.3 + 0.3 * 2**0.5 + 0.3 * 2**0.5 + ) + + +def test_ground_just_past_the_battery_is_needed_out_to_the_straight_line_reach(): + # ends.py: ground in front seen to 12.5, just past the ends only to 10.4. A point d past an + # end is within R of the footprint out to D + sqrt(R^2 - d^2), not R - d. + lo, hi = 5.0, 5.0 + 31 / 12 + scene = open_wall(10.4, meter_pm=0.0) + scene["coverage"]["observed"][1:] = [ + {"band": "ground", "span_ft": [-30.0, lo], "out_ft": 10.4}, + {"band": "ground", "span_ft": [lo, hi], "out_ft": 12.5}, + {"band": "ground", "span_ft": [hi, 30.0], "out_ft": 10.4}, + ] + gaps = reach_gaps(scene, POOL, "pool_clearance", "w1", lo, hi) + assert gaps and gaps[0][1].startswith("ground [-5.30, 5.00] observed out to 12.13 ft") + + +def test_a_passing_footprint_keeps_the_wall_error_clear_of_its_stretch_end(): + # corner.py: a run whose last footprint ends at the corner s = 10 is unsure at 3baa338 + # (check_backing: within e of the segment's end); relabelled pass, it must be flagged. + scene = open_wall(20.0) + scene["walls"] = [ + {"id": "w1", "baseline": [[-30.0, 0.0], [10.0, 0.0]], "plus_minus_ft": 0.3}, + {"id": "w2", "baseline": [[10.0, 0.0], [10.0, -20.0]], "plus_minus_ft": 0.3}, + ] + r = result(spot=False, sweep=[pass_run(6.846, 10.0 - 31 / 12)]) + assert footprint_problems(scene, r, RULES) == [ + "sweep pass start 7.42 ends within its error (0.60 ft) of its straight wall's end at 10.00" + ] + r["sweep"][0]["outcome"] = "unsure" # unsure may stand that close + assert footprint_problems(scene, r, RULES) == [] + + +def test_a_footprint_past_its_stretch_by_more_than_the_slack_is_flagged(): + # With no error, a pass ending 0.2 ft past its wall's end: beyond the 0.05 ft slack. + scene = open_wall(20.0, meter_pm=0.0) + scene["walls"][0]["plus_minus_ft"] = 0.0 + r = result(spot=False, sweep=[pass_run(30.2 - 31 / 12, 30.2 - 31 / 12)]) + assert footprint_problems(scene, r, RULES) == [ + "sweep pass start 27.62 runs past its straight wall [-30.00, 30.00]" + ] + + +def test_a_result_that_drops_a_check_is_flagged_and_its_coverage_still_needed(): + # dropped.py: a PASS judged on a scene whose ground reaches drive's 5 ft but not pool's 10. + rules = replace( + RULES, + needs={ + "pool_clearance": (Need("ground", 10.0),), + "drive_clearance": (Need("ground", 5.0),), + }, + ) + scene = open_wall(D_FT + 5 + 0.6 + 0.1) + full = result(spot=False, sweep=[pass_run(2.0, 2.0)]) + dropped = full | {"checks": [c for c in full["checks"] if c["id"] != "pool_clearance"]} + msgs = invariant_problems(scene, dropped, rules=rules) + assert any("pool_clearance needs ground" in m for m in msgs) + assert "the result leaves out check pool_clearance, which the rules define" in msgs + + +def test_the_route_wall_is_needed_past_the_meter_by_its_error(): + # route_end.py: the wall seen from s = 0 only. The route starts at the meter, which may + # stand 0.3 ft either side, so the wall is needed from -0.3 (server README route_path). + scene = open_wall(20.0) + scene["coverage"]["observed"][0]["span_ft"] = [0.0, 30.0] + r = result(spot=False, checks=[], sweep=[pass_run(3.7, 14.3875)]) + assert any( + "the wall and cable route [-0.30, 16.97] were not all observed" in m + for m in coverage_problems(scene, r, RULES) + ) + + +# Flags at server 3baa338 after the review fixes, each the checker's (see the commit message). + + +def test_spans_along_the_walls_widen_by_the_along_error_not_the_meters(): + # The app scan: headroom needs the overhead band over [s0 - e, s1 + e] with e the wall's + # error plus the slide (0 on the meter's wall), not the meter's 0.3 in plan. + headroom = replace(RULES, needs={"headroom": (Need("overhead", 0.0, 6.5, along=True),)}) + scene = open_wall(20.0) # wall 0.3, meter default 0.3 + scene["coverage"]["observed"].append({"band": "overhead", "span_ft": [1.7, 4.9]}) + r = result(spot=False, sweep=[pass_run(2.0, 2.0)]) + assert coverage_problems(scene, r, headroom) == [] # needs [1.7, 4.88] + scene["coverage"]["observed"][-1]["span_ft"] = [1.8, 4.9] + assert any("overhead [1.70, 1.80]" in m for m in coverage_problems(scene, r, headroom)) + + +def test_ground_short_of_the_reach_by_less_than_the_seen_growth_is_seen(): + # d-gas-drift: ground seen to 14.50 where 14.5007 is needed. The server grows what was seen + # by 0.0049 ft before comparing (SEEN_GROWTH_FT), so a 0.0007 ft shortfall is seen. + scene = open_wall(D_FT + 10.6 - 0.0007) + r = result(spot=False, sweep=[pass_run(2.0, 2.0)]) + assert coverage_problems(scene, r, POOL) == [] + scene["coverage"]["observed"][1]["out_ft"] = D_FT + 10.6 - 0.01 + assert coverage_problems(scene, r, POOL) != [] + + +def test_ground_past_an_inside_corner_behind_the_next_wall_is_the_house(): + # g05 after a capture: past the battery's right end, the straight-line reach runs past the + # corner at x = 4 to ground behind w2, which the server's yard model leaves out. + pool = replace(RULES, needs={"pool_clearance": (Need("ground", 10.0),)}) + scene = inside_corner(10.66) + scene["coverage"]["observed"][1:] = [ + {"band": "ground", "span_ft": [-7.0, 10.0], "out_ft": 10.66}, + {"band": "ground", "span_ft": [10.0, 22.0], "out_ft": 12.0}, + ] + run = { + "wall_id": "w1", + "start_ft": [4.04, 4.04], + "outcome": "pass", + "failing": [], + "unsure": [], + } + r = result(spot=False, checks=[check() | {"id": "pool_clearance"}], sweep=[run]) + assert coverage_problems(scene, r, pool) == [] + plan = GroundPlan(scene, pool) + assert not plan.required((6.0, 5.0)) # behind w2, in front of no wall + assert plan.required((1.0, 11.5)) # in the corner's yard, in front of w1 + assert plan.required((-9.0, 3.0)) # past the chain's left end + + +# Codex on 255bc84, verified by the #11 caretaker: three more false certifications. + + +def test_a_pass_with_no_checks_is_flagged(): + # 4114865912: decision pass, a passing spot and sweep, and checks []. + r = result(decision="pass", checks=[], complete=False) + r["policy"]["auto_approve"] = True + msgs = invariant_problems(broadly_seen(), r, rules=RULES) + assert "decision pass with no checks" in msgs + assert "the result leaves out check gas_clearance, which the rules define" in msgs + + +def test_the_spot_centre_must_sit_on_its_footprint_on_the_wall(): + # 4114865917: centre and meter_offset moved together by 5 ft, footprint unchanged. + r = result() + r["spot"]["center"] = [7.29, 0.92] + r["spot"]["meter_offset_ft"] = [7.29, 0.92] + msgs = invariant_problems(SCENE, r, rules=RULES) + assert any(m.startswith("spot.center [7.29, 0.92] is not its footprint's centre") for m in msgs) + assert any(m.startswith("spot.center [7.29, 0.92] is not the centre of span") for m in msgs) + assert not any("spot.center" in m for m in invariant_problems(SCENE, result(), rules=RULES)) + + +def test_manual_review_needs_a_spot_that_does_not_fail(): + # 4114865919: manual_review, a failing spot, a failing check and a matching failing run. + run = { + "wall_id": "w1", + "start_ft": [1.0, 1.0], + "outcome": "fail", + "failing": ["gas_clearance"], + "unsure": [], + } + r = result(checks=[check("fail", 2.0)], sweep=[run]) + r["spot"]["outcome"] = "fail" + r["stats"] |= {"pass": 0, "fail": 1} + msgs = invariant_problems(SCENE, r, rules=RULES) + assert "decision manual_review with a failing spot" in msgs + passing_spot = result(checks=[check("fail", 2.0)]) + assert "the spot is pass but its check gas_clearance fails" in invariant_problems( + SCENE, passing_spot, rules=RULES + ) + + +def test_a_band_gap_under_the_coverage_tolerance_is_rounding(): + # The app scan at c820cf1, less coverage: wall seen to 18.0, needed to 18.0037 for + # wall_equipment_above. The server's missing() drops gaps under 0.01 ft; 0.011 stays. + assert gaps_over([(-6.5, 18.0)], 9.94, 18.0037) == [] + assert gaps_over([(-6.5, 18.0)], 9.94, 18.011) == [(18.0, 18.011)] + assert gaps_over([(0.0, 1.0), (1.009, 3.0)], 0.5, 2.0) == [] + assert gaps_over([(0.0, 1.0), (1.011, 3.0)], 0.5, 2.0) == [(1.0, 1.011)] diff --git a/verification/tests/test_scoreboard.py b/verification/tests/test_scoreboard.py new file mode 100644 index 000000000..21292cb27 --- /dev/null +++ b/verification/tests/test_scoreboard.py @@ -0,0 +1,677 @@ +import contextlib +import datetime as dt +import json +import os +import textwrap +import time + +import pytest + +from hsverify import scoreboard as sb +from hsverify.scoreboard import ( + Context, + MetricsError, + Outcome, + build_board, + evaluate, + load_metrics, + parse_metric, + render_markdown, + summarise_rollup, +) + +SHA = "a" * 40 +OLD = "b" * 40 +WS = {"S1": {"title": "Evals"}} +FIXED_NOW = dt.datetime(2026, 9, 26, 7, 30, tzinfo=dt.UTC) + + +class FakeTree: + """Files per SHA; show returns None for a missing file, like gitref.show.""" + + def __init__(self, files: dict[str, dict[str, str]]): + self._files = files + + def files(self, sha): + return list(self._files.get(sha, {})) + + def show(self, sha, path): + return self._files.get(sha, {}).get(path) + + +def ctx(files=None, prs=None, **kw) -> Context: + return Context(tree=FakeTree(files or {}), prs=prs, now=lambda: FIXED_NOW, **kw) + + +def metric(kind: str, ref: str = "origin/t3/evals", **probe): + return parse_metric( + {"id": "S1-1", "workstream": "S1", "ref": ref, "text": "t", kind: probe}, WS + ) + + +# --- CI rollup ------------------------------------------------------------------------------- + + +def run(name, conclusion, status="COMPLETED"): + return {"__typename": "CheckRun", "name": name, "status": status, "conclusion": conclusion} + + +def context_status(name, state): + return {"__typename": "StatusContext", "context": name, "state": state} + + +def test_rollup_counts_each_bucket_and_names_failures(): + s = summarise_rollup( + [ + run("iOS build", "SUCCESS"), + run("Lint", "FAILURE"), + run("Server", None, status="IN_PROGRESS"), + run("Optional", "SKIPPED"), + context_status("CodeRabbit", "SUCCESS"), + context_status("Vercel", "ERROR"), + context_status("Queue", "PENDING"), + ] + ) + assert s.counts == {"success": 2, "failure": 2, "pending": 2, "skipped": 1} + assert s.failing == ["Lint", "Vercel"] + assert s.text() == "2 success, 2 failure, 2 pending, 1 skipped; failing: Lint, Vercel" + + +def test_rollup_skips_entries_without_name_or_outcome(): + s = summarise_rollup( + [ + {"__typename": "CheckRun", "name": None, "status": "COMPLETED", "conclusion": None}, + run("Done without conclusion", None), + context_status(None, "SUCCESS"), + context_status("No state", None), + run("ok", "SUCCESS"), + ] + ) + assert s.counts == {"success": 1, "failure": 0, "pending": 0, "skipped": 0} + assert s.text() == "1 success" + + +def test_rollup_unknown_conclusion_counts_as_failure(): + s = summarise_rollup([run("Weird", "SOMETHING_NEW")]) + assert s.failing == ["Weird"] + + +def test_rollup_empty(): + assert summarise_rollup(None).text() == "no checks" + + +# --- metrics file ---------------------------------------------------------------------------- + + +def test_real_metrics_file_loads(): + titles, metrics = load_metrics() + assert set(titles) == {"S1", "S2", "S3", "S4", "M"} + assert {m.workstream for m in metrics} == set(titles) + + +@pytest.mark.parametrize( + ("raw", "message"), + [ + ({"id": "X", "workstream": "S1", "ref": "r"}, "missing text"), + ({"id": "X", "workstream": "S9", "ref": "r", "text": "t", "manual": {}}, "unknown work"), + ({"id": "X", "workstream": "S1", "ref": "r", "text": "t"}, "exactly one probe"), + ( + {"id": "X", "workstream": "S1", "ref": "r", "text": "t", "manual": {}, "pr_body": {}}, + "exactly one probe", + ), + ( + {"id": "X", "workstream": "S1", "ref": "r", "text": "t", "manual": {"status": "ok"}}, + "manual needs evidence", + ), + ( + { + "id": "X", + "workstream": "S1", + "ref": "r", + "text": "t", + "manual": {"status": "done", "evidence": "e"}, + }, + "manual status must be", + ), + ( + {"id": "X", "workstream": "S1", "ref": "r", "text": "t", "report": {"glob": "g"}}, + "report needs key", + ), + ( + { + "id": "X", + "workstream": "S1", + "ref": "r", + "text": "t", + "report": {"glob": "g", "key": "k", "equals": 1, "less_than": 2}, + }, + "exactly one of equals", + ), + ( + {"id": "X", "workstream": "S1", "ref": "r", "text": "t", "file_at_ref": {}}, + "exactly one of path, paths", + ), + ( + { + "id": "X", + "workstream": "S1", + "ref": "r", + "text": "t", + "grep_at_ref": {"paths": ["a"], "pattern": "x", "each": ["1"]}, + }, + "must contain {each}", + ), + ( + { + "id": "X", + "workstream": "S1", + "ref": "r", + "text": "t", + "grep_at_ref": {"paths": ["a"], "pattern": "("}, + }, + "bad pattern", + ), + ( + {"id": "X", "workstream": "S1", "ref": "r", "text": "t", "pr_check": {"nme": "x"}}, + "does not take nme", + ), + ( + { + "id": "X", + "workstream": "S1", + "ref": "r", + "text": "t", + "pr_check": {"name": "x", "max_status": "partial"}, + }, + "needs a cap_reason", + ), + ], +) +def test_bad_metrics_fail_loudly(raw, message): + with pytest.raises(MetricsError, match=message): + parse_metric(raw, WS) + + +def test_duplicate_ids_rejected(tmp_path): + path = tmp_path / "m.yaml" + path.write_text( + textwrap.dedent( + """ + workstreams: {S1: {title: E}} + metrics: + - {id: A, workstream: S1, ref: r, text: t, manual: {status: gap, evidence: e}} + - {id: A, workstream: S1, ref: r, text: t, manual: {status: gap, evidence: e}} + """ + ) + ) + with pytest.raises(MetricsError, match="duplicate metric ids: A"): + load_metrics(path) + + +# --- ref missing ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kind", "probe"), + [ + ("file_at_ref", {"path": "a"}), + ("grep_at_ref", {"paths": ["a"], "pattern": "x"}), + ("command", {"run": "true"}), + ("pr_check", {"name": "x"}), + ("pr_body", {"pattern": "x"}), + ( + "report", + {"glob": "/nonexistent/*.json", "key": "k", "equals": 1, "require_current_sha": True}, + ), + ], +) +def test_missing_ref_is_reported_not_raised(kind, probe): + out = evaluate(metric(kind, **probe), None, ctx(slow=True)) + assert out == Outcome("ref missing", "origin/t3/evals does not exist") + + +def test_manual_and_sha_free_report_ignore_missing_ref(tmp_path): + out = evaluate(metric("manual", status="partial", evidence="half"), None, ctx()) + assert out == Outcome("partial", "half") + probe = {"glob": str(tmp_path / "*.json"), "key": "k", "equals": 1} + assert evaluate(metric("report", **probe), None, ctx()).status == "no evidence yet" + + +def test_a_report_removed_while_the_board_lists_them_is_skipped(tmp_path, monkeypatch): + (tmp_path / "kept.json").write_text(json.dumps({"k": 1})) + listed = [str(tmp_path / "removed.json"), str(tmp_path / "kept.json")] + monkeypatch.setattr(sb.globlib, "glob", lambda pattern: listed) + probe = {"glob": str(tmp_path / "*.json"), "key": "k", "equals": 1} + assert evaluate(metric("report", **probe), None, ctx()).status == "met" + + +# --- file_at_ref and grep_at_ref ------------------------------------------------------------- + + +def test_file_at_ref(): + c = ctx({SHA: {"server/a.json": "{}"}}) + met = evaluate(metric("file_at_ref", path="server/a.json"), SHA, c) + assert met.status == "met" + assert met.links == (("server/a.json", f"{sb.BLOB_URL}/{SHA}/server/a.json"),) + part = evaluate(metric("file_at_ref", paths=["server/a.json", "server/b.json"]), SHA, c) + assert part == Outcome("partial", "missing at aaaaaaaa: server/b.json", met.links) + assert evaluate(metric("file_at_ref", path="nope"), SHA, c).status == "gap" + + +GREP_FILES = { + SHA: { + "server/tests/test_golden.py": "def test_golden_01_x(): ...\ndef test_golden_02_y(): ...\n", + "server/tests/test_other.py": "def test_golden_11(): ...\n", + "server/solver.py": "golden_03 is not a test\n", + } +} + + +def test_grep_without_each_counts_matches(): + c = ctx(GREP_FILES) + m = metric("grep_at_ref", paths=["server/tests/*.py"], pattern=r"def test_", min_matches=3) + out = evaluate(m, SHA, c) + assert out.status == "met" + assert out.evidence == "3 matches in 2 files" + m = metric("grep_at_ref", paths=["server/tests/*.py"], pattern=r"def test_", min_matches=4) + assert evaluate(m, SHA, c).evidence == "3 of 4 matches needed, searched 2 files" + + +def test_grep_each_is_partial_and_names_what_is_missing(): + m = metric( + "grep_at_ref", + paths=["server/tests/*.py"], + pattern=r"golden[^0-9\n]{0,12}{each}(?!\d)", + each=["01", "02", "03", "1"], + ) + out = evaluate(m, SHA, ctx(GREP_FILES)) + # 03 exists only outside the globbed paths; "1" must not match inside "11" or "01". + assert out.status == "partial" + assert out.evidence == "found 01, 02; missing 03, 1" + assert [label for label, _ in out.links] == ["server/tests/test_golden.py"] + + +def test_grep_each_mapping_uses_labels_and_all_found_is_met(): + m = metric( + "grep_at_ref", + paths=["server/tests/*.py"], + pattern="{each}", + each={"first": r"golden_0[12]", "eleven": r"golden_11"}, + ) + assert evaluate(m, SHA, ctx(GREP_FILES)).evidence == "all 2 found" + + +def test_grep_with_no_matching_files_is_a_gap(): + m = metric("grep_at_ref", paths=["ios/*.swift"], pattern="{each}", each=["a"]) + assert evaluate(m, SHA, ctx(GREP_FILES)) == Outcome("gap", "no files match ios/*.swift") + + +def test_max_status_caps_met_at_partial(): + m = metric( + "grep_at_ref", + paths=["server/tests/*.py"], + pattern="def", + max_status="partial", + cap_reason="exists only", + ) + out = evaluate(m, SHA, ctx(GREP_FILES)) + assert out.status == "partial" + assert out.evidence.endswith("(exists only)") + + +# --- command --------------------------------------------------------------------------------- + + +def fake_worktree(root): + @contextlib.contextmanager + def wt(sha): + yield root + + return wt + + +def test_command_runs_in_worktree_keeps_last_lines_and_caches(tmp_path): + (tmp_path / "server").mkdir() + (tmp_path / "server" / "marker").write_text("x") + cache: dict = {} + c = ctx(slow=True, cache=cache, worktree=fake_worktree(tmp_path)) + run = "ls; echo one; echo two; echo three; echo four" + out = evaluate(metric("command", run=run, cwd="server"), SHA, c) + assert out == Outcome("met", "exit 0: two / three / four") + assert cache["S1-1"]["sha"] == SHA + assert cache["S1-1"]["status"] == "met" + + out = evaluate(metric("command", run="echo bad; exit 3", cwd="server"), SHA, c) + assert out == Outcome("gap", "exit 3: bad") + + +def test_command_missing_cwd_and_timeout(tmp_path): + c = ctx(slow=True, worktree=fake_worktree(tmp_path)) + assert evaluate(metric("command", run="true", cwd="server"), SHA, c) == Outcome( + "gap", "no server/ at aaaaaaaa" + ) + out = evaluate(metric("command", run="echo started; sleep 5", timeout_s=0.5), SHA, c) + assert out == Outcome("gap", "timed out after 0.5 s: started") + + +def test_slow_command_not_run_shows_cache_only_for_same_sha(tmp_path): + cache = { + "S1-1": {"sha": SHA, "ran_at": "2026-09-26 01:00 PDT", "status": "met", "evidence": "ok"} + } + + def no_worktree(sha): + raise AssertionError("slow probe must not run") + + c = ctx(cache=cache, worktree=no_worktree) + m = metric("command", run="true") + assert evaluate(m, SHA, c) == Outcome("not run", "slow; last run 2026-09-26 01:00 PDT: met, ok") + assert evaluate(m, OLD, c) == Outcome("not run", "slow; run with --slow") + + +# --- report ---------------------------------------------------------------------------------- + + +def write_report(path, data, mtime): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data)) + os.utime(path, (mtime, mtime)) + return path + + +def report_metric(tmp_path, **probe): + return metric("report", glob=str(tmp_path / "*" / "report.json"), **probe) + + +def test_report_uses_newest_and_compares(tmp_path): + write_report(tmp_path / "old" / "report.json", {"latency_ms": 2000}, 100) + new = write_report(tmp_path / "new" / "report.json", {"latency_ms": 420}, 200) + out = evaluate(report_metric(tmp_path, key="latency_ms", less_than=1000), SHA, ctx()) + assert out == Outcome("met", f"latency_ms = 420 (want < 1000) at no sha: {new}") + + +@pytest.mark.parametrize( + ("data", "probe", "status"), + [ + ({"build": {"warnings": []}}, {"key": "build.warnings", "length_equals": 0}, "met"), + ({"build": {"warnings": ["w"]}}, {"key": "build.warnings", "length_equals": 0}, "gap"), + ({"states": [1, 2, 3]}, {"key": "states", "length_at_least": 3}, "met"), + ({"states": [1, 2]}, {"key": "states", "length_at_least": 3}, "gap"), + ({"ok": True}, {"key": "ok", "equals": True}, "met"), + ({"ok": 1}, {"key": "ok", "equals": "yes"}, "gap"), + ({"latency_ms": True}, {"key": "latency_ms", "less_than": 1000}, "gap"), + ({"states": [{"state": "result"}]}, {"key": "states.0.state", "equals": "result"}, "met"), + ({"n": 3}, {"key": "n", "length_equals": 0}, "gap"), + ], +) +def test_report_comparators(tmp_path, data, probe, status): + write_report(tmp_path / "r" / "report.json", data, 100) + assert evaluate(report_metric(tmp_path, **probe), SHA, ctx()).status == status + + +def test_report_missing_key_and_no_reports(tmp_path): + m = report_metric(tmp_path, key="build.ok", equals=True) + assert evaluate(m, SHA, ctx()).status == "no evidence yet" + path = write_report(tmp_path / "r" / "report.json", {"build": {}}, 100) + assert evaluate(m, SHA, ctx()) == Outcome("no evidence yet", f"no build.ok in {path} (no sha)") + + +def test_report_skips_newer_runs_that_did_not_set_the_key(tmp_path): + m = report_metric(tmp_path, key="latency_ms", less_than=1000) + write_report(tmp_path / "old" / "report.json", {"latency_ms": 420.0}, 100) + write_report(tmp_path / "new" / "report.json", {"latency_ms": None}, 200) + outcome = evaluate(m, SHA, ctx()) + assert outcome.status == "met" + assert "old" in outcome.evidence + + +def test_report_requiring_current_sha_marks_stale_or_picks_matching(tmp_path): + older = write_report(tmp_path / "a" / "report.json", {"sha": SHA, "states": [1]}, 100) + newest = write_report(tmp_path / "b" / "report.json", {"sha": OLD, "states": []}, 200) + m = report_metric(tmp_path, key="states", length_at_least=1, require_current_sha=True) + out = evaluate(m, SHA, ctx()) + assert out == Outcome("met", f"states = length 1 (want at least 1) at sha aaaaaaaa: {older}") + + out = evaluate(m, "c" * 40, ctx()) + assert out == Outcome("stale", f"report for sha bbbbbbbb, ref at cccccccc: {newest}") + + +def test_report_sha_key_compares_the_named_field_not_sha(tmp_path): + # An e2e report: `sha` is the server's, `app_sha` the app's the export came from. + path = write_report( + tmp_path / "r" / "report.json", + {"sha": OLD, "app_sha": SHA, "app_export_passed": True}, + 100, + ) + m = report_metric( + tmp_path, key="app_export_passed", equals=True, require_current_sha=True, sha_key="app_sha" + ) + assert evaluate(m, SHA, ctx()) == Outcome( + "met", f"app_export_passed = true (want True) at app_sha aaaaaaaa: {path}" + ) + # The server SHA matching the ref is not enough. + assert evaluate(m, OLD, ctx()) == Outcome( + "stale", f"report for app_sha aaaaaaaa, ref at bbbbbbbb: {path}" + ) + + +def test_report_sha_key_missing_from_the_report_is_stale(tmp_path): + path = write_report(tmp_path / "r" / "report.json", {"sha": SHA, "passed": True}, 100) + m = report_metric( + tmp_path, key="passed", equals=True, require_current_sha=True, sha_key="app_sha" + ) + assert evaluate(m, SHA, ctx()) == Outcome( + "stale", f"report for no app_sha, ref at aaaaaaaa: {path}" + ) + + +def test_stale_cites_the_newest_report_that_records_the_sha(tmp_path): + measured = write_report(tmp_path / "a" / "report.json", {"app_sha": OLD, "passed": True}, 100) + write_report(tmp_path / "b" / "report.json", {"sha": SHA, "passed": True}, 200) + m = report_metric( + tmp_path, key="passed", equals=True, require_current_sha=True, sha_key="app_sha" + ) + assert evaluate(m, SHA, ctx()) == Outcome( + "stale", f"report for app_sha bbbbbbbb, ref at aaaaaaaa: {measured}" + ) + + +def test_report_sha_key_defaults_to_sha(): + assert metric("report", glob="x", key="k", equals=1).probe["sha_key"] == "sha" + + +def test_report_valid_key_ignores_runs_that_were_not_evidence(tmp_path): + m = report_metric( + tmp_path, key="issue_count", equals=0, require_current_sha=True, valid_key="ok" + ) + good = write_report( + tmp_path / "a" / "report.json", {"sha": SHA, "ok": True, "issue_count": 0}, 100 + ) + # Newer, but zero screens audited: issue_count 0 must not count. + blind = write_report( + tmp_path / "b" / "report.json", {"sha": SHA, "ok": False, "issue_count": 0}, 200 + ) + assert evaluate(m, SHA, ctx()) == Outcome( + "met", f"issue_count = 0 (want 0) at sha aaaaaaaa: {good}" + ) + good.unlink() + assert evaluate(m, SHA, ctx()) == Outcome( + "gap", f"ok is not true in the newest report (sha aaaaaaaa): {blind}" + ) + # A report from before `ok` existed is not evidence either. + write_report(tmp_path / "b" / "report.json", {"sha": SHA, "issue_count": 0}, 300) + assert evaluate(m, SHA, ctx()).status == "gap" + + +def test_real_app_metrics_compare_the_app_sha(): + for mid in ("S3-8", "S4-1"): + m = real_metric(mid) + assert m.ref == "origin/t3/ios-mvf" + assert m.probe["require_current_sha"] is True + assert m.probe["sha_key"] == "app_sha" + assert real_metric("S3-6b").probe["valid_key"] == "ok" + + +def test_report_path_shortens_home(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + assert sb.short_home(tmp_path / "x" / "report.json") == "~/x/report.json" + assert sb.short_home("/elsewhere/report.json") == "/elsewhere/report.json" + + +# --- PR probes ------------------------------------------------------------------------------- + + +def pr(branch="t3/evals", head=SHA, checks=(), body=""): + return { + "number": 12, + "title": "Evals", + "headRefName": branch, + "headRefOid": head, + "isDraft": True, + "updatedAt": "2026-09-26T07:47:51Z", + "statusCheckRollup": list(checks), + "body": body, + } + + +def test_pr_check_states(): + m = metric("pr_check", name="iOS build") + ok = {"t3/evals": pr(checks=[run("iOS build", "SUCCESS")])} + assert evaluate(m, SHA, ctx(prs=ok)).status == "met" + assert evaluate(m, OLD, ctx(prs=ok)) == Outcome( + "stale", + "iOS build passed for aaaaaaaa, ref at bbbbbbbb", + (("#12", f"{sb.PR_URL}/12"),), + ) + rerun = {"t3/evals": pr(checks=[run("iOS build", "SUCCESS"), run("iOS build", "FAILURE")])} + assert evaluate(m, SHA, ctx(prs=rerun)).status == "gap" + pending = {"t3/evals": pr(checks=[run("iOS build", None, status="QUEUED")])} + assert evaluate(m, SHA, ctx(prs=pending)).status == "not run" + other = {"t3/evals": pr(checks=[run("Lint", "SUCCESS")])} + assert evaluate(m, SHA, ctx(prs=other)).evidence == "no check named 'iOS build'" + assert evaluate(m, SHA, ctx(prs={})) == Outcome("no evidence yet", "no open PR for t3/evals") + assert evaluate(m, SHA, ctx(prs=None)) == Outcome("no evidence yet", "PR list unavailable") + + +def test_pr_body_counts_images(): + m = metric("pr_body", pattern=r"!\[[^\]]*\]\([^)]+\)| None: ... +def test_10_margins_cover_equality(e, offset, expected) -> None: ... +def test_11a_unseen_wall_past_a_tapped_corner() -> None: ... +def test_golden_12_last_start() -> None: ... + +@given(st.lists(st.floats(0, 1), min_size=1), st.booleans()) +@settings(max_examples=50) +def test_missing_coverage_never_passes(xs, flag) -> None: ... + +@given(st.floats()) +def test_mirror_gives_the_same_decision(x) -> None: ... +""" + + +def real_metric(mid): + return next(m for m in load_metrics()[1] if m.id == mid) + + +def test_golden_pattern_accepts_numbered_test_names_only(): + out = evaluate(real_metric("S2-2"), SHA, ctx({SHA: {"server/tests/test_g.py": S2_TESTS}})) + assert out.status == "partial" + assert out.evidence.startswith("found 07, 10, 11, 12; missing 01, 02, 03, 04, 05, 06, 08") + + +def test_property_pattern_needs_a_given_decorator(): + # test_07 and test_10 name coverage and equality but are not Hypothesis tests. + out = evaluate(real_metric("S2-3"), SHA, ctx({SHA: {"server/tests/test_p.py": S2_TESTS}})) + assert out.status == "partial" + assert out.evidence.startswith( + "found missing coverage, mirror; missing equality unsure, larger clearance" + ) + + +def test_slow_only_runs_the_named_probe(tmp_path): + c = ctx(slow=True, slow_only=frozenset({"S2-1"}), worktree=fake_worktree(tmp_path)) + assert evaluate(metric("command", run="true"), SHA, c).status == "not run" + c.slow_only = frozenset({"S1-1"}) + assert evaluate(metric("command", run="true"), SHA, c).status == "met" + + +def test_tail_keeps_last_three_lines_with_spaces_collapsed(): + assert ( + sb._tail("a\n\n... [ 82%]\nb\n 87 passed in 1s \n") + == "... [ 82%] / b / 87 passed in 1s" + ) diff --git a/verification/tests/test_simrun.py b/verification/tests/test_simrun.py new file mode 100644 index 000000000..515f29828 --- /dev/null +++ b/verification/tests/test_simrun.py @@ -0,0 +1,188 @@ +import datetime as dt +import io +import json +import queue +import subprocess +from pathlib import Path + +from hsverify import simrun +from hsverify.simrun import ( + ShotRecord, + app_export, + compiling, + copy_app_export, + missing_required, + pump_log, + seconds_since, + slug, + wait_for_log_stream, +) +from hsverify.statelog import SUBSYSTEM + +BANNER = 'Filtering the log data using "subsystem == "dev.housescanning.housescan""\n' + + +def log_line(message: str, category: str = "state") -> str: + return ( + json.dumps( + { + "timestamp": "2026-09-26 02:50:00.123456-0500", + "subsystem": SUBSYSTEM, + "category": category, + "eventMessage": message, + } + ) + + "\n" + ) + + +def test_state_time_uses_the_log_clock(): + stamp = "2026-09-26 02:47:25.977214-0500" + logged = dt.datetime.strptime(stamp, "%Y-%m-%d %H:%M:%S.%f%z").timestamp() + assert seconds_since(logged - 2.5, stamp, fallback=9.0) == 2.5 + + +def test_unparseable_timestamp_falls_back_to_arrival_time(): + assert seconds_since(0.0, "", fallback=1.234) == 1.23 + + +def test_slug_is_filename_safe(): + assert slug("origin/t3/ios-mvf") == "origin-t3-ios-mvf" + assert slug("wall walk: gap!") == "wall-walk-gap" + + +def test_only_compiling_xcodebuilds_block_a_build(): + assert compiling("/Applications/Xcode.app/.../xcodebuild -project a.xcodeproj build") + assert compiling("xcodebuild test -project a.xcodeproj -scheme A") + assert not compiling("xcodebuild -project a.xcodeproj test-without-building") + + +# --- log stream readiness -------------------------------------------------------------------- + + +def drain(events: queue.Queue) -> list: + items = [] + while not events.empty(): + items.append(events.get_nowait()) + return items + + +def test_ready_is_signalled_by_the_first_line_then_states_follow(): + events: queue.Queue = queue.Queue() + sink = io.StringIO() + pump_log(iter([BANNER, log_line("STATE=onboarding")]), events, sink) + kinds = [(kind, getattr(payload, "name", payload)) for kind, payload in drain(events)] + assert kinds == [("ready", BANNER.strip()), ("state", "onboarding")] + assert sink.getvalue() == BANNER + log_line("STATE=onboarding") + + +def test_a_silent_log_stream_never_signals_ready_and_times_out(): + events: queue.Queue = queue.Queue() + pump_log(iter([]), events, io.StringIO()) + assert events.empty() + assert wait_for_log_stream(events, 0.01) == ( + "log stream printed nothing within 0 s; the app was not launched" + ) + + +def test_wait_for_log_stream_passes_once_ready(): + events: queue.Queue = queue.Queue() + pump_log(iter([BANNER]), events, io.StringIO()) + assert wait_for_log_stream(events, 1.0) is None + + +# --- required states ------------------------------------------------------------------------- + + +def shot(state: str) -> ShotRecord: + return ShotRecord(1, state, 0.0, "01.png", False, f"STATE={state}") + + +def test_missing_required_state_is_a_problem(): + states = [shot("onboarding"), shot("uploading")] + assert missing_required(["result"], states) == ["required state result never appeared"] + assert missing_required(["result"], [*states, shot("result")]) == [] + assert missing_required([], []) == [] + + +def test_sim_app_requires_the_result_state(): + makefile = (Path(__file__).resolve().parents[1] / "Makefile").read_text() + recipe = makefile.split("\nsim-app:\n", 1)[1].split("\n\n", 1)[0] + assert "--require result" in recipe + + +# --- app export ------------------------------------------------------------------------------ + + +def write_log(out: Path, *lines: str) -> None: + (out / "state.ndjson").write_text(BANNER + "".join(lines)) + + +def test_last_logged_bundle_is_copied_as_scan_zip(tmp_path): + first, last = tmp_path / "first.zip", tmp_path / "last.zip" + first.write_bytes(b"old") + last.write_bytes(b"PK new") + out = tmp_path / "report" + out.mkdir() + write_log( + out, + log_line(f"bundle {first} with 3 keyframes", category="engine"), + log_line("STATE=result"), + log_line(f"bundle {last} with 77 keyframes", category="engine"), + ) + assert copy_app_export(out) == ("scan.zip", []) + assert (out / "scan.zip").read_bytes() == b"PK new" + + +def test_no_bundle_logged_means_no_export_and_no_problem(tmp_path): + write_log(tmp_path, log_line("STATE=onboarding")) + assert copy_app_export(tmp_path) == (None, []) + assert not (tmp_path / "scan.zip").exists() + + +def test_logged_bundle_that_is_gone_is_a_problem(tmp_path): + gone = tmp_path / "gone.zip" + write_log(tmp_path, log_line(f"bundle {gone} with 5 keyframes", category="engine")) + export, problems = copy_app_export(tmp_path) + assert export is None + assert len(problems) == 1 + assert problems[0].startswith(f"the app logged bundle {gone} with 5 keyframes; copy failed") + + +def test_a_bundle_line_the_runner_cannot_read_is_a_problem(tmp_path): + write_log(tmp_path, log_line("bundle written to a new place, 5 keyframes", category="engine")) + assert copy_app_export(tmp_path) == ( + None, + ["the app logged a bundle line this runner cannot read; see state.ndjson"], + ) + + +def test_a_run_that_must_keep_the_scan_fails_without_one(tmp_path): + write_log(tmp_path, log_line("STATE=result")) + assert app_export(tmp_path, required=False) == (None, []) + name, problems = app_export(tmp_path, required=True) + assert name is None and "--require-export" in problems[0] + + +def test_a_run_installs_its_own_copy_of_the_app_it_built(tmp_path, monkeypatch): + derived = tmp_path / "derived" + built = derived / "Build" / "Products" / "Debug-iphonesimulator" / "HouseScan.app" + monkeypatch.setattr(simrun, "DERIVED_DATA", derived) + monkeypatch.setattr(simrun, "BUILD_LOCK", tmp_path / "lock") + monkeypatch.setattr(simrun, "wait_for_other_builds", lambda max_wait_s: 0.0) + + def fake_build(cmd, **kwargs): + built.mkdir(parents=True, exist_ok=True) + (built / "Info.plist").write_text("run A") + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(simrun.subprocess, "run", fake_build) + tree, out = tmp_path / "tree", tmp_path / "report" + (tree / "ios" / "HouseScan.xcodeproj").mkdir(parents=True) + out.mkdir() + result, app = simrun.build_app(tree, out, 0, "ios/HouseScan.xcodeproj", "HouseScan") + assert result["ok"] and app is not None + (built / "Info.plist").write_text("run B") # another run rebuilds after the lock is released + assert (app / "Info.plist").read_text() == "run A" + simrun.discard_app(app) + assert not app.parent.exists() and out.exists() diff --git a/verification/tests/test_statelog.py b/verification/tests/test_statelog.py new file mode 100644 index 000000000..6796ba25e --- /dev/null +++ b/verification/tests/test_statelog.py @@ -0,0 +1,96 @@ +import json + +import pytest + +from hsverify.statelog import ( + CATEGORY, + SUBSYSTEM, + RedactedStateError, + parse_bundle_line, + parse_ndjson_line, + parse_plain_line, +) + + +def entry(message: str, subsystem: str = SUBSYSTEM, category: str = CATEGORY) -> str: + return json.dumps( + { + "timestamp": "2026-09-26 02:50:00.123456-0500", + "subsystem": subsystem, + "category": category, + "eventMessage": message, + } + ) + + +def test_reads_state_name(): + event = parse_ndjson_line(entry("STATE=wall_walk")) + assert event is not None + assert event.name == "wall_walk" + assert event.timestamp.startswith("2026-09-26") + + +def test_detail_after_the_name_is_not_part_of_it(): + event = parse_ndjson_line(entry("STATE=gap_request wall=w1 from=-4.0")) + assert event is not None + assert event.name == "gap_request" + + +@pytest.mark.parametrize( + "line", + [ + "Filtering the log data using ...", # banner printed before the JSON lines + "", + "{not json", + entry("STATE=x", subsystem="com.apple.arkit"), + entry("STATE=x", category="network"), + entry("tracking normal"), + ], +) +def test_ignores_everything_else(line): + assert parse_ndjson_line(line) is None + + +@pytest.mark.parametrize("message", ["", "STATE="]) +def test_redacted_marker_is_loud(message): + with pytest.raises(RedactedStateError): + parse_ndjson_line(entry(message)) + + +def test_plain_line(): + assert parse_plain_line("2026 app[12] STATE=result_reveal") == "result_reveal" + assert parse_plain_line("no marker here") is None + + +BUNDLE_PATH = "/Users/x/Library/Developer/CoreSimulator/Devices/D/data/Caches/Scans/S/scan.zip" + + +@pytest.mark.parametrize( + "suffix", ["", ", 0 with depth, no mesh (kept on the phone)", ", 12 with depth, mesh"] +) +def test_reads_the_engine_bundle_line(suffix): + line = entry(f"bundle {BUNDLE_PATH} with 77 keyframes{suffix}", category="engine") + assert parse_bundle_line(line) == (BUNDLE_PATH, 77) + + +@pytest.mark.parametrize( + "line", + [ + entry(f"bundle {BUNDLE_PATH} with 77 keyframes"), # state category + entry(f"bundle {BUNDLE_PATH} with 77 keyframes", subsystem="other", category="engine"), + entry("bundle written", category="engine"), + "Filtering the log data using ...", + ], +) +def test_bundle_line_ignores_everything_else(line): + assert parse_bundle_line(line) is None + + +def test_reads_the_packet_bundle_line_from_app_f14947e(): + line = entry( + f"bundle {BUNDLE_PATH}: packet 1.1 with 33 photos (0 with depth), 0 depth frames, " + "41 trajectory samples", + category="engine", + ) + assert parse_bundle_line(line) == (BUNDLE_PATH, 33) + assert parse_bundle_line(entry(f"bundle {BUNDLE_PATH}: packet 1.1", category="engine")) is None diff --git a/verification/uv.lock b/verification/uv.lock new file mode 100644 index 000000000..793b85a89 --- /dev/null +++ b/verification/uv.lock @@ -0,0 +1,465 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "house-scanning-verification" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "jsonschema" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "jsonschema", specifier = ">=4.23" }, + { name = "numpy", specifier = ">=2.5.3" }, + { name = "pillow", specifier = ">=12.3.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.9" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]