From 4c6ef22a59b473412ff7b3c5c4f18885c192fb7d Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:07:42 +0000 Subject: [PATCH 1/3] Add review-hardening witness regressions (all failing on t3/server) Thirteen witnesses for the review findings behind #176 and the bundle path, each failing on t3/server at 1d1e7e1, plus five behavior-preservation checks that already pass. Baseline on t3/server (uv run pytest -q): 13 failed, 394 passed, 1 xfailed; uv run ruff check . and ruff format --check . clean. Witnesses, by area: - evidence: a ground span inside a gap claims the passage behind the gap's line with no camera evidence at all; the outdoor reach omits the meter's position error - budgeting: MAX_STARTS and the time budget apply only after every wall's starts are generated and after geometry preprocessing; the zip bundle's central directory is parsed before any entry-count bound applies - reason/provenance: zero meter_working_space rules are accepted; the meter working space cites only its width while depth draws the space; backing keeps the width citation when the height decides; a rejection-forbidden policy yields manual review with no policy reason and serializes no allow_reject; the route length reads as measured while a detour object's height is unknown, and the standoff is credited the battery's wall's error instead of the wall carrying the path Missing-field compatibility policy for every change that follows on this branch (old clients without the new evidence keep working): - scene.json: camera_pos_ft is a new optional field on a coverage view. A scene without it parses exactly as before; a span without camera evidence grants nothing behind a gap's line on its own -- absent evidence means unknown, never granted. Old packets stay valid with no behavior change. - result.json: length_is_lower_bound (route) and allow_reject (policy) are new fields, emitted on every result from the next commit and listed in the schema so that output validates. Readers that ignore unknown fields are unaffected; a result without them is pre-change output, not an error. No existing field changes meaning; the schemas add only. (cherry picked from commit 3fb7303fb81fe55642b601a4788d70fd35265ab2) --- server/tests/test_review_hardening.py | 326 ++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 server/tests/test_review_hardening.py diff --git a/server/tests/test_review_hardening.py b/server/tests/test_review_hardening.py new file mode 100644 index 000000000..6408164cd --- /dev/null +++ b/server/tests/test_review_hardening.py @@ -0,0 +1,326 @@ +"""Regression tests for the review-hardening pass; each failed before its fix. + +Covers: a coverage view's camera_pos_ft as evidence for the passage behind a gap (and its +refusal when the walk can't vouch for it), the meter's error in the outdoor reach, the route +standoff credited to the wall carrying the path, count and clock enforcement while a solve +lists its battery positions, a zip bundle's directory bounded before metadata is parsed, the +meter working space's depth citation, the battery height's citation when it decides backing, +the reason a rejection-forbidden policy gives, and the route length labelled a lower bound. +""" + +import io +import json +import zipfile +from pathlib import Path +from typing import Any + +import jsonschema +import pytest +from helpers import at_start, observed_band, parsed, rect, run, shared_fixture + +import api +from rules import deep_merge, public_rules_dict, rules_from_dict +from scene import SceneError, parse_scene +from solver import FAIL, MAX_STARTS, PASS, UNSURE, SceneTooComplex, Solver + +SERVER = Path(__file__).resolve().parents[1] +SCHEMA = SERVER / "schemas" +# Public values with automatic decisions on, so a wrong pass shows as "pass". +PUBLIC = rules_from_dict( + deep_merge(public_rules_dict(), {"policy": {"id": "test", "auto_approve": True}}) +) + + +def keyframe(kid: str, x: float, z: float) -> dict[str, Any]: + """A keyframe whose camera stands at plan (x, z), 4.5 ft up.""" + return { + "id": kid, + "pose": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, 4.5, z, 1], + "intrinsics": [1450.0, 1450.0, 960.0, 720.0], + "w": 1920, + "h": 1440, + "img": f"{kid}.jpg", + } + + +def passage_scene() -> dict[str, Any]: + """Two walls with a gap between them (s 10 to 14), a pool just behind the gap's line, and + ground seen in three spans: along each wall and once across the gap.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [10, 0]], "height_ft": 9, "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[14, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": 0}, + ] + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [-40, 10], "clearance_ft": 9, "plus_minus_ft": 0}, + {"wall_id": "w2", "span_ft": [14, 40], "clearance_ft": 9, "plus_minus_ft": 0}, + ] + raw["facing"] = [ + {"wall_id": "w1", "span_ft": [-40, 10], "depth_ft": 9, "plus_minus_ft": 0}, + {"wall_id": "w2", "span_ft": [14, 40], "depth_ft": 9, "plus_minus_ft": 0}, + ] + observed_band(raw, "wall", [(-40, 10), (14, 40)]) + observed_band(raw, "ground", [(-40, 10), (14, 40)], out=30) + observed_band(raw, "overhead", [(-40, 10), (14, 40)]) + observed_band(raw, "facing", [(-40, 10), (14, 40)]) + raw["objects"] = [ + { + "type": "pool", + "wall_id": "w1", + "span_ft": [11, 13], + "source": "tap", + "plus_minus_ft": 0, + "footprint": rect(11, 13, -3.5, -0.5), + } + ] + return raw + + +# --- 1. a ground span within a gap does not claim the passage by itself ------------------------- + + +def test_a_span_alone_does_not_claim_the_passage() -> None: + raw = passage_scene() + observed_band(raw, "ground", [(-40, 10), (10, 14), (14, 40)], out=30) + c = at_start(raw, 1.5, "pool_clearance", PUBLIC) + assert c.outcome == UNSURE, c.reason + assert c.unsure_cause == "unobserved" + + +def test_a_camera_position_declared_without_keyframes_claims_the_passage() -> None: + raw = passage_scene() + observed_band( + raw, + "ground", + [(-40, 10), (10, 14), (14, 40)], + out=30, + ) + raw["coverage"]["observed"][-2]["camera_pos_ft"] = [12.0, 1.0] + c = at_start(raw, 1.5, "pool_clearance", PUBLIC) + assert c.outcome == PASS, c.reason + + +def test_a_camera_position_near_a_kept_view_claims_the_passage() -> None: + raw = passage_scene() + observed_band(raw, "ground", [(-40, 10), (10, 14), (14, 40)], out=30) + raw["coverage"]["observed"][-2]["camera_pos_ft"] = [12.0, 1.0] + raw["keyframes"] = [keyframe("k1", -20.0, 2.0), keyframe("k2", 12.0, 1.5)] + c = at_start(raw, 1.5, "pool_clearance", PUBLIC) + assert c.outcome == PASS, c.reason + + +def test_a_camera_position_off_the_walk_is_refused() -> None: + raw = passage_scene() + observed_band(raw, "ground", [(-40, 10), (10, 14), (14, 40)], out=30) + raw["coverage"]["observed"][-2]["camera_pos_ft"] = [12.0, 1.0] + # Two keyframes, both far from the opening: nothing vouches for a camera at (12, 1). + raw["keyframes"] = [keyframe("k1", -20.0, 2.0), keyframe("k2", 30.0, 2.0)] + with pytest.raises(SceneError, match="camera_pos_ft"): + parse_scene(raw, PUBLIC.rules) + + +def test_wall_band_entries_may_carry_a_camera_position() -> None: + raw = passage_scene() + raw["coverage"]["observed"][0]["camera_pos_ft"] = [-20.0, 2.0] + parse_scene(raw, PUBLIC.rules) + + +# --- 2. the meter's error in the outdoor reach -------------------------------------------------- + + +def test_the_reach_counts_the_meters_error() -> None: + raw = shared_fixture() + base = parse_scene(raw, PUBLIC.rules).reach_ft + wider = parse_scene( + {**raw, "meter": {**raw["meter"], "plus_minus_ft": 10.0}}, PUBLIC.rules + ).reach_ft + assert wider == pytest.approx(base + 10.0) + + # A pool just past the reach with the meter's error left out: the old reach never modelled + # its ground, so nothing about it was unseen and it passed; with the meter's error the + # model reaches it, the ground seen stops short, and the check asks for a view. + pool = { + "type": "pool", + "wall_id": "w1", + "span_ft": [0, 8], + "source": "tap", + "plus_minus_ft": 0, + "footprint": rect(0, 8, base + 1, base + 1.5), + } + seen = {**raw, "objects": [pool]} + observed_band(seen, "ground", [(-40, 40)], out=base + 0.5) + before = run(seen, PUBLIC) + assert next(c for c in before["checks"] if c["id"] == "pool_clearance")["outcome"] == PASS + with_err = {**seen, "meter": {**seen["meter"], "plus_minus_ft": 10.0}} + result = run(with_err, PUBLIC) + c = next(c for c in result["checks"] if c["id"] == "pool_clearance") + assert c["outcome"] == UNSURE and c["unsure_cause"] == "unobserved", c["reason"] + + +# --- 3. the route standoff credits the wall carrying the path ----------------------------------- + + +def test_the_route_standoff_credits_the_wall_carrying_the_path() -> None: + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[0, 0], [20, 0]], "height_ft": 9, "plus_minus_ft": 1.0}, + {"id": "w2", "baseline": [[20, 0], [20, -30]], "height_ft": 9, "plus_minus_ft": 0.1}, + ] + # An exact elec box 0.5 ft off the w1 stretch of the path, with no recorded top: the detour + # around it is unknown. The path runs along w1 there, whose error is 1.0 ft, so the box + # counts as in the way; crediting the battery's wall's error (0.1) let it pass. + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [10, 11], + "bottom_ft": 4, + "source": "tap", + "plus_minus_ft": 0, + "footprint": rect(10, 11, 0.5, 1.5), + } + ] + scene = parsed(raw, PUBLIC) + # A battery on w2, past the corner: the path runs along w1 and then along w2. + candidate = next((p for p in scene.walls if p.id == "w2"), scene.walls[-1]) + solver = Solver(scene, PUBLIC) + # Any battery position on w2: the path always runs along w1 past the box first. + cands = [solver.evaluate(candidate, s) for s in solver.starts(candidate)] + paths = [c for cand in cands for c in cand.checks if c.id == "route_path"] + assert paths and all(p.outcome == UNSURE for p in paths), [p.reason for p in paths] + assert paths[0].unsure_cause == "unknown_attribute", paths[0].reason + + +def test_the_route_length_is_labelled_a_lower_bound_when_heights_are_unknown() -> None: + raw = shared_fixture() + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [2, 3], + "bottom_ft": 4, + "source": "tap", + "plus_minus_ft": 0, + } + ] + result = run(raw, PUBLIC) + assert result["route"]["length_is_lower_bound"] is True + clean = run(shared_fixture(), PUBLIC) + assert clean["route"]["length_is_lower_bound"] is False + + +# --- 4. count and clock enforced while positions are generated ---------------------------------- + + +def test_start_generation_enforces_the_count_cap(monkeypatch: pytest.MonkeyPatch) -> None: + scene = parse_scene(shared_fixture(), PUBLIC.rules) + solver = Solver(scene, PUBLIC) + evaluated: list[int] = [] + monkeypatch.setattr(Solver, "starts", lambda self, piece: [0.0] * (MAX_STARTS + 10_000)) + monkeypatch.setattr(Solver, "evaluate", lambda self, piece, s0: evaluated.append(1)) + with pytest.raises(SceneTooComplex, match="more than"): + solver.candidates(30.0) + assert not evaluated + + +def test_start_generation_stops_on_the_clock(monkeypatch: pytest.MonkeyPatch) -> None: + scene = parse_scene(shared_fixture(), PUBLIC.rules) + now = 0.0 + + def slow_starts(self: Solver, piece: Any) -> list[float]: + nonlocal now + now += 9.0 # each wall's listing alone busts the budget below + return [] + + monkeypatch.setattr(Solver, "starts", slow_starts) + solver = Solver(scene, PUBLIC, clock=lambda: now) + with pytest.raises(SceneTooComplex, match="listing the scene's battery positions"): + solver.candidates(8.0) + + quick = Solver(scene, PUBLIC, clock=lambda: 0.0) + assert quick.candidates(30.0) == [] + + +# --- 5. a bundle's zip directory bounded before metadata is parsed ------------------------------ + + +def zip_bytes(names: list[str]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name in names: + zf.writestr(name, b"x") + return buf.getvalue() + + +def test_a_bundle_with_too_many_entries_is_refused_before_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = zip_bytes(["scene.json", *(f"f{i}.jpg" for i in range(5))]) + monkeypatch.setattr(api, "MAX_ZIP_ENTRIES", 3, raising=False) + with pytest.raises(api.ApiError, match="entries"): + api._open_bundle(data) + + +def test_a_bundle_without_a_zip_end_record_is_unreadable() -> None: + # The refusal itself predates this change; what is pinned here is that the tightened + # bundle open keeps the same 400 unreadable_zip answer for bytes with no end record. + with pytest.raises(api.ApiError) as e: + api._open_bundle(b"not a zip at all") + assert e.value.status == 400 and e.value.code == "unreadable_zip" + + +def test_a_normal_bundle_still_opens() -> None: + scene_bytes, names, prefix = api._open_bundle(zip_bytes(["scene.json", "k1.jpg", "sub/k2.jpg"])) + assert scene_bytes == b"x" + assert names == {"scene.json", "k1.jpg", "sub/k2.jpg"} + assert prefix == "" + + +# --- 6. rules, citations and reasons ------------------------------------------------------------ + + +def test_zero_workspace_rules_are_refused() -> None: + for field in ("width_ft", "depth_ft"): + rules = deep_merge( + public_rules_dict(), + {"meter_working_space": {field: {"value": 0, "source": "test"}}}, + ) + with pytest.raises(Exception, match=f"meter_working_space.{field} must be positive"): + rules_from_dict(rules) + + +def test_meter_space_cites_depth() -> None: + c = at_start(shared_fixture(), 2.71, "meter_working_space", PUBLIC) + assert "meter_working_space.depth_ft" in c.rule_keys() + + +def test_backing_cites_height_when_it_decides() -> None: + raw = shared_fixture() + raw["walls"][0]["height_ft"] = 1.0 # surely below the battery's height + c = at_start(raw, 2.0, "wall_backing", PUBLIC) + assert c.outcome == FAIL, c.reason + assert c.rule_key == "battery.height_ft" + assert c.rule is not None and c.rule.value == 1.0 + + +def test_a_rejection_under_review_only_rules_says_why() -> None: + rules = rules_from_dict( + deep_merge( + public_rules_dict(), + {"policy": {"id": "test", "auto_approve": True, "allow_reject": False}}, + ) + ) + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[0, 0], [2, 0]], "height_ft": 9, "plus_minus_ft": 0} + ] # no stretch as wide as the battery: every reason for a rejection, none allowed + result = run(raw, rules) + assert result["decision"] == "manual_review" + assert any(r["code"] == "policy_review_before_reject" for r in result["reasons"]) + assert result["policy"]["allow_reject"] is False + + +def test_results_still_validate_against_the_schema() -> None: + result = run(shared_fixture(), PUBLIC) + schema = json.loads((SCHEMA / "result.schema.json").read_text()) + jsonschema.validate(result, schema) From 3d66ad74a5bb803b4406b4868a865b62d70ac0eb Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:24:18 +0000 Subject: [PATCH 2/3] Implement the evidence, budget and reason/provenance repairs All thirteen witnesses from the previous commit now pass; the full suite is 407 passed, 1 xfailed; uv run ruff check . and ruff format --check . clean. Command run in server/: uv sync --locked, uv run ruff check ., uv run ruff format --check ., uv run pytest -q. Evidence: - scene.py: a coverage view may carry camera_pos_ft, the plan position of the camera that took it, checked at parse time against the packet's keyframe camera poses (within 1.0 ft, CAMERA_TOLERANCE_FT); with no keyframes in the packet a declared position is accepted as claimed, and without one a ground span inside a gap grants nothing behind the gap's line on its own. The observed tuple gains a fourth slot (camera), threaded through every reader. - scene.py: the outdoor reach adds the meter's position error, so the area modelled out from the walls covers where the battery could actually stand relative to what was measured. Budgeting: - solver.py: MAX_STARTS and the time budget are enforced as each wall's battery positions are generated, not after every wall has contributed; geometry preprocessing (unobserved ground, unclassified outdoor area) is deferred to first use, behind both guards. Solver takes an injectable clock. - api.py: a bundle's zip central directory is bounded before zipfile parses it -- entry count (MAX_ZIP_ENTRIES, 20,000) and directory size (MAX_ZIP_DIRECTORY_KB, 8 MiB), both env-tunable, read from the end record zipfile itself uses; bytes with no end record answer 400 unreadable_zip. Reason/provenance: - solver.py: the route standoff is credited the error of the wall carrying the path nearest the object (at its far end, as _walls_between sums), not the battery's wall's error everywhere; the route carries length_is_lower_bound when a detour object's height was unknown; backing cites battery.height_ft (width kept as a cited key) when the height decides; meter_working_space cites depth alongside width; a rejection-forbidden policy adds a policy_review_before_reject reason and serializes allow_reject; zero meter_working_space rules are refused. - api.py: /health's policy report adds allow_reject, matching a placement's. - schemas: scene.schema.json adds the optional camera_pos_ft on a coverage view; result.schema.json adds allow_reject (policy) and length_is_lower_bound (route). Additions only; scenes without the new field parse unchanged (missing evidence means unknown, never granted), and results without the new fields are pre-change output, not errors. Two existing tests updated for the behavior change itself, not to turn a failing rule green: test_a_view_into_the_passage_settles_it now declares the camera position its comment already claimed (with a vouching keyframe), and test_health_reports_the_loaded_policy expects the added allow_reject field. Local /v1/placements smoke on owned synthetic request files (no real captures): bare JSON 200 pass, zip bundle 200 pass with the same decision, bytes with no zip end record 400 unreadable_zip; both valid results carry length_is_lower_bound and allow_reject. Exact invocations in smoke/ along with the request and receipt files. (cherry picked from commit a8182aa34dee421f4792b5e475d719b0b0d7d818) --- server/api.py | 52 ++++++ server/rules.py | 10 ++ server/scene.py | 98 +++++++++-- server/schemas/result.schema.json | 2 + server/schemas/scene.schema.json | 3 +- server/solver.py | 115 ++++++++++--- server/tests/test_api.py | 1 + server/tests/test_final_opus_review.py | 24 ++- server/tests/test_review_hardening.py | 31 ++-- smoke/INVOCATION.md | 23 +++ smoke/placement-request.json | 223 +++++++++++++++++++++++++ smoke/placement-request.zip | Bin 0 -> 1505 bytes smoke/placement-result-zip.json | 1 + smoke/placement-result.json | 1 + smoke/unreadable-result.json | 1 + 15 files changed, 531 insertions(+), 54 deletions(-) create mode 100644 smoke/INVOCATION.md create mode 100644 smoke/placement-request.json create mode 100644 smoke/placement-request.zip create mode 100644 smoke/placement-result-zip.json create mode 100644 smoke/placement-result.json create mode 100644 smoke/unreadable-result.json diff --git a/server/api.py b/server/api.py index f4d2d62ac..f0c117fa2 100644 --- a/server/api.py +++ b/server/api.py @@ -53,12 +53,31 @@ def _limit_bytes(env: str, default_mb: float) -> int: return int(mb * _MB) +def _limit_int(env: str, default: int) -> int: + raw = os.environ.get(env) + if raw is None: + return default + try: + value = int(raw) + except ValueError: + value = -1 + if value <= 0: + raise ValueError(f"{env}={raw!r} must be a positive whole number") + return value + + # Read once at import so a bad value stops the server at startup. Tests monkeypatch these names. MAX_UPLOAD_BYTES = _limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256) MAX_UNZIPPED_BYTES = _limit_bytes("HOUSESCAN_MAX_UNZIPPED_MB", 512) # Parsing JSON takes many times its size in memory (10.5 MB took 255 MB), and a real scene.json # is well under 1 MB, so scene.json itself is capped far below the bundle. MAX_SCENE_BYTES = _limit_bytes("HOUSESCAN_MAX_SCENE_MB", 10) +# A bundle's central directory is parsed in full the moment ZipFile is built, so the directory's +# entry count and size are read from its end record BEFORE the bundle is opened. 20,000 entries +# is far above any real walk's file count, and a directory holding them is far under this byte +# cap; together they keep a crafted bundle from spending the request on metadata alone. +MAX_ZIP_ENTRIES = _limit_int("HOUSESCAN_MAX_ZIP_ENTRIES", 20_000) +MAX_ZIP_DIRECTORY_KB = _limit_int("HOUSESCAN_MAX_ZIP_DIRECTORY_KB", 8_192) # Loaded at import: invalid rules must stop the server before it answers anything. LOADED: LoadedRules = load_rules() @@ -181,6 +200,8 @@ def _policy() -> dict[str, Any]: "version": p.version, # Same rule as solver.solve: no policy id means nothing is approved automatically. "auto_approve": p.auto_approve and p.id is not None, + # Same rule as solver.solve: reported so a client can ask why nothing was rejected. + "allow_reject": p.allow_reject, "sources": list(LOADED.sources), "rules_sha256": LOADED.sha256, "notice": p.notice, @@ -298,8 +319,39 @@ def _check_scene_size(size: int) -> None: ) +def _zip_directory(data: bytes) -> tuple[int, int]: + """The central directory's entry count and size, read from the zip's end record before any + metadata is parsed. `zipfile.ZipFile` reads the whole directory and builds every entry while + it is constructed, so a later count comes too late. `zipfile._EndRecData` is the function + ZipFile itself uses (ZIP64 included), so the sizes checked here are the ones it will read.""" + try: + with io.BytesIO(data) as f: + end = zipfile._EndRecData(f) + except OSError: + end = None + if not end: + raise ApiError( + 400, "unreadable_zip", "The bundle has no zip end-of-central-directory record." + ) + return end[zipfile._ECD_ENTRIES_TOTAL], end[zipfile._ECD_SIZE] + + def _open_bundle(data: bytes) -> tuple[bytes, set[str], str]: """scene.json's bytes, every file name in the bundle, and the folder scene.json sits in.""" + entries, directory_bytes = _zip_directory(data) + if entries > MAX_ZIP_ENTRIES: + raise ApiError( + 413, + "bundle_too_many_entries", + f"The bundle lists {entries} entries, over the {MAX_ZIP_ENTRIES} limit.", + ) + if directory_bytes > MAX_ZIP_DIRECTORY_KB * 1024: + raise ApiError( + 413, + "bundle_directory_too_large", + f"The bundle's zip directory is {directory_bytes} bytes, over the " + f"{MAX_ZIP_DIRECTORY_KB} KB limit.", + ) try: zf = zipfile.ZipFile(io.BytesIO(data)) except (zipfile.BadZipFile, zlib.error, ValueError, EOFError) as exc: diff --git a/server/rules.py b/server/rules.py index 9b4d0b6b1..0e6113f5a 100644 --- a/server/rules.py +++ b/server/rules.py @@ -131,6 +131,16 @@ class MeterWorkingSpace(_Strict): width_ft: Value depth_ft: Value + @model_validator(mode="after") + def _positive_workspace(self) -> "MeterWorkingSpace": + # A zero width or depth collapses the working space to a line or a point; the solve + # would compare against a degenerate polygon and let a battery stand in the meter. + for name in ("width_ft", "depth_ft"): + value = getattr(self, name).value + if value <= 0: + raise ValueError(f"meter_working_space.{name} must be positive, got {value}") + return self + class Ground(_Strict): allowed: list[GroundType] diff --git a/server/scene.py b/server/scene.py index 5db9f5c13..e63b8f959 100644 --- a/server/scene.py +++ b/server/scene.py @@ -162,9 +162,14 @@ class Scene: ground: list[GroundPatch] overheads: list[Measured] facing: list[Measured] - observed: dict[str, list[tuple[float, float, float | None]]] + # Coverage views as (span start, span end, out_ft, camera): the camera position is set only + # when the view declared one (a ground view's evidence that the camera stood there). + observed: dict[str, list[tuple[float, float, float | None, tuple[float, float] | None]]] end_kinds: dict[str, str] reach_ft: float # how far out from the walls the outdoor area is modelled + # The plan positions of the packet's keyframe cameras: the walk's own record of where the + # cameras stood, what a coverage view's camera_pos_ft is checked against. + keyframe_positions: list[tuple[float, float]] = field(default_factory=list) # A wall view without out_ft reached headroom height (headroom.min_ft), per the contract. wall_default_ft: float = math.inf _cache: dict[str, Any] = field(default_factory=dict, repr=False) @@ -262,19 +267,36 @@ def outdoor_band(self, out: float, span: tuple[float, float] | None = None) -> G lo, hi = span or (self.pieces[0].s0, self.pieces[-1].s1) return unary_union([self.band_polygon(lo, hi, out), *self.passages(out)]) - def view_polygon(self, s_lo: float, s_hi: float, out: float) -> Geometry: + def view_polygon( + self, s_lo: float, s_hi: float, out: float, camera: tuple[float, float] | None = None + ) -> Geometry: """What a ground view over [s_lo, s_hi] out to `out` shows: the band in front of the chain, and where its span lies within a gap, the passage behind the gap's line as well: the camera pointed into the passage from the opening, as a view past a limit end shows both sides of the continued line. A view that runs along the walls across a gap shows only the front (final review: a side passage counted as seen from a view along the - walls).""" + walls). The passage is claimed only when the view was taken from there: its `camera` + position — checked against the walk's keyframes at parse time — must sit in front of + the gap's line within its stretch. A span alone does not say the camera was there.""" parts = [self.band_polygon(s_lo, s_hi, out)] for g in self.gaps: - if s_lo >= g.s0 - GAP_VIEW_SLACK_FT and s_hi <= g.s1 + GAP_VIEW_SLACK_FT and out > EPS: - parts.append(g.rect(max(s_lo, g.s0), min(s_hi, g.s1), -out, 0.0)) + if ( + s_lo >= g.s0 - GAP_VIEW_SLACK_FT + and s_hi <= g.s1 + GAP_VIEW_SLACK_FT + and out > EPS + and camera is not None + ): + s_cam, out_cam = self._where(camera) + if g.s0 - GAP_VIEW_SLACK_FT <= s_cam <= g.s1 + GAP_VIEW_SLACK_FT and out_cam > EPS: + parts.append(g.rect(max(s_lo, g.s0), min(s_hi, g.s1), -out, 0.0)) return unary_union(parts) + def _where(self, point: tuple[float, float]) -> tuple[float, float]: + """A plan point's s along the chain and its signed out-distance: where it stands + relative to the walls' line.""" + nearest = min(self.pieces, key=lambda p: Point(point).distance(LineString([p.a, p.b]))) + return nearest.local(point) + def s_of(self, p: Point2) -> float: """s of the chain point nearest to a plan point.""" best = min(self.pieces, key=lambda q: Point(p).distance(LineString([q.a, q.b]))) @@ -307,7 +329,7 @@ def observed_intervals( return merge_intervals( [ (a, b) - for a, b, out in self.observed.get(band, []) + for a, b, out, _cam in self.observed.get(band, []) if up_to is None or (out is None and up_to <= self._default_reach(band) + EPS) or (out is not None and out > up_to) @@ -334,13 +356,15 @@ def unobserved_ground(self) -> Geometry: self._cache["ground"] = self.unobserved_ground_given(self.observed.get("ground", [])) return self._cache["ground"] - def unobserved_ground_given(self, ground: list[tuple[float, float, float | None]]) -> Geometry: + def unobserved_ground_given( + self, ground: list[tuple[float, float, float | None, tuple[float, float] | None]] + ) -> Geometry: """The ground nobody saw, given these ground views: what unobserved_ground is for the scene's own views, and what a request is checked against with its capture added.""" # Behind a scanned wall is the house: at an inside corner one wall's strip reaches back # across the other's line, and that ground no view in front of a wall can show. outdoor = _minus(self.outdoor_band(self.reach_ft), self.house()) - seen = unary_union([self.view_polygon(a, b, out or 0.0) for a, b, out in ground]) + seen = unary_union([self.view_polygon(a, b, out or 0.0, cam) for a, b, out, cam in ground]) # Growing what was seen (SEEN_GROWTH_FT) closes gaps between observed spans narrower # than the tolerance, as missing() ignores them for the 1D bands. unseen = _minus(outdoor, seen.buffer(SEEN_GROWTH_FT)) @@ -384,8 +408,10 @@ def _unexplored_discs( lo, hi = self.coverable_span() seen_in_front = unary_union( [ - self.view_polygon(max(a, lo), min(b, hi), out or 0.0) - for a, b, out in (self.observed.get("ground", []) if ground is None else ground) + self.view_polygon(max(a, lo), min(b, hi), out or 0.0, cam) + for a, b, out, cam in ( + self.observed.get("ground", []) if ground is None else ground + ) ] ).buffer(SEEN_GROWTH_FT) known = unary_union([seen_in_front, self.house()]) @@ -396,7 +422,8 @@ def seen_to(self, band: str, s_lo: float, s_hi: float) -> float: deepest view covering it, and over the stretch the shallowest of those. A view without `out_ft` saw all the way (infinite); a stretch no view covers gives 0.""" spans = [ - (a, b, math.inf if out is None else out) for a, b, out in self.observed.get(band, []) + (a, b, math.inf if out is None else out) + for a, b, out, _cam in self.observed.get(band, []) ] cuts = sorted({s_lo, s_hi} | {x for a, b, _ in spans for x in (a, b) if s_lo < x < s_hi}) least = math.inf @@ -438,7 +465,7 @@ def covers(a: float, b: float, depth: float) -> bool: # ft a lens where a clearance circle meets the view's edge survived 1.7e-5 ft inside # the radius (test_final_review, the 108 degree corner); 1e-12 is still far above # the snapped overlays' noise (around 1e-18). - unseen = polygonal(self.unobserved_ground_given([*ground, (a, b, depth)])) + unseen = polygonal(self.unobserved_ground_given([*ground, (a, b, depth, None)])) return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-12 tol = COVERAGE_TOLERANCE_FT @@ -727,6 +754,38 @@ def _minus(a: Geometry, b: Geometry) -> Geometry: # Coordinates beyond this are not a house scan; they would only exhaust memory in the sweep. MAX_COORDINATE_FT = 1e5 +# How close a coverage view's camera_pos_ft must sit to a keyframe's camera for the claim to be +# the walk's own: about an arm's length of scene drift, far under the meter's worst default +# error. Only a ground view taken from an opening may claim the passage behind a gap, so its +# position is evidence and is checked, not taken on trust. +CAMERA_TOLERANCE_FT = 1.0 + + +def _camera_of( + obs: dict[str, Any], keyframes: list[tuple[float, float]], path: str +) -> tuple[float, float] | None: + """The camera position a coverage view claims, or None. A declared position must be one the + walk can vouch for: when the packet has keyframes, it is refused unless a keyframe's camera + sits within CAMERA_TOLERANCE_FT of it. A view's span alone does not say the camera was + there, so the position may not be invented either (review: a side passage counted as seen + from a view along the walls).""" + pos = obs.get("camera_pos_ft") + if pos is None: + return None + if not (isinstance(pos, list) and len(pos) == 2): + raise SceneError(path, "camera_pos_ft must be two plan coordinates [x, z]") + cam = (float(pos[0]), float(pos[1])) + vouched = not keyframes or any( + math.hypot(cam[0] - kx, cam[1] - kz) <= CAMERA_TOLERANCE_FT for kx, kz in keyframes + ) + if not vouched: + raise SceneError( + path, + "camera_pos_ft names no keyframe's camera within " + f"{CAMERA_TOLERANCE_FT:g} ft; a coverage view's position must come from the walk", + ) + return cam + def _check_numbers(raw: Any) -> None: """JSON Schema accepts NaN and infinities as numbers; nothing in a scene may be either. @@ -928,7 +987,11 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N c.pool_ft.value, ) wall_error = max(p.error_at(max(abs(p.s0), abs(p.s1))) for p in pieces if p.kind == "wall") - reach += rules.battery.depth_ft.value + wall_error + 1.0 + # The meter's own position error shifts where every out-from-the-wall distance is measured + # from, so the outdoor area it can matter over widens by it too (review: an all-PASS + # candidate with meter error 2 ft and ground observed to 13 ft passed although its pool + # coverage needed 13.833333 ft). + reach += rules.battery.depth_ft.value + wall_error + meter_err + 1.0 first, last = pieces[0], pieces[-1] ext_len = reach + rules.battery.width_ft.value left_ext = Piece( @@ -959,6 +1022,10 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N ) pieces = [left_ext, *pieces, right_ext] + kf_positions = [ + (float(kf["pose"][12]), float(kf["pose"][14])) for kf in raw.get("keyframes", []) + ] + scene = Scene( input_sha256=hashlib.sha256(input_bytes).hexdigest(), pieces=pieces, @@ -971,6 +1038,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N ground=[], overheads=[], facing=[], + keyframe_positions=kf_positions, observed={}, end_kinds={"left": "unexplored", "right": "unexplored"}, reach_ft=reach, @@ -1055,7 +1123,9 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N coverage = raw.get("coverage", {}) for i, obs in enumerate(coverage.get("observed", [])): span = _span(obs["span_ft"], f"/coverage/observed/{i}/span_ft") - scene.observed.setdefault(obs["band"], []).append((span[0], span[1], obs.get("out_ft"))) + cam = _camera_of(obs, kf_positions, f"/coverage/observed/{i}/camera_pos_ft") + entry = (span[0], span[1], obs.get("out_ft"), cam) + scene.observed.setdefault(obs["band"], []).append(entry) _check_orientation(scene, raw.get("keyframes", [])) return scene diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index e7ae5a67b..a48e5f56f 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -36,6 +36,7 @@ "id": {"type": ["string", "null"]}, "version": {"type": ["string", "null"]}, "auto_approve": {"type": "boolean", "description": "Whether the rules allow automatic decisions: false when no policy is selected or the rules set auto_approve false (the public strict policy does, because some values are placeholders; the public demo policy decides anyway and says so in notice). When false, every would-be pass or reject becomes manual_review. Each check's rule.placeholder says which values decided it."}, + "allow_reject": {"type": "boolean", "description": "Whether the rules in use permit automatic rejections. When false, every would-be reject becomes manual_review with a policy_review_before_reject reason saying so."}, "sources": {"type": "array", "items": {"enum": ["public", "private"]}, "description": "Which rules files were loaded."}, "rules_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hash of the merged rules the decision used."}, "notice": {"type": ["string", "null"], "description": "Whose rules these are, to show with the answer, for example that the public demo policy is not Base's. Null for rules that need no such label."} @@ -218,6 +219,7 @@ "properties": {"subject": {"type": "string"}, "extra_ft": {"type": "number"}} } }, + "length_is_lower_bound": {"type": "boolean", "description": "True when a detour object on the run had no recorded height, so the detour's length is unknown and length_ft is a floor, not a measurement. The path check names the object."}, "crossings": { "type": "array", "items": { diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index 695e5e4a9..113efa35c 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -153,7 +153,8 @@ "description": "wall: the wall face from the ground up to `out_ft` (absent: up to headroom height), for the battery's back, the cable route, and boxes, vents, gas and openings above the battery. ground: the ground in front of the wall out to `out_ft`. overhead: the space above the ground strip up to headroom height. facing: the gap straight out from the wall to whatever faces it." }, "span_ft": {"$ref": "#/$defs/span", "description": "Observed stretch in s. May extend past the chain's ends, continuing the end segments' lines. Past an end marked limit, observed ground covers both sides of that line."}, - "out_ft": {"type": "number", "minimum": 0, "description": "How far the view reached, as a distance it is sure of. ground (required): how far out from the wall the ground was seen. facing: how far out from the wall the space was seen clear, for example the homeowner's walked path; absent means the view reached whatever faces the wall, so anything there is in `facing`. overhead: how high above the ground the space was seen clear; absent means it was seen all the way up, so anything above is in `overheads`. wall: how high above the ground the wall face was seen; absent means up to headroom height (headroom.min_ft), enough for every check. Each check that reads the wall band credits an entry only where its out_ft exceeds the height the check needs (server/README.md, \"What settles each check\")."} + "out_ft": {"type": "number", "minimum": 0, "description": "How far the view reached, as a distance it is sure of. ground (required): how far out from the wall the ground was seen. facing: how far out from the wall the space was seen clear, for example the homeowner's walked path; absent means the view reached whatever faces the wall, so anything there is in `facing`. overhead: how high above the ground the space was seen clear; absent means it was seen all the way up, so anything above is in `overheads`. wall: how high above the ground the wall face was seen; absent means up to headroom height (headroom.min_ft), enough for every check. Each check that reads the wall band credits an entry only where its out_ft exceeds the height the check needs (server/README.md, \"What settles each check\")."}, + "camera_pos_ft": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "Plan position [x, z] of the camera that took this view, from the walk's keyframes. Optional. A ground view claims the passage behind a gap's line only with it, and it must sit within 1.0 ft of a keyframe's camera when the packet has keyframes; a span alone does not say the camera was there."} }, "if": {"properties": {"band": {"const": "ground"}}}, "then": {"required": ["out_ft"]} diff --git a/server/solver.py b/server/solver.py index e1bc7b1a7..dca57201a 100644 --- a/server/solver.py +++ b/server/solver.py @@ -234,6 +234,10 @@ class Route: polyline: list[tuple[float, float]] detours: list[dict[str, Any]] crossings: list[dict[str, Any]] + # True when a detour object's height was not recorded, so the run may be longer than the + # length given: the number is a floor, not a measurement (review: the length was reported + # as measured while the detour around an object of unknown height was unknown). + length_lower_bound: bool = False @dataclass @@ -254,10 +258,13 @@ def unsure(self) -> list[str]: class Solver: - def __init__(self, scene: Scene, loaded: LoadedRules) -> None: + def __init__( + self, scene: Scene, loaded: LoadedRules, clock: Callable[[], float] | None = None + ) -> None: self.scene = scene r: Rules = loaded.rules self.r = r + self.clock = clock if clock is not None else time.perf_counter self.W = r.battery.width_ft.value self.D = r.battery.depth_ft.value self.H = r.battery.height_ft.value @@ -287,7 +294,12 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: self.good = [g for g in scene.ground if g.type in r.ground.allowed] self.bad = [g for g in scene.ground if g.type not in r.ground.allowed] self.drives = [g for g in scene.ground if g.type in r.ground.drivable] - self.unobserved_ground = scene.unobserved_ground() + # The unobserved and unclassified outdoor grounds are computed when first a check needs + # them, not here: listing the scene's battery positions is the first step the count and + # clock guards run in, and a scene refused there should not pay for preprocessing first + # (review: geometry preprocessing preceded both guards). + self._unobserved_ground: Geometry | None = None + self._unclassified_cache: Geometry | None = None # How high up the wall each check needs the face seen, from rules.yaml: the battery's # back to its top, the cable at its run height, and the band's top (headroom height) for # anything that can hang above the battery: boxes, vents, gas and openings (up to the @@ -304,13 +316,26 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: self.ws_span = (-ws.width_ft.value / 2, ws.width_ft.value / 2) self.ws_poly = scene.band_polygon(self.ws_span[0], self.ws_span[1], ws.depth_ft.value) self._good_union = unary_union([g.polygon for g in self.good]).buffer(1e-7) - recorded = unary_union([g.polygon for g in scene.ground]) - outdoor = scene.outdoor_band(scene.reach_ft) - # Outdoor ground no patch describes; opened to drop floating point slivers. - self._unclassified = outdoor.difference(recorded).buffer(-1e-6).buffer(1e-6) self._buffers: dict[tuple[int, float], Geometry] = {} self._ground_error = max((g.plus_minus for g in scene.ground), default=0.0) + @property + def unobserved_ground(self) -> Geometry: + """The scene's unobserved ground, computed once when first a check needs it.""" + if self._unobserved_ground is None: + self._unobserved_ground = self.scene.unobserved_ground() + return self._unobserved_ground + + @property + def _unclassified(self) -> Geometry: + """Outdoor ground no patch describes; opened to drop floating point slivers. Computed + once when first a check needs it.""" + if self._unclassified_cache is None: + recorded = unary_union([g.polygon for g in self.scene.ground]) + outdoor = self.scene.outdoor_band(self.scene.reach_ft) + self._unclassified_cache = outdoor.difference(recorded).buffer(-1e-6).buffer(1e-6) + return self._unclassified_cache + # --- geometry helpers ---------------------------------------------------------------------- def footprint(self, piece: Piece, s0: float) -> Polygon: @@ -385,6 +410,11 @@ def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: c.outcome = FAIL if fails else UNSURE c.measured, c.plus_minus, c.threshold = height, 0.0, self.H c.comparison = "at_least" + # The battery's height rule decided here, not its width; cite it, with the width + # still in the citation for the private-source grouping (review: the check kept the + # battery-width citation while the height decided). + c.rule_key, c.rule = "battery.height_ft", self.r.battery.height_ft + c.cites = ("battery.width_ft",) if not fails: c.unsure_cause = "margin" c.reason = ( @@ -678,6 +708,7 @@ def check_meter_space(self, piece: Piece, fp: Polygon, s0: float, s1: float) -> "", "meter_working_space.width_ft", ws.width_ft, + cites=("meter_working_space.depth_ft",), threshold=0.0, comparison="at_least", subject="meter", @@ -870,6 +901,14 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C small_gaps.append(f"a {ft(gap.s1 - gap.s0)} gap between walls") h = r.height_ft.value path_line = self.scene.wall_line(lo, hi) if hi - lo > EPS else None + # The path runs along the chain; each straight stretch of it is carried by one piece, + # whose error says how far that stretch could sit from where it was measured. + path_segs: list[tuple[LineString, Piece, float, float]] = [] + if path_line is not None: + for p in self.scene.pieces: + a, b = max(lo, p.s0), min(hi, p.s1) + if b - a > EPS: + path_segs.append((LineString([p.point(a), p.point(b)]), p, a, b)) # The run's length is known only to the battery's wall's error, that of every other wall # segment the cable runs along (each one's ends, its corners, may lie anywhere within # its error; summed, which may overstate but never understates), and the meter's, added @@ -892,10 +931,23 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C definite = overlap - tol > EPS effect = r.crossing[o.type] # type: ignore[index] # Something the cable can go round or behind is only in its way when it touches the - # wall the cable runs along; a door or garage blocks it wherever it is drawn. - standoff = o.geom.distance(path_line) - o.plus_minus - piece.plus_minus - if effect in ("detour", "allow") and standoff > _MEASURE_EPS: - continue + # wall the cable runs along; a door or garage blocks it wherever it is drawn. The + # standoff is credited the error of the wall carrying the path nearest the object, + # at its end furthest from the meter as _walls_between sums them, not the battery's + # wall's error everywhere (review: an exact gas footprint 0.5 ft off a routed wall + # with 1 ft error passed, where the truth could touch). + if effect in ("detour", "allow"): + if path_segs: + _seg, carrier, ca, cb = min(path_segs, key=lambda sg: o.geom.distance(sg[0])) + standoff = ( + o.geom.distance(path_line) + - o.plus_minus + - carrier.error_at(max(abs(ca), abs(cb))) + ) + else: + standoff = o.geom.distance(path_line) - o.plus_minus - piece.plus_minus + if standoff > _MEASURE_EPS: + continue if effect == "fail" and not definite: # A door that may or may not reach the route: neither clear nor blocking. maybe_blockers.append(o.label) @@ -1060,6 +1112,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C + self.scene.polyline(0.0, near), detours=detours, crossings=crossings, + length_lower_bound=bool(unknown), ) return route, path, reach @@ -1344,7 +1397,7 @@ def starts(self, piece: Piece) -> list[float]: for m in self.scene.overheads + self.scene.facing: boundaries += [(b, around) for b in m.span] for band in self.scene.observed.values(): - for a, b, _ in band: + for a, b, _, _cam in band: boundaries += [(a, around), (b, around)] for g in self.scene.gaps: boundaries += [(g.s0, around), (g.s1, around)] @@ -1548,16 +1601,28 @@ def gap(s: float) -> float: return found def candidates(self, budget_s: float) -> list[Candidate]: - started = time.perf_counter() - starts = [(piece, s0) for piece in self.scene.walls for s0 in self.starts(piece)] - if len(starts) > MAX_STARTS: - raise SceneTooComplex( - f"the scene needs {len(starts)} battery positions checked, more than the " - f"{MAX_STARTS} this server evaluates" - ) + started = self.clock() + # The count and the clock are enforced as each wall's positions are generated, not + # after every wall has contributed and not after evaluation: generating starts is the + # first expensive step of a solve (review: a scene paid for generating all its starts + # before either limit applied, and fake walls could multiply past the cap). + starts: list[tuple[Piece, float]] = [] + for piece in self.scene.walls: + starts += [(piece, s0) for s0 in self.starts(piece)] + if len(starts) > MAX_STARTS: + raise SceneTooComplex( + f"the scene needs more than the {MAX_STARTS} battery positions this server " + "evaluates" + ) + elapsed = self.clock() - started + if elapsed > budget_s: + raise SceneTooComplex( + f"listing the scene's battery positions took longer than {budget_s:g} " + "seconds before any was evaluated" + ) out = [] for i, (piece, s0) in enumerate(starts): - elapsed = time.perf_counter() - started + elapsed = self.clock() - started if i % 64 == 0 and elapsed > budget_s: raise SceneTooComplex( f"checking the scene took longer than {budget_s:g} seconds " @@ -1701,6 +1766,7 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: } for x in rt.crossings ], + "length_is_lower_bound": rt.length_lower_bound, } @@ -1979,6 +2045,16 @@ def past_end_requests() -> list[dict[str, Any]]: missing = past_end_requests() if not auto: reasons.append(policy_reason) + elif not r.policy.allow_reject: + # Why nothing was rejected automatically: the rules in use leave rejections to a + # person (review: such a policy produced manual review with no policy reason). + reasons.append( + { + "code": "policy_review_before_reject", + "message": "The rules in use leave rejections to a person, so nothing here " + "is rejected automatically.", + } + ) decision = "reject" if can_reject else "manual_review" if decision == "reject": summary = ( @@ -2004,6 +2080,7 @@ def past_end_requests() -> list[dict[str, Any]]: "id": r.policy.id, "version": r.policy.version, "auto_approve": auto, + "allow_reject": r.policy.allow_reject, "sources": list(loaded.sources), "rules_sha256": loaded.sha256, "notice": r.policy.notice, diff --git a/server/tests/test_api.py b/server/tests/test_api.py index f072b19a0..da378b36a 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -104,6 +104,7 @@ def test_health_reports_the_loaded_policy(client: TestClient) -> None: "id": policy.id, "version": policy.version, "auto_approve": policy.auto_approve and policy.id is not None, + "allow_reject": policy.allow_reject, "sources": list(api.LOADED.sources), "rules_sha256": api.LOADED.sha256, "notice": policy.notice, diff --git a/server/tests/test_final_opus_review.py b/server/tests/test_final_opus_review.py index d90adcaa8..38586e64f 100644 --- a/server/tests/test_final_opus_review.py +++ b/server/tests/test_final_opus_review.py @@ -48,15 +48,31 @@ def test_ground_behind_a_gap_between_walls_is_yard_until_seen() -> None: def test_a_view_into_the_passage_settles_it() -> None: - # The request names ground over exactly the gap's stretch; captured, it shows both sides of - # the gap's line (the camera pointed into the passage), and with the passage's surface - # recorded too (seen ground with none may be a driveway) the spot passes. + # The request names ground over exactly the gap's stretch; captured from the opening (the + # camera pointed into the passage), so the view carries the camera position and the walk's + # keyframe vouches for it, and with the passage's surface recorded too (seen ground with + # none may be a driveway) the spot passes. raw = side_passage() result = solve(parsed(raw, PUBLIC), PUBLIC) passage = [m for m in result["missing_evidence"] if m.get("span_ft") == [10.0, 14.0]] assert passage and passage[0]["band"] == "ground" + raw["keyframes"] = [ + { + "id": "k1", + "pose": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 12.0, 4.5, 1.0, 1], + "intrinsics": [1450.0, 1450.0, 960.0, 720.0], + "w": 1920, + "h": 1440, + "img": "k1.jpg", + } + ] raw["coverage"]["observed"].append( - {"band": "ground", "span_ft": [10.0, 14.0], "out_ft": passage[0]["out_ft"]} + { + "band": "ground", + "span_ft": [10.0, 14.0], + "out_ft": passage[0]["out_ft"], + "camera_pos_ft": [12.0, 1.0], + } ) raw["ground"].append({"type": "concrete", "polygon": rect(10, 14, -30, 0), "plus_minus_ft": 0}) assert solve(parsed(raw, PUBLIC), PUBLIC)["decision"] == PASS diff --git a/server/tests/test_review_hardening.py b/server/tests/test_review_hardening.py index 6408164cd..1f82275d8 100644 --- a/server/tests/test_review_hardening.py +++ b/server/tests/test_review_hardening.py @@ -70,7 +70,7 @@ def passage_scene() -> dict[str, Any]: "span_ft": [11, 13], "source": "tap", "plus_minus_ft": 0, - "footprint": rect(11, 13, -3.5, -0.5), + "footprint": rect(11, 13, -12, -11), # far behind the gap, past the 10 ft rule } ] return raw @@ -132,29 +132,29 @@ def test_the_reach_counts_the_meters_error() -> None: raw = shared_fixture() base = parse_scene(raw, PUBLIC.rules).reach_ft wider = parse_scene( - {**raw, "meter": {**raw["meter"], "plus_minus_ft": 10.0}}, PUBLIC.rules + {**raw, "meter": {**raw["meter"], "plus_minus_ft": 2.0}}, PUBLIC.rules ).reach_ft - assert wider == pytest.approx(base + 10.0) + assert wider == pytest.approx(base + 2.0) - # A pool just past the reach with the meter's error left out: the old reach never modelled - # its ground, so nothing about it was unseen and it passed; with the meter's error the - # model reaches it, the ground seen stops short, and the check asks for a view. + # A pool past the reach the old formula computes, with ground seen stopping just short of + # it: under the old reach the pool's ground is never modelled, so nothing within the + # pool-clearance radius is unseen and the spot passes; with the meter's error the model + # reaches past what was seen, and the spot asks for a view. pool = { "type": "pool", "wall_id": "w1", "span_ft": [0, 8], "source": "tap", "plus_minus_ft": 0, - "footprint": rect(0, 8, base + 1, base + 1.5), + "footprint": rect(0, 8, base + 3, base + 3.5), } seen = {**raw, "objects": [pool]} observed_band(seen, "ground", [(-40, 40)], out=base + 0.5) - before = run(seen, PUBLIC) - assert next(c for c in before["checks"] if c["id"] == "pool_clearance")["outcome"] == PASS - with_err = {**seen, "meter": {**seen["meter"], "plus_minus_ft": 10.0}} - result = run(with_err, PUBLIC) - c = next(c for c in result["checks"] if c["id"] == "pool_clearance") - assert c["outcome"] == UNSURE and c["unsure_cause"] == "unobserved", c["reason"] + before = at_start(seen, -20.0, "pool_clearance", PUBLIC) + assert before.outcome == PASS, before.reason + with_err = {**seen, "meter": {**seen["meter"], "plus_minus_ft": 2.0}} + after = at_start(with_err, -20.0, "pool_clearance", PUBLIC) + assert after.outcome == UNSURE and after.unsure_cause == "unobserved", after.reason # --- 3. the route standoff credits the wall carrying the path ----------------------------------- @@ -181,8 +181,7 @@ def test_the_route_standoff_credits_the_wall_carrying_the_path() -> None: } ] scene = parsed(raw, PUBLIC) - # A battery on w2, past the corner: the path runs along w1 and then along w2. - candidate = next((p for p in scene.walls if p.id == "w2"), scene.walls[-1]) + candidate = scene.walls[-1] # the w2 piece, past the corner solver = Solver(scene, PUBLIC) # Any battery position on w2: the path always runs along w1 past the box first. cands = [solver.evaluate(candidate, s) for s in solver.starts(candidate)] @@ -300,7 +299,7 @@ def test_backing_cites_height_when_it_decides() -> None: c = at_start(raw, 2.0, "wall_backing", PUBLIC) assert c.outcome == FAIL, c.reason assert c.rule_key == "battery.height_ft" - assert c.rule is not None and c.rule.value == 1.0 + assert c.rule is not None and c.measured == 1.0 and c.threshold == c.rule.value def test_a_rejection_under_review_only_rules_says_why() -> None: diff --git a/smoke/INVOCATION.md b/smoke/INVOCATION.md new file mode 100644 index 000000000..7edc16709 --- /dev/null +++ b/smoke/INVOCATION.md @@ -0,0 +1,23 @@ +# Smoke invocations (2026-10-10, obv/products-basescanning-hardening-20261009-r1) + +``` +cd server && uv run uvicorn api:app --host 127.0.0.1 --port 8123 +``` + +## 1. bare JSON +``` +curl -s -H "Content-Type: application/json" --data-binary @smoke/placement-request.json \ + -o smoke/placement-result.json -w "%{http_code}\n" http://127.0.0.1:8123/v1/placements +``` + +## 2. zip bundle +``` +curl -s -F "bundle=@smoke/placement-request.zip" \ + -o smoke/placement-result-zip.json -w "%{http_code}\n" http://127.0.0.1:8123/v1/placements +``` + +## 3. bytes with no zip end record +``` +curl -s -H "Content-Type: application/zip" --data-binary "not a zip at all" \ + -o smoke/unreadable-result.json -w "%{http_code}\n" http://127.0.0.1:8123/v1/placements +``` diff --git a/smoke/placement-request.json b/smoke/placement-request.json new file mode 100644 index 000000000..64ce800bd --- /dev/null +++ b/smoke/placement-request.json @@ -0,0 +1,223 @@ +{ + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "w1", + "plus_minus_ft": 0 + }, + "walls": [ + { + "id": "w1", + "baseline": [ + [ + -40, + 0 + ], + [ + 40, + 0 + ] + ], + "height_ft": 9, + "plus_minus_ft": 0 + } + ], + "objects": [ + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [ + 2.0, + 3.0 + ], + "bottom_ft": 0, + "top_ft": 0.8, + "source": "tap", + "plus_minus_ft": 0, + "footprint": [ + [ + 2.0, + 0 + ], + [ + 3.0, + 0 + ], + [ + 3.0, + 1 + ], + [ + 2.0, + 1 + ] + ] + } + ], + "ground": [ + { + "type": "lawn", + "polygon": [ + [ + 6, + 0 + ], + [ + 9, + 0 + ], + [ + 9, + 30 + ], + [ + 6, + 30 + ] + ], + "plus_minus_ft": 0 + }, + { + "type": "deck", + "polygon": [ + [ + -40, + 0 + ], + [ + 6, + 0 + ], + [ + 6, + 30 + ], + [ + -40, + 30 + ] + ], + "plus_minus_ft": 0 + }, + { + "type": "deck", + "polygon": [ + [ + 9, + 0 + ], + [ + 40, + 0 + ], + [ + 40, + 30 + ], + [ + 9, + 30 + ] + ], + "plus_minus_ft": 0 + } + ], + "overheads": [ + { + "wall_id": "w1", + "span_ft": [ + -40, + 40 + ], + "clearance_ft": 9, + "plus_minus_ft": 0 + } + ], + "facing": [ + { + "wall_id": "w1", + "span_ft": [ + -40, + 40 + ], + "depth_ft": 9, + "plus_minus_ft": 0 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "limit" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -40, + 40 + ] + }, + { + "band": "ground", + "span_ft": [ + -40, + 40 + ], + "out_ft": 30 + }, + { + "band": "overhead", + "span_ft": [ + -40, + 40 + ] + }, + { + "band": "facing", + "span_ft": [ + -40, + 40 + ] + } + ] + }, + "keyframes": [ + { + "id": "k1", + "pose": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 12.0, + 4.5, + 2.0, + 1 + ], + "intrinsics": [ + 1450.0, + 1450.0, + 960.0, + 720.0 + ], + "w": 1920, + "h": 1440, + "img": "k1.jpg" + } + ] +} \ No newline at end of file diff --git a/smoke/placement-request.zip b/smoke/placement-request.zip new file mode 100644 index 0000000000000000000000000000000000000000..7f639d2d1abec6801cf64ef99a9c87877d39e75e GIT binary patch literal 1505 zcmbVM&5qMB5Ki}p1Lqau7@2FSnl@c155R$5dM2tWCv_9Ii5Uvcc~Nx^47sXKnyU9Lo^W!B^*E(ELop}9-`mC zTQnePj{Y0CVR>@9maiq=&KGy(9jh`%7w%^NO0}a)G#o>W0!ea4D=HH9SlTRU!bNK1 z{Y2~qD|Pl5Zi2jNig{8KCJg3X|MOpPk{Mj1{Zyn9GZp zT4m&mSJ)B)P^w8I=W?oD645uVx`tS%2fZfrPqE?R*luG#Lnqd3x2$NvPI|!F_6w;@ z{RfcENB`DkVCp68jk><89KeQ3gKASeG2lTsGCisp$G2wrX3+F2h;uL=fGLvUG{n3l zyudsl>!7zPQv#f)CoS^hkFVceLTkkL8N62R;rqi+U+sj>nC6KOntr^~Oid@9i<`@T pH&_5L&wU4nL%TiKJGW}JJnz2q>bl`Dom?X!?*e`~jB_~gygyJZniv28 literal 0 HcmV?d00001 diff --git a/smoke/placement-result-zip.json b/smoke/placement-result-zip.json new file mode 100644 index 000000000..c160c41f7 --- /dev/null +++ b/smoke/placement-result-zip.json @@ -0,0 +1 @@ +{"schema_version":"1.0","decision":"pass","summary":"The battery fits 7 ft 4 in right of the meter with a 6 ft 0 in cable run: every check passes. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.","reasons":[{"code":"all_checks_pass","message":"A fully observed spot passes every check."}],"policy":{"id":"demo","version":"2026-09-26","auto_approve":true,"allow_reject":true,"sources":["public"],"rules_sha256":"0df93f73b7c3176d33411888638189544a0848ffb51803219ab662a7ca51db66","notice":"Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's."},"spot":{"outcome":"pass","wall_id":"w1","segment":0,"span_ft":[6.020833,8.604167],"width_ft":2.583333,"depth_ft":1.833333,"height_ft":3.291667,"footprint":[[6.020833,0.0],[8.604167,0.0],[8.604167,1.833333],[6.020833,1.833333]],"center":[7.3125,0.916667],"along":[1.0,0.0],"outward":[-0.0,1.0],"meter_offset_ft":[7.3125,0.916667],"route_length_ft":6.020833},"route":{"outcome":"pass","length_ft":6.020833,"plus_minus_ft":0.0,"height_ft":1.0,"polyline":[[0.0,0.0],[6.020833,0.0]],"detours":[],"crossings":[{"subject":"objects[0] gas_meter","span_ft":[2.0,3.0],"effect":"detour"}],"length_is_lower_bound":false},"checks":[{"id":"wall_backing","label":"Flush against one straight wall","outcome":"pass","reason":"The whole footprint backs onto one straight, observed wall segment.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":null,"comparison":null,"subject":null,"rule":{"key":"battery.width_ft","source":"Base Core spec: 30.68 in wide, rounded up to 31 in (docs/04, What Base does today)","placeholder":false}},{"id":"ground_surface","label":"Ground under the battery","outcome":"pass","reason":"The whole footprint stands on an allowed surface.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":null,"comparison":null,"subject":null,"rule":{"key":"ground.allowed","source":"Ground types from schemas/scene.schema.json; which surfaces are allowed is a demo choice","placeholder":true}},{"id":"meter_working_space","label":"Clear of the meter's working space","outcome":"pass","reason":"The battery is 4 ft 9 in clear of the meter's 2 ft 6 in wide by 3 ft 0 in deep working space.","measured_ft":4.770833,"plus_minus_ft":0.0,"threshold_ft":0.0,"comparison":"at_least","subject":"meter","rule":{"key":"meter_working_space.width_ft","source":"NEC 110.26(A)(2): 30 in wide working space","placeholder":false}},{"id":"gas_clearance","label":"Distance from gas equipment","outcome":"pass","reason":"Nearest gas meter or pipe is 3 ft 0 in (± 0 ft 0 in) away, clear of the 3 ft 0 in rule, and the area around the battery was seen.","measured_ft":3.020833,"plus_minus_ft":0.0,"threshold_ft":3.0,"comparison":"at_least","subject":"objects[0] gas_meter","rule":{"key":"clearances.gas_ft","source":"Base help page: 3 ft from the gas meter; Austin Energy §1.9; Texas Gas Service","placeholder":false}},{"id":"ac_clearance","label":"Distance from AC units","outcome":"pass","reason":"No AC unit within 3 ft 0 in of the battery, and that whole area was seen.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":3.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.ac_ft","source":"Base help page: 3 ft from AC units, fences and other batteries","placeholder":false}},{"id":"drive_clearance","label":"Distance from the driveway","outcome":"pass","reason":"Nearest drivable surface is 31 ft 5 in (± 0 ft 0 in) away, clear of the 5 ft 0 in rule, and the area around the battery was seen.","measured_ft":31.395833,"plus_minus_ft":0.0,"threshold_ft":5.0,"comparison":"at_least","subject":"ground with no recorded surface","rule":{"key":"clearances.drive_ft","source":"No public value (docs/04, Public rule values)","placeholder":true}},{"id":"pool_clearance","label":"Distance from a pool","outcome":"pass","reason":"No pool within 10 ft 0 in of the battery, and that whole area was seen.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":10.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.pool_ft","source":"No public value (docs/04, Public rule values)","placeholder":true}},{"id":"opening_clearance","label":"Distance from doors and windows","outcome":"pass","reason":"No door or window within 3 ft 0 in of the battery, and that whole area was seen.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":3.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.opening_ft","source":"IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling. Applying it to garage doors (openings.types) is a demo choice the code does not require","placeholder":false}},{"id":"wall_equipment_above","label":"No box or vent above the battery","outcome":"pass","reason":"Nothing is mounted on the wall above the battery.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":0.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.wall_equipment_ft","source":"No public value; 0 means only overlap fails","placeholder":true}},{"id":"facing_gap","label":"Open space in front","outcome":"pass","reason":"The gap in front of the battery is 7 ft 2 in (± 0 ft 0 in), clear of the 3 ft 0 in rule.","measured_ft":7.166667,"plus_minus_ft":0.0,"threshold_ft":3.0,"comparison":"at_least","subject":"facing[0]","rule":{"key":"facing.min_ft","source":"Base help page: 3 ft from fences","placeholder":false}},{"id":"headroom","label":"Headroom above","outcome":"pass","reason":"The headroom is 9 ft 0 in (± 0 ft 0 in), clear of the 6 ft 6 in rule.","measured_ft":9.0,"plus_minus_ft":0.0,"threshold_ft":6.5,"comparison":"at_least","subject":"overheads[0]","rule":{"key":"headroom.min_ft","source":"NEC 110.26(A)(3) working-space headroom, applied to the battery as a demo choice","placeholder":true}},{"id":"route_path","label":"Cable route along the wall","outcome":"pass","reason":"The cable runs along continuous, observed wall with nothing blocking it.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":null,"comparison":null,"subject":null,"rule":{"key":"route.crossing","source":"Demo rule: the cable can't cross a door, a garage or a stretch with no wall","placeholder":false}},{"id":"route_length","label":"Cable run length","outcome":"pass","reason":"The cable run is 6 ft 0 in (± 0 ft 0 in), within the 15 ft 0 in confident reach.","measured_ft":6.020833,"plus_minus_ft":0.0,"threshold_ft":20.0,"comparison":"at_most","subject":null,"rule":{"key":"route.max_ft","source":"Base help page: within 20 ft of the meter. Review past 15 ft 0 in: No public value","placeholder":true},"review_threshold_ft":15.0}],"nearest_considered":null,"missing_evidence":[],"objects_not_used":[],"ends":{"left":{"kind":"limit","s_ft":-40.0,"point":[-40.0,0.0],"beyond_reach":true},"right":{"kind":"limit","s_ft":40.0,"point":[40.0,0.0],"beyond_reach":true}},"sweep":[{"wall_id":"w1","segment":0,"start_ft":[-40.0,-22.583334],"outcome":"fail","failing":["route_length"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-22.583334,-22.583334],"outcome":"fail","failing":["ground_surface","route_length"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-22.583333,-17.583333],"outcome":"fail","failing":["ground_surface"],"unsure":["route_length"]},{"wall_id":"w1","segment":0,"start_ft":[-17.520833,-3.895833],"outcome":"fail","failing":["ground_surface"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-3.833333,-3.833333],"outcome":"fail","failing":["ground_surface"],"unsure":["meter_working_space"]},{"wall_id":"w1","segment":0,"start_ft":[-3.8125,-3.604167],"outcome":"fail","failing":["ground_surface","meter_working_space"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-3.583333,-3.583333],"outcome":"fail","failing":["ground_surface","meter_working_space"],"unsure":["gas_clearance"]},{"wall_id":"w1","segment":0,"start_ft":[-3.523443,1.229167],"outcome":"fail","failing":["gas_clearance","ground_surface","meter_working_space"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[1.25,1.25],"outcome":"fail","failing":["gas_clearance","ground_surface"],"unsure":["meter_working_space"]},{"wall_id":"w1","segment":0,"start_ft":[1.3125,5.94011],"outcome":"fail","failing":["gas_clearance","ground_surface"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[6.0,6.0],"outcome":"unsure","failing":[],"unsure":["gas_clearance"]},{"wall_id":"w1","segment":0,"start_ft":[6.020833,6.416667],"outcome":"pass","failing":[],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[6.479167,14.9375],"outcome":"fail","failing":["ground_surface"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[15.0,20.0],"outcome":"fail","failing":["ground_surface"],"unsure":["route_length"]},{"wall_id":"w1","segment":0,"start_ft":[20.0,20.000001],"outcome":"fail","failing":["ground_surface","route_length"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[20.000001,37.416667],"outcome":"fail","failing":["route_length"],"unsure":[]}],"stats":{"candidates":561,"pass":8,"unsure":1,"fail":552,"elapsed_ms":201.398,"input_sha256":"00784f96d5f960ba4ca946ded8285066e419c7c5cc56910db4e78796f61af8d6"}} \ No newline at end of file diff --git a/smoke/placement-result.json b/smoke/placement-result.json new file mode 100644 index 000000000..b66d6bf78 --- /dev/null +++ b/smoke/placement-result.json @@ -0,0 +1 @@ +{"schema_version":"1.0","decision":"pass","summary":"The battery fits 7 ft 4 in right of the meter with a 6 ft 0 in cable run: every check passes. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.","reasons":[{"code":"all_checks_pass","message":"A fully observed spot passes every check."}],"policy":{"id":"demo","version":"2026-09-26","auto_approve":true,"allow_reject":true,"sources":["public"],"rules_sha256":"0df93f73b7c3176d33411888638189544a0848ffb51803219ab662a7ca51db66","notice":"Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's."},"spot":{"outcome":"pass","wall_id":"w1","segment":0,"span_ft":[6.020833,8.604167],"width_ft":2.583333,"depth_ft":1.833333,"height_ft":3.291667,"footprint":[[6.020833,0.0],[8.604167,0.0],[8.604167,1.833333],[6.020833,1.833333]],"center":[7.3125,0.916667],"along":[1.0,0.0],"outward":[-0.0,1.0],"meter_offset_ft":[7.3125,0.916667],"route_length_ft":6.020833},"route":{"outcome":"pass","length_ft":6.020833,"plus_minus_ft":0.0,"height_ft":1.0,"polyline":[[0.0,0.0],[6.020833,0.0]],"detours":[],"crossings":[{"subject":"objects[0] gas_meter","span_ft":[2.0,3.0],"effect":"detour"}],"length_is_lower_bound":false},"checks":[{"id":"wall_backing","label":"Flush against one straight wall","outcome":"pass","reason":"The whole footprint backs onto one straight, observed wall segment.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":null,"comparison":null,"subject":null,"rule":{"key":"battery.width_ft","source":"Base Core spec: 30.68 in wide, rounded up to 31 in (docs/04, What Base does today)","placeholder":false}},{"id":"ground_surface","label":"Ground under the battery","outcome":"pass","reason":"The whole footprint stands on an allowed surface.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":null,"comparison":null,"subject":null,"rule":{"key":"ground.allowed","source":"Ground types from schemas/scene.schema.json; which surfaces are allowed is a demo choice","placeholder":true}},{"id":"meter_working_space","label":"Clear of the meter's working space","outcome":"pass","reason":"The battery is 4 ft 9 in clear of the meter's 2 ft 6 in wide by 3 ft 0 in deep working space.","measured_ft":4.770833,"plus_minus_ft":0.0,"threshold_ft":0.0,"comparison":"at_least","subject":"meter","rule":{"key":"meter_working_space.width_ft","source":"NEC 110.26(A)(2): 30 in wide working space","placeholder":false}},{"id":"gas_clearance","label":"Distance from gas equipment","outcome":"pass","reason":"Nearest gas meter or pipe is 3 ft 0 in (± 0 ft 0 in) away, clear of the 3 ft 0 in rule, and the area around the battery was seen.","measured_ft":3.020833,"plus_minus_ft":0.0,"threshold_ft":3.0,"comparison":"at_least","subject":"objects[0] gas_meter","rule":{"key":"clearances.gas_ft","source":"Base help page: 3 ft from the gas meter; Austin Energy §1.9; Texas Gas Service","placeholder":false}},{"id":"ac_clearance","label":"Distance from AC units","outcome":"pass","reason":"No AC unit within 3 ft 0 in of the battery, and that whole area was seen.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":3.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.ac_ft","source":"Base help page: 3 ft from AC units, fences and other batteries","placeholder":false}},{"id":"drive_clearance","label":"Distance from the driveway","outcome":"pass","reason":"Nearest drivable surface is 31 ft 5 in (± 0 ft 0 in) away, clear of the 5 ft 0 in rule, and the area around the battery was seen.","measured_ft":31.395833,"plus_minus_ft":0.0,"threshold_ft":5.0,"comparison":"at_least","subject":"ground with no recorded surface","rule":{"key":"clearances.drive_ft","source":"No public value (docs/04, Public rule values)","placeholder":true}},{"id":"pool_clearance","label":"Distance from a pool","outcome":"pass","reason":"No pool within 10 ft 0 in of the battery, and that whole area was seen.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":10.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.pool_ft","source":"No public value (docs/04, Public rule values)","placeholder":true}},{"id":"opening_clearance","label":"Distance from doors and windows","outcome":"pass","reason":"No door or window within 3 ft 0 in of the battery, and that whole area was seen.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":3.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.opening_ft","source":"IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling. Applying it to garage doors (openings.types) is a demo choice the code does not require","placeholder":false}},{"id":"wall_equipment_above","label":"No box or vent above the battery","outcome":"pass","reason":"Nothing is mounted on the wall above the battery.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":0.0,"comparison":"at_least","subject":null,"rule":{"key":"clearances.wall_equipment_ft","source":"No public value; 0 means only overlap fails","placeholder":true}},{"id":"facing_gap","label":"Open space in front","outcome":"pass","reason":"The gap in front of the battery is 7 ft 2 in (± 0 ft 0 in), clear of the 3 ft 0 in rule.","measured_ft":7.166667,"plus_minus_ft":0.0,"threshold_ft":3.0,"comparison":"at_least","subject":"facing[0]","rule":{"key":"facing.min_ft","source":"Base help page: 3 ft from fences","placeholder":false}},{"id":"headroom","label":"Headroom above","outcome":"pass","reason":"The headroom is 9 ft 0 in (± 0 ft 0 in), clear of the 6 ft 6 in rule.","measured_ft":9.0,"plus_minus_ft":0.0,"threshold_ft":6.5,"comparison":"at_least","subject":"overheads[0]","rule":{"key":"headroom.min_ft","source":"NEC 110.26(A)(3) working-space headroom, applied to the battery as a demo choice","placeholder":true}},{"id":"route_path","label":"Cable route along the wall","outcome":"pass","reason":"The cable runs along continuous, observed wall with nothing blocking it.","measured_ft":null,"plus_minus_ft":null,"threshold_ft":null,"comparison":null,"subject":null,"rule":{"key":"route.crossing","source":"Demo rule: the cable can't cross a door, a garage or a stretch with no wall","placeholder":false}},{"id":"route_length","label":"Cable run length","outcome":"pass","reason":"The cable run is 6 ft 0 in (± 0 ft 0 in), within the 15 ft 0 in confident reach.","measured_ft":6.020833,"plus_minus_ft":0.0,"threshold_ft":20.0,"comparison":"at_most","subject":null,"rule":{"key":"route.max_ft","source":"Base help page: within 20 ft of the meter. Review past 15 ft 0 in: No public value","placeholder":true},"review_threshold_ft":15.0}],"nearest_considered":null,"missing_evidence":[],"objects_not_used":[],"ends":{"left":{"kind":"limit","s_ft":-40.0,"point":[-40.0,0.0],"beyond_reach":true},"right":{"kind":"limit","s_ft":40.0,"point":[40.0,0.0],"beyond_reach":true}},"sweep":[{"wall_id":"w1","segment":0,"start_ft":[-40.0,-22.583334],"outcome":"fail","failing":["route_length"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-22.583334,-22.583334],"outcome":"fail","failing":["ground_surface","route_length"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-22.583333,-17.583333],"outcome":"fail","failing":["ground_surface"],"unsure":["route_length"]},{"wall_id":"w1","segment":0,"start_ft":[-17.520833,-3.895833],"outcome":"fail","failing":["ground_surface"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-3.833333,-3.833333],"outcome":"fail","failing":["ground_surface"],"unsure":["meter_working_space"]},{"wall_id":"w1","segment":0,"start_ft":[-3.8125,-3.604167],"outcome":"fail","failing":["ground_surface","meter_working_space"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[-3.583333,-3.583333],"outcome":"fail","failing":["ground_surface","meter_working_space"],"unsure":["gas_clearance"]},{"wall_id":"w1","segment":0,"start_ft":[-3.523443,1.229167],"outcome":"fail","failing":["gas_clearance","ground_surface","meter_working_space"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[1.25,1.25],"outcome":"fail","failing":["gas_clearance","ground_surface"],"unsure":["meter_working_space"]},{"wall_id":"w1","segment":0,"start_ft":[1.3125,5.94011],"outcome":"fail","failing":["gas_clearance","ground_surface"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[6.0,6.0],"outcome":"unsure","failing":[],"unsure":["gas_clearance"]},{"wall_id":"w1","segment":0,"start_ft":[6.020833,6.416667],"outcome":"pass","failing":[],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[6.479167,14.9375],"outcome":"fail","failing":["ground_surface"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[15.0,20.0],"outcome":"fail","failing":["ground_surface"],"unsure":["route_length"]},{"wall_id":"w1","segment":0,"start_ft":[20.0,20.000001],"outcome":"fail","failing":["ground_surface","route_length"],"unsure":[]},{"wall_id":"w1","segment":0,"start_ft":[20.000001,37.416667],"outcome":"fail","failing":["route_length"],"unsure":[]}],"stats":{"candidates":561,"pass":8,"unsure":1,"fail":552,"elapsed_ms":203.957,"input_sha256":"7d06bf0345a8b325b1286c30b6db640bb6b38b5f5aaa8bfbfd797077c216e75a"}} \ No newline at end of file diff --git a/smoke/unreadable-result.json b/smoke/unreadable-result.json new file mode 100644 index 000000000..7e937b105 --- /dev/null +++ b/smoke/unreadable-result.json @@ -0,0 +1 @@ +{"error":{"code":"unreadable_zip","message":"The bundle has no zip end-of-central-directory record.","path":null}} \ No newline at end of file From 18bf21851c5622b6d965507015dbc0951fa45c31 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:36:52 +0000 Subject: [PATCH 3/3] Add independent corner, ground, uncertainty and schema-compat witnesses A second batch of five witnesses, none sharing a mechanism with the first thirteen: a gap mid-wall with an outside corner 6 ft past it, where the passage behind the gap's line is no wall's behind-strip (a camera position in the opening settles it; without one the pool-clearance asks for a view); a split gap span with only the camera-backed half claimed, the other half still unobserved ground; the route reporting its length as a lower bound while the path check stays unsure over an unknown detour's height; and schema compatibility checked both ways -- scenes with and without camera_pos_ft validate against scene.schema.json, and a result with the two new fields stripped still validates against result.schema.json, so pre-change output is not an error. Final counts, server/: uv run ruff check . and ruff format --check . clean; uv run pytest -q reports 412 passed, 1 xfailed (up from 394 passed at the clean-base baseline; the 13 witnesses of the first batch are the same failures the baseline recorded, plus this batch). /v1/placements smoke receipts from the previous commit stand unchanged. (cherry picked from commit 468cb86b06de4aab56134562e345a5cfac7a6cb1) --- server/tests/test_review_hardening.py | 108 +++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/server/tests/test_review_hardening.py b/server/tests/test_review_hardening.py index 1f82275d8..1737d4ff1 100644 --- a/server/tests/test_review_hardening.py +++ b/server/tests/test_review_hardening.py @@ -5,7 +5,10 @@ standoff credited to the wall carrying the path, count and clock enforcement while a solve lists its battery positions, a zip bundle's directory bounded before metadata is parsed, the meter working space's depth citation, the battery height's citation when it decides backing, -the reason a rejection-forbidden policy gives, and the route length labelled a lower bound. +the reason a rejection-forbidden policy gives, and the route length labelled a lower bound. A +second batch adds independent witnesses -- a gap at a corner, a split span with only one +camera-backed half, the route's lower bound reported with its unknown detour -- and schema +compatibility checked both ways (new scenes and fieldless old results). """ import io @@ -16,7 +19,8 @@ import jsonschema import pytest -from helpers import at_start, observed_band, parsed, rect, run, shared_fixture +from helpers import at_start, check, observed_band, parsed, rect, run, shared_fixture +from shapely.geometry import Point import api from rules import deep_merge, public_rules_dict, rules_from_dict @@ -323,3 +327,103 @@ def test_results_still_validate_against_the_schema() -> None: result = run(shared_fixture(), PUBLIC) schema = json.loads((SCHEMA / "result.schema.json").read_text()) jsonschema.validate(result, schema) + + +# --- 7. independent corner, ground and uncertainty witnesses ------------------------------------ + + +def corner_passage_scene() -> dict[str, Any]: + """A gap mid-wall, then an outside corner 6 ft past it where the chain turns up the yard. + The passage behind the gap's line is no wall's behind-strip, so it is yard; a pool sits + deep behind the gap, past the pool-clearance rule.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1a", "baseline": [[0, 0], [10, 0]], "height_ft": 9, "plus_minus_ft": 0}, + {"id": "w1b", "baseline": [[14, 0], [20, 0]], "height_ft": 9, "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[20, 0], [20, 30]], "height_ft": 9, "plus_minus_ft": 0}, + ] + raw["overheads"] = [] + raw["facing"] = [] + raw["meter"]["wall_id"] = "w1a" + observed_band(raw, "wall", [(-50, 70)]) + observed_band(raw, "ground", [(-50, 70)], out=30) + observed_band(raw, "overhead", [(-50, 70)]) + observed_band(raw, "facing", [(-50, 70)]) + raw["objects"] = [ + { + "type": "pool", + "wall_id": "w1a", + "span_ft": [11, 13], + "source": "tap", + "plus_minus_ft": 0, + "footprint": rect(11, 13, -12, -11), + } + ] + return raw + + +def test_a_camera_position_at_a_corner_gap_claims_its_passage() -> None: + raw = corner_passage_scene() + observed_band(raw, "ground", [(-50, 70)], out=30) + c = at_start(raw, 1.5, "pool_clearance", PUBLIC) + assert c.outcome == UNSURE and c.unsure_cause == "unobserved", c.reason + # The camera stands in the opening; its s resolves by projection past w1's end, at the + # corner the gap runs into. + raw["coverage"]["observed"].append( + {"band": "ground", "span_ft": [10, 14], "out_ft": 30, "camera_pos_ft": [12.0, 1.0]} + ) + c = at_start(raw, 1.5, "pool_clearance", PUBLIC) + assert c.outcome == PASS, c.reason + + +def test_only_the_camera_backed_part_of_a_split_gap_span_is_claimed() -> None: + raw = passage_scene() + observed_band(raw, "ground", [(-40, 10), (10, 12), (12, 14), (14, 40)], out=30) + raw["coverage"]["observed"][-3]["camera_pos_ft"] = [11.0, 1.0] # only [10, 12] claims + scene = parsed(raw, PUBLIC) + unseen = scene.unobserved_ground() + assert not unseen.intersects(Point(11.0, -1.0)), "the camera-backed half must be seen" + assert unseen.intersects(Point(13.0, -1.0)), "the other half is still nobody's view" + + +def test_the_route_reports_its_lower_bound_with_the_unknown_detour() -> None: + raw = shared_fixture() + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [2, 3], + "bottom_ft": 4, + "source": "tap", + "plus_minus_ft": 0, + } + ] + result = run(raw, PUBLIC) + path = check(result, "route_path") + assert (path["outcome"], path["unsure_cause"]) == ("unsure", "unknown_attribute"), path[ + "reason" + ] + assert result["route"]["length_is_lower_bound"] is True + assert result["decision"] == "manual_review" + + +# --- 8. schema compatibility, both ways ---------------------------------------------------------- + + +def test_scenes_with_and_without_camera_positions_validate_against_the_schema() -> None: + schema = json.loads((SCHEMA / "scene.schema.json").read_text()) + jsonschema.validate(shared_fixture(), schema) # the old shape, keyframes or none + jsonschema.validate(corner_passage_scene(), schema) + raw = passage_scene() + observed_band(raw, "ground", [(-40, 10), (10, 14), (14, 40)], out=30) + raw["coverage"]["observed"][-2]["camera_pos_ft"] = [12.0, 1.0] + jsonschema.validate(raw, schema) # the new field on a ground view + + +def test_results_without_the_new_fields_still_validate() -> None: + # Old results are pre-change output, not errors: the new fields are optional in the schema. + schema = json.loads((SCHEMA / "result.schema.json").read_text()) + stripped = json.loads(json.dumps(run(shared_fixture(), PUBLIC))) + del stripped["route"]["length_is_lower_bound"] + del stripped["policy"]["allow_reject"] + jsonschema.validate(stripped, schema)