diff --git a/server/README.md b/server/README.md index 27e30c3d6..5912db53a 100644 --- a/server/README.md +++ b/server/README.md @@ -11,7 +11,7 @@ Needs [uv](https://docs.astral.sh/uv/) 0.11. From `server/`: | `uv sync --locked` | Install | | `uv run uvicorn api:app --host 0.0.0.0 --port 8000` | Serve the API | | `uv run python siteplan.py scene.json plan.svg` | Solve one scene and write its site plan | -| `uv run pytest -q`, `uv run ruff check .`, `uv run ruff format .` | Test and lint (`make server` runs what CI runs) | +| `uv run pytest -q`, `uv run ruff check .`, `uv run ruff format .` | Test and lint (`make server` runs what CI runs). With `CI` set, as GitHub Actions sets it, the property tests run a fixed set of examples; locally they search at random | Without the web layer: `loaded = load_rules()`, then `solve(parse_scene(raw, loaded.rules), loaded)` (from `rules`, `scene` and `solver`). @@ -74,7 +74,8 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON - Wall `baseline` points run **left to right as seen from outside**, and walls are listed in that order. That fixes each wall's outward side. - `s` is feet along the walls from the meter, negative to the left. `span_ft` and coverage use it. - `coverage` lists what the capture actually saw, and whether each end of the walk is a real `limit` or `unexplored`. Anything unseen makes the checks that depend on it UNSURE, so a scene without `coverage` can't pass. -- A limit end (a fence, a corner) doesn't clear the ground beyond it: a pool there still counts. Ground seen past a limit end, a `span_ft` beyond the chain's end, covers both sides of the wall's continued line, so pointing the camera past the end settles it. Past an unexplored end only walking on does. +- A limit end (a fence, a corner) doesn't clear the ground beyond it: a pool there still counts. Ground seen past a limit end, a `span_ft` beyond the chain's end, covers both sides of the wall's continued line, so pointing the camera past the end settles it, also where it lies within reach of an unexplored end at the other side. Past an unexplored end only walking on does. +- Consecutive walls meet when the space between one's end and the next one's start is within both walls' position errors (capped at `sweep.wall_join_ft`, 0.6; walls with zero declared error meet only where their ends coincide); `s` then continues from the first wall's end without that space. A wider space is a gap: a stretch with no wall that no battery backs onto and no cable crosses, and `s` counts its length. The ground behind a gap (a side passage) is yard like the ground in front. A ground view shows it only when the view's span lies within the gap, the camera pointed into the passage; such a view covers both sides of the gap's line out to its `out_ft`. A view that runs along the walls across a gap shows only the front of it. A scene with more than 200 straight wall segments, counted after nearly collinear taps merge, is refused, because the solve grows faster than their square. - Give an object a plan `footprint` when it stands off the wall (a regulator, an AC unit), or clearances are measured to its stretch of wall line. - Omit `plus_minus_ft` and AR-placed positions get `rules.yaml`'s default error for their source plus 0.16 ft per foot along the walls from the meter, from measured ARKit drift. Send your own when you know better. @@ -82,25 +83,25 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON A check is settled when everything it depends on is observed and measured; then it passes or fails on the numbers. What follows is the coverage (`coverage.observed`) and the measurements each check needs, so a capture that supplies exactly this gets a decision on the first upload. Values in brackets are the public rules (`rules.yaml`); private rules may differ, and a request always names the exact span and depth. -Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58, depth D = 1.83). `e` is the wall's position error at the battery's far edge from the meter: the wall's `plus_minus_ft`, or the default for its `source` (tap 0.3, the default when `source` is absent; mesh 0.5; plane 0.75, an untested estimate) plus 0.16 per foot along the walls from the meter. `r` is a check's rule value. A ground band "over [a, b] out to d" means an observed `{"band": "ground", "span_ft": [a, b], "out_ft": d}` (or several that together cover it). A wall band "seen higher than h" means wall entries whose `out_ft`, the height up the face the view reached, exceeds h; an entry without `out_ft` counts as seen to headroom height, which satisfies every check. Heights come from `rules.yaml` and are compared as given: nothing in the rules models a vertical error, so report a height you are sure of. +Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58, depth D = 1.83). `e` is the battery's position error: its wall's error at the battery's far edge from the meter (the wall's `plus_minus_ft`, or the default for its `source`: tap 0.3, the default when `source` is absent; mesh 0.5; plane 0.75, an untested estimate; plus 0.16 per foot along the walls from the meter), plus the meter's error against anything placed in plan (an object's `footprint`, a ground patch, a wall's end), or plus how far the battery can slide against anything placed along the walls in s (a span); a check that compares with both kinds takes the larger. The battery is placed by its offset from the meter, so it moves with the meter against anything placed in plan. Against a span it moves only when it stands round a corner from the meter: moving the meter or a corner between them then slides it by that move's error times the difference between the two walls' directions (0 on the meter's own wall, √2 at a right angle). A hazard with no `footprint` lies on its wall's line at its span, so against one on another wall a clearance also adds, for each wall between the two other than the battery's, that wall's error times one plus its turn against the battery's wall: the wall's line moves, and its corners slide what lies past them. `r` is a check's rule value. A ground band "over [a, b] out to d" means an observed `{"band": "ground", "span_ft": [a, b], "out_ft": d}` (or several that together cover it). A wall band "seen higher than h" means wall entries whose `out_ft`, the height up the face the view reached, exceeds h; an entry without `out_ft` counts as seen to headroom height (`headroom.min_ft`), inclusive: it settles a check needing up to that height, and no more. An explicit `out_ft` must be strictly higher than the height a check needs, so an explicit 6.5 does not settle a check needing 6.5 while an omitted one does. The asymmetry keeps every answer for scenes that omit wall heights unchanged, and the finite default stops an omitted view from settling a check that needs more (an 8 ft battery's back). Heights come from `rules.yaml` and are compared as given: nothing in the rules models a vertical error, so report a height you are sure of. | Check | Reads | Coverage that settles it | | --- | --- | --- | -| `wall_backing` | `walls` | wall band over [s0, s1], seen higher than the battery (3.29) | +| `wall_backing` | `walls`, and `height_ft` when given | the footprint clear of its straight segment's ends by e (within e: UNSURE), and wall band over [s0 − e, s1 + e], seen higher than the battery (3.29); each wall behind the battery that declares `height_ft` must be taller than the battery | | `ground_surface` | `ground` patches | ground over [s0 − e, s1 + e] out to D + e, and a patch of an allowed type under the whole footprint | | `meter_working_space` | `meter` | nothing to observe | | `gas_clearance` (r = 3) | `objects` of type `gas_meter`, with `footprint` when it stands off the wall | ground over [s0 − r − e, s1 + r + e] out to D + r + e, and wall band over the same span, seen higher than headroom height (6.5) | | `ac_clearance` (r = 3), `pool_clearance` (r = 10) | `objects` of type `ac`, `pool` | ground over [s0 − r − e, s1 + r + e] out to D + r + e | -| `battery_clearance` (r = 3), when the scene marks one | `objects` of type `battery`, with `footprint` (a battery already installed; issue #27) | ground over [s0 − r − e, s1 + r + e] out to D + r + e. The cable detours over an existing battery on its route | +| `battery_clearance` (r = 3), when the scene marks one or the rules set it above `gas_ft` | `objects` of type `battery`, with `footprint` (a battery already installed; issue #27) | ground over [s0 − r − e, s1 + r + e] out to D + r + e, and wall band over the same span, seen higher than headroom height (6.5). The cable detours over an existing battery on its route | | `drive_clearance` (r = 5) | `ground` patches of type `drive` | as above | -| `opening_clearance` (r = 3) | `objects` of type `door`, `window`, `garage_door`; `attrs.operable` and `attrs.well` for windows | wall band over [s0 − r − e, s1 + r + e], seen higher than headroom height (6.5), or than `openings.exempt_bottom_above_ft` when the rules set a lower one | -| `wall_equipment_above` (r = 0) | `objects` of type `elec_box`, `vent` | wall band over [s0 − r, s1 + r], seen higher than headroom height (6.5) | -| `facing_gap` (r = 3, from the battery's front) | `facing` measurements | facing band over [s0, s1]. Where no `facing` entry covers it, the band's `out_ft` must exceed D + r (4.83): a walked path proves the space clear out to where the homeowner walked. No `out_ft` means the view reached whatever faces the wall, and it is in `facing` | -| `headroom` (r = 6.5) | `overheads` measurements | overhead band over [s0, s1]. Where no `overheads` entry covers it, the band's `out_ft` (height seen clear) must exceed r. No `out_ft` means seen clear all the way up, as from a tilt-up view of open sky | -| `route_path` | `walls`, openings on the route | wall band from the meter to the battery's near edge, seen higher than the cable's run (`route.height_ft`, 1.0) | +| `opening_clearance` (r = 3) | `objects` of type `door`, `window`, `garage_door`; `attrs.operable` and `attrs.well` for windows | wall band over [s0 − r − e, s1 + r + e], seen higher than headroom height (6.5), or than `openings.exempt_bottom_above_ft` when the rules set a lower one; rules setting it above headroom height are refused at load | +| `wall_equipment_above` (r = 0) | `objects` of type `elec_box`, `vent` | wall band over [s0 − r − e, s1 + r + e], seen higher than headroom height (6.5) | +| `facing_gap` (r = 3, from the battery's front) | `facing` measurements | facing band over [s0 − e, s1 + e], every place the battery may sit. Where no `facing` entry covers it, the band's `out_ft` must exceed D + r (4.83): a walked path proves the space clear out to where the homeowner walked. No `out_ft` means the view reached whatever faces the wall, and it is in `facing` | +| `headroom` (r = 6.5) | `overheads` measurements | overhead band over [s0 − e, s1 + e]. Where no `overheads` entry covers it, the band's `out_ft` (height seen clear) must exceed r. No `out_ft` means seen clear all the way up, as from a tilt-up view of open sky | +| `route_path` | `walls`, openings on the route (the run starts at the meter, so its distance from the wall's line counts toward `route_length`, and every wall it runs along adds its error) | wall band from the meter to the battery's near edge, widened at each end by that end's error (the meter's, the wall's), seen higher than the cable's run (`route.height_ft`, 1.0) | | `route_length` | `walls`, `meter` | nothing to observe | -Errors: a measured value passes only when it clears the rule by more than its error. Objects take the default error for their `source` (tape 0.05, tap 0.3, vlm 1.5, plus 0.16 per foot along the walls for tap and vlm) unless they carry `plus_minus_ft`, and walls likewise (tap 0.3, mesh 0.5, plane 0.75, each plus the same drift). Send a wall's `source` when its line comes from the mesh or detected planes rather than taps, so the error bars match how it was measured; `facing` and `overheads` entries default to the mesh error, 0.5. Every `out_ft` is taken as exact, so report the distance you are sure of (for a walked path, the distance from the wall less your position error). +Errors: a measured value passes only when it clears the rule by more than its error. Objects take the default error for their `source` (tape 0.05, tap 0.3, vlm 1.5, plus 0.16 per foot along the walls for tap and vlm) unless they carry `plus_minus_ft`, and walls likewise (tap 0.3, mesh 0.5, plane 0.75, each plus the same drift). Send a wall's `source` when its line comes from the mesh or detected planes rather than taps, so the error bars match how it was measured; `facing` and `overheads` entries default to the mesh error, 0.5. Every `out_ft` is taken as exact, so report the distance you are sure of (for a walked path, the distance from the wall less your position error). Ground that was seen but has no recorded surface may be a driveway, so within `drive_clearance`'s reach it leaves that check UNSURE as an unknown attribute until a `ground` patch records it, as it does for `ground_surface` under the battery. When a check at the best spot needs ground past an unexplored end, the answer asks to walk past that end, even an end too far for a spot past it to reach. The meter's working space is drawn in front of the wall under it, so its check adds that wall's error to the meter's and the battery's wall's. A battery overlapping it from another wall is measured by how deep the overlap is. The cable run's length is judged over the range it can truly take: moving the meter by d moves both the route's start and the battery, changing the run by d·(v ± u) less up to twice the meter's standoff (v: the difference between the battery's and the meter's wall directions, signed by the battery's side; u: from the wall to the meter), so it can lengthen by up to max(e·|v + u|, e·|v − u| − 2·standoff), √5·e round a right angle with the meter on its wall's line, and shorten only as far as the standoff allows. Ends and corners: @@ -108,7 +109,7 @@ Ends and corners: - `unexplored` means the wall continues. Within cable reach it raises a `past_end` request when no spot passes, because a spot may be round it; nothing else settles it. - `limit` means no usable wall past it. Ground past a limit end still counts for clearances (a pool behind a fence is still a pool): show it by pointing the camera past the end, reported as a ground span beyond the chain's end, which covers both sides of the wall's continued line. -Supplying exactly what a request in `missing_evidence` names settles it: a `coverage.observed` entry with its `band` and `span_ft`, and an `out_ft` at least the request's `out_ft` (ground, facing, overhead and wall requests carry one). +Ground behind a scanned wall that is also on the house side of every scanned wall's line is the house and never counts as unseen; anything else in front of any wall is yard, so a wall running back behind another can't hide the yard in front of it. Seen ground closes gaps narrower than the 0.01 ft tolerance and no wider. A ground request's `out_ft` is the smallest depth, over its span, for which the unseen ground in question would be covered, computed with the same geometry the checks use. Supplying exactly what a request in `missing_evidence` names settles it: a `coverage.observed` entry with its `band` and `span_ft`, and an `out_ft` at least the request's `out_ft` (ground, facing, overhead and wall requests carry one). ## Reading a result @@ -128,4 +129,4 @@ A check passes only when its margin exceeds its error and fails only when it mis ## Rules -`rules.yaml` holds the public values. `placeholder: true` marks a value with no public source (the pool's 10 ft and the driveway's 5 ft among them). Its `demo` policy decides automatically anyway, so the team can test real passes and rejects, and every answer says so: `policy.notice` and the end of `summary` read "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." `HOUSESCAN_POLICY=strict` selects the earlier behaviour instead: `auto_approve: false`, so every would-be pass or reject goes to manual review. At startup the server merges private rules over the public ones when there are any: `HOUSESCAN_PRIVATE_RULES_B64` (the YAML, base64-encoded), else `HOUSESCAN_PRIVATE_RULES` (a path), else `/private/rules.yaml` if it exists. Setting both variables is an error. Private rules replace the policy and drop the demo notice unless they set their own. Every value the private file sets must carry its own `source`, or the server refuses to start; answers show those citations only as "Private rules". Never commit Base's values. Base-derived tests live beside them in `private/tests/`: `uv run pytest ../private/tests -q`. +`rules.yaml` holds the public values. `placeholder: true` marks a value with no public source (the pool's 10 ft and the driveway's 5 ft among them). Its `demo` policy decides automatically anyway, so the team can test real passes and rejects, and every answer says so: `policy.notice` and the end of `summary` read "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." `HOUSESCAN_POLICY=strict` selects the earlier behaviour instead: `auto_approve: false`, so every would-be pass or reject goes to manual review. At startup the server merges private rules over the public ones when there are any: `HOUSESCAN_PRIVATE_RULES_B64` (the YAML, base64-encoded), else `HOUSESCAN_PRIVATE_RULES` (a path), else `/private/rules.yaml` if it exists. Setting both variables is an error. Private rules replace the policy and its demo notice; while any public placeholder is still in effect, `policy.notice` names the checks it decides (and any other placeholder settings), so a partial private file never reads as complete. Every value the private file sets must carry its own `source`, or the server refuses to start; answers show those citations only as "Private rules". Never commit Base's values. Base-derived tests live beside them in `private/tests/`: `uv run pytest ../private/tests -q`. 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/examples/smoke.py b/server/examples/smoke.py index 24905913b..9668f57f9 100644 --- a/server/examples/smoke.py +++ b/server/examples/smoke.py @@ -45,7 +45,9 @@ def redirect_request(self, *args: object) -> None: return None -_KEYED = urllib.request.build_opener(_NoRedirect) +# An explicit empty proxy table: with http_proxy set, a keyed request would otherwise go through +# the proxy, key included. +_KEYED = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect) def post(url: str, body: bytes, key: str | None) -> tuple[int, dict]: diff --git a/server/rules.py b/server/rules.py index 88d78e2a7..0e6113f5a 100644 --- a/server/rules.py +++ b/server/rules.py @@ -15,7 +15,7 @@ from typing import Any, Literal import yaml -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator SERVER_DIR = Path(__file__).resolve().parent PUBLIC_RULES = SERVER_DIR / "rules.yaml" @@ -90,6 +90,13 @@ class Sweep(_Strict): wall_join_ft: Value meter_to_wall_max_ft: Value + @model_validator(mode="after") + def _positive_step(self) -> "Sweep": + # Every solve steps the battery along the wall by this much; zero or less can't advance. + if self.step_ft.value <= 0: + raise ValueError(f"sweep.step_ft must be positive, got {self.step_ft.value}") + return self + class Clearances(_Strict): gas_ft: Value @@ -124,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] @@ -139,6 +156,17 @@ class Route(_Strict): corner_allowance_ft: Value crossing: dict[ObjectType, Effect] + @model_validator(mode="after") + def _confident_within_max(self) -> "Route": + # Past the confident reach a run goes to review, past the maximum it fails; a confident + # reach beyond the maximum would let a run over the maximum pass. + if self.confident_reach_ft.value > self.max_ft.value: + raise ValueError( + f"route.confident_reach_ft ({self.confident_reach_ft.value}) must not exceed " + f"route.max_ft ({self.max_ft.value})" + ) + return self + class Rules(_Strict): policy: Policy @@ -154,6 +182,18 @@ class Rules(_Strict): ground: Ground route: Route + @model_validator(mode="after") + def _exemption_within_the_opening_checks_height(self) -> "Rules": + # The opening check needs the wall seen only up to headroom height; a window above that + # would still count under a higher exemption but could be missed, unseen, above the view. + exempt = self.openings.exempt_bottom_above_ft + if exempt is not None and exempt > self.headroom.min_ft.value: + raise ValueError( + f"openings.exempt_bottom_above_ft ({exempt}) must not exceed headroom.min_ft " + f"({self.headroom.min_ft.value}), the wall height the opening check requires seen" + ) + return self + @dataclass(frozen=True) class LoadedRules: @@ -251,11 +291,67 @@ def load_rules(private_path: Path | None = None) -> LoadedRules: if private is None: return rules_from_dict(data) private_keys = frozenset(overridden_keys(data, private)) - if "notice" not in private.get("policy", {}): - # The public notice says the answers are not under Base's rules; merged under private - # rules it would be false. - data["policy"]["notice"] = None - return rules_from_dict(deep_merge(data, private), ("public", "private"), private_keys) + merged = deep_merge(data, private) + # The public notice says the answers are not under Base's rules, which private rules make + # untrue; but any public placeholder they leave in place still decides answers, so the + # notice names those checks instead of going quiet. + own = private.get("policy", {}).get("notice") + merged["policy"]["notice"] = _mixed_notice(own, _placeholders_left(merged, private_keys)) + return rules_from_dict(merged, ("public", "private"), private_keys) + + +# The check each placeholder rule decides; placeholders not tied to one check are named by key. +PLACEHOLDER_CHECKS = { + "clearances.drive_ft": "drive_clearance", + "clearances.pool_ft": "pool_clearance", + "clearances.wall_equipment_ft": "wall_equipment_above", + "headroom.min_ft": "headroom", + "ground": "ground_surface", + "route.confident_reach_ft": "route_length", + "route.corner_allowance_ft": "route_length", + "route.height_ft": "route_path", +} + + +def _placeholders_left(merged: dict[str, Any], private_keys: frozenset[str]) -> list[str]: + """Dotted keys still marked placeholder that the private file did not set.""" + left: list[str] = [] + + def walk(node: dict[str, Any], path: str) -> None: + if node.get("placeholder") is True: + # A cited value is replaced when the private file sets it; a group (such as ground) + # only when it sets every child that decides something, not just one of them. + if "value" in node: + replaced = path in private_keys + else: + children = [k for k in node if k not in ("source", "placeholder")] + replaced = all( + any(p == f"{path}.{k}" or p.startswith(f"{path}.{k}.") for p in private_keys) + for k in children + ) + if not replaced: + left.append(path) + return + for key, value in node.items(): + if isinstance(value, dict): + walk(value, f"{path}.{key}" if path else key) + + walk(merged, "") + return left + + +def _mixed_notice(own: str | None, left: list[str]) -> str | None: + if not left: + return own + checks = sorted({PLACEHOLDER_CHECKS[k] for k in left if k in PLACEHOLDER_CHECKS}) + other = sorted(k for k in left if k not in PLACEHOLDER_CHECKS) + parts = [] + if checks: + parts.append("these checks still use public placeholder values: " + ", ".join(checks)) + if other: + parts.append("placeholder settings still apply: " + ", ".join(other)) + mixed = "Private rules, but " + "; ".join(parts) + "." + return f"{own} {mixed}" if own else mixed def _public_policy(data: dict[str, Any]) -> dict[str, Any]: diff --git a/server/scene.py b/server/scene.py index c6c995505..e63b8f959 100644 --- a/server/scene.py +++ b/server/scene.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import Any +import shapely from jsonschema import Draft202012Validator from shapely import Geometry, LineString, Point, Polygon, unary_union from shapely.validation import explain_validity @@ -116,6 +117,9 @@ class SceneObject: source: str plus_minus: float geom: Geometry + # Placed by a plan outline (`footprint`), not along the walls in s: the battery moves against + # it with the meter's error, not with the walls' slide. + in_plan: bool = False @property def label(self) -> str: @@ -147,7 +151,10 @@ class Scene: meter_pos: tuple[float, float, float] meter_plus_minus: float meter_piece: Piece - wall_spans: list[tuple[str | None, float, float]] # each wall's stretch of s, as uploaded + # Each uploaded wall segment, numbered as uploaded, before collinear points and walls are + # merged: (wall id, segment index, s start, s end, the wall's declared height_ft or None). + # What a spot's wall_id and segment refer to, and the height a battery backs onto. + wall_spans: list[tuple[str, int, float, float, float | None]] objects: list[SceneObject] # Objects with no footprint past an unexplored end: (index, type, side, s at their middle). # The wall may turn there, so they have no known place and are not measured (issue #42). @@ -155,9 +162,16 @@ 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) # --- chain ----------------------------------------------------------------------------------- @@ -182,10 +196,23 @@ def s_max(self) -> float: def meter_xz(self) -> Point2: return (self.meter_pos[0], self.meter_pos[2]) + def segment_at(self, s: float) -> tuple[str, int]: + """The uploaded wall and its segment under s: both from the same original segment, since + a joined straight piece can span two walls.""" + wid, index, _, _, _ = min(self.wall_spans, key=lambda w: max(w[2] - s, s - w[3], 0.0)) + return wid, index + + def lowest_wall(self, s_lo: float, s_hi: float) -> float | None: + """The lowest declared height of the uploaded walls over [s_lo, s_hi], or None.""" + heights = [ + h + for _, _, a, b, h in self.wall_spans + if h is not None and a < s_hi - EPS and b > s_lo + EPS + ] + return min(heights, default=None) + def wall_at(self, s: float) -> str: - """The id of the uploaded wall under s (a joined straight piece can span two).""" - best = min(self.wall_spans, key=lambda w: max(w[1] - s, s - w[2], 0.0)) - return str(best[0]) + return self.segment_at(s)[0] def piece_at(self, s: float) -> Piece: for p in self.pieces: @@ -228,6 +255,48 @@ def band_polygon(self, s_lo: float, s_hi: float, out: float) -> Geometry: parts.append(_wedge(prev.b, prev.outward, nxt.outward, out)) return unary_union(parts) + def passages(self, out: float) -> list[Geometry]: + """The ground behind each gap's line out to `out`: a side passage between two walls. No + wall faces it and it isn't the house, so it is yard like the ground in front.""" + return [g.rect(g.s0, g.s1, -out, 0.0) for g in self.gaps] + + def outdoor_band(self, out: float, span: tuple[float, float] | None = None) -> Geometry: + """The ground within `out` of the chain (over `span`, the whole chain by default) that can + hold a hazard: in front of it, and behind each gap (house() is still to be taken + out).""" + 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, 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). 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 + 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]))) @@ -260,11 +329,18 @@ def observed_intervals( return merge_intervals( [ (a, b) - for a, b, out in self.observed.get(band, []) - if up_to is None or out is None or out > up_to + 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) ] ) + def _default_reach(self, band: str) -> float: + """How high a view without out_ft reached: headroom height for the wall, which settles + every check that needs no more; all the way up for the other bands.""" + return self.wall_default_ft if band == "wall" else math.inf + def missing( self, band: str, s_lo: float, s_hi: float, up_to: float | None = None ) -> list[tuple[float, float]]: @@ -277,24 +353,50 @@ def missing( def unobserved_ground(self) -> Geometry: if "ground" not in self._cache: - lo, hi = self.pieces[0].s0, self.pieces[-1].s1 - outdoor = self.band_polygon(lo, hi, self.reach_ft) - seen = unary_union( - [ - self.band_polygon(a, b, out or 0.0) - for a, b, out in self.observed.get("ground", []) - ] - ) - # Growing what was seen by half the tolerance closes gaps between observed spans - # narrower than it, as missing() ignores them for the 1D bands. - unseen = outdoor.difference(seen.buffer(COVERAGE_TOLERANCE_FT / 2)) - self._cache["ground"] = unary_union([unseen, *self._unexplored_discs()]) + self._cache["ground"] = self.unobserved_ground_given(self.observed.get("ground", [])) return self._cache["ground"] - def _unexplored_discs(self) -> list[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, 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)) + # Remove floating-point seams where the strips and corner wedges of two polygons meet, + # even when they spur off a larger unseen area: no view can be requested for them, so + # they would leave a check nothing settles. Only seams this thin go, so the growth above + # stays the whole tolerance and a gap wider than it stays unseen. + unseen = unseen.buffer(-SEAM_FT, join_style="mitre").buffer(SEAM_FT, join_style="mitre") + return unary_union([unseen, *self._unexplored_discs(ground)]) + + def house(self) -> Geometry: + """Ground that is surely the house, not yard a hazard could hide on: behind a scanned + wall segment (out to the modelled reach) and on the house side of every scanned wall's + line. A strip behind one wall alone can cross another facade into its yard (a wall + running back parallel behind another), so when in doubt a point is yard: that can only + cost an extra request, where calling yard house could pass unseen ground.""" + if "house" not in self._cache: + behind = unary_union([p.rect(p.s0, p.s1, -self.reach_ft, 0.0) for p in self.walls]) + far = 10 * self.reach_ft + sum(p.s1 - p.s0 for p in self.walls) + for p in self.walls: + behind = behind.intersection(p.rect(p.s0 - far, p.s1 + far, -far, 0.0)) + self._cache["house"] = behind + return self._cache["house"] + + def _unexplored_discs( + self, ground: list[tuple[float, float, float | None]] | None = None + ) -> list[Geometry]: """Round an unexplored end the walls may turn any way, so the straight extension past it - proves nothing: everything within reach of that end counts as unseen, except ground in - front of the scanned walls that was actually observed and the house behind them.""" + proves nothing: everything within reach of that end counts as unseen, except ground that + was actually observed where a view can show it (coverable_span: in front of the scanned + walls and past a limit end) and the house behind the walls. A disc can reach past the + other end; ground seen there past a limit end is as real as anywhere else.""" ends = {"left": self.walls[0].a, "right": self.walls[-1].b} discs = [ Point(ends[side]).buffer(self.reach_ft) @@ -303,15 +405,16 @@ def _unexplored_discs(self) -> list[Geometry]: ] if not discs: return [] + lo, hi = self.coverable_span() seen_in_front = unary_union( [ - self.band_polygon(max(a, self.s_min), min(b, self.s_max), out or 0.0) - for a, b, out in self.observed.get("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(COVERAGE_TOLERANCE_FT / 2) - # Behind a scanned segment is the house itself, not ground a hazard could hide on. - behind = unary_union([p.rect(p.s0, p.s1, -self.reach_ft, 0.0) for p in self.walls]) - known = unary_union([seen_in_front, behind]) + ).buffer(SEEN_GROWTH_FT) + known = unary_union([seen_in_front, self.house()]) return [d.difference(known) for d in discs] def seen_to(self, band: str, s_lo: float, s_hi: float) -> float: @@ -319,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 @@ -342,17 +446,73 @@ def farthest_out(self, region: Geometry) -> float: ] return max((line.distance(p) for p in points), default=0.0) + def view_to_cover( + self, region: Geometry, s_lo: float, s_hi: float + ) -> tuple[float, float, float]: + """The ground view (s_lo, s_hi, out_ft) after which none of `region` is unseen, with the + smallest depth, found by bisection on exactly the geometry the checks use, so capturing + it settles the request in one round. The region's distance from the chain's lines is + not enough: near a corner a point can be close to one line but in front of another + wall, and past an unexplored end only ground in front of the scanned walls counts. A + span ending exactly at a convex corner misses the wedge in front of it, so if no depth + settles the region the span is widened by the tolerance at both ends and tried again.""" + ground = list(self.observed.get("ground", [])) + + area = polygonal(region) + + def covers(a: float, b: float, depth: float) -> bool: + # The checks count any unseen ground within their radius, however small. At 1e-9 sq + # 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, None)])) + return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-12 + + tol = COVERAGE_TOLERANCE_FT + # A region's nearest wall need not be the one it lies in front of (at an inside corner, + # ground just behind one wall's line is in front of the next): last, the span is widened + # over every wall whose strip holds part of the region. + fronted = [s_lo, s_hi] + for p in self.walls: + strip = p.rect(p.s0, p.s1, 0.0, self.reach_ft) + part = shapely.intersection(area, strip, grid_size=1e-9) + if part.area > 1e-9: + us = [p.local((x, z))[0] for x, z in shapely.get_coordinates(part)] + fronted += [max(min(us), p.s0), min(max(us), p.s1)] + spans = ((s_lo, s_hi), (s_lo - tol, s_hi + tol), (min(fronted) - tol, max(fronted) + tol)) + for a, b in spans: + lo, hi = 0.0, max(self.farthest_out(region), 1e-3) + while not covers(a, b, hi) and hi <= 4 * self.reach_ft + 100: + lo, hi = hi, hi * 2 + if not covers(a, b, hi): + continue + for _ in range(30): + mid = (lo + hi) / 2 + if covers(a, b, mid): + hi = mid + else: + lo = mid + return a, b, hi + # No view settles it (a view's span decides what it can reach); the distance from the + # chain's lines is the honest lower bound. + return s_lo, s_hi, self.farthest_out(region) + + def coverable_span(self) -> tuple[float, float]: + """The s range a view can settle: the scanned walls, plus the continuation past a limit + end. It stops at an unexplored end, past which the walls may turn any way.""" + left = self.s_min if self.end_kinds.get("left") == "unexplored" else self.pieces[0].s0 + right = self.s_max if self.end_kinds.get("right") == "unexplored" else self.pieces[-1].s1 + return left, right + def coverable(self, band: str) -> Geometry: """Where observing `band` can settle what is unseen: in front of the scanned walls, and past a limit end. Past an unexplored end the walls may turn any way, so no view settles it; only walking on does (a past_end request).""" key = f"coverable-{band}" if key not in self._cache: - left, right = self.pieces[0], self.pieces[-1] - lo = self.s_min if self.end_kinds.get("left") == "unexplored" else left.s0 - hi = self.s_max if self.end_kinds.get("right") == "unexplored" else right.s1 + lo, hi = self.coverable_span() if band == "ground": - self._cache[key] = self.band_polygon(lo, hi, self.reach_ft) + self._cache[key] = _minus(self.outdoor_band(self.reach_ft, (lo, hi)), self.house()) else: self._cache[key] = self.wall_line(lo, hi).buffer(1e-6, cap_style="flat") return self._cache[key] @@ -383,6 +543,13 @@ def unobserved_wall(self, up_to: float | None = None) -> Geometry: return self._cache[key] +def polygonal(geom: Geometry) -> Geometry: + """Only the areas of a geometry: overlays can leave line or point fragments beside them, + which an area overlay refuses as mixed-dimension input.""" + parts = [g for g in getattr(geom, "geoms", [geom]) if g.geom_type.endswith("Polygon")] + return unary_union(parts) if parts else Polygon() + + def merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]: out: list[tuple[float, float]] = [] for a, b in sorted(intervals): @@ -432,6 +599,21 @@ def _wedge(v: Point2, n1: Point2, n2: Point2, radius: float) -> Polygon: COLLINEAR_FT = 0.05 +def _uploaded_spans( + wid: str, uploaded: list[Point2], s0: float, s1: float, height: float | None +) -> list[tuple[str, int, float, float, float | None]]: + """Each uploaded segment of a wall with its stretch of s, numbered as uploaded. The segments + are laid out by their lengths, scaled to the merged wall's [s0, s1], since merging points + within COLLINEAR_FT of a line shortens the path by a hair.""" + lengths = [_norm(_sub(b, a)) for a, b in itertools.pairwise(uploaded)] + scale = (s1 - s0) / sum(lengths) + out, s = [], s0 + for i, length in enumerate(lengths): + out.append((wid, i, s, s + length * scale, height)) + s += length * scale + return out + + def _merge_collinear(pts: list[Point2], tol: float, path: str) -> list[Point2]: """Drop baseline points that lie on the straight line between their neighbours (within the wall's error), so a tap in the middle of a straight wall is not a corner the battery can't @@ -501,7 +683,15 @@ def _outward(along: Point2) -> Point2: def _xz(point: list[float]) -> Point2: - return (float(point[0]), float(point[1])) + return (_flush(point[0]), _flush(point[1])) + + +def _flush(v: float) -> float: + """A plan coordinate, with anything below the overlay grid (OVERLAY_GRID_FT) read as 0. A + subnormal one (7.8e-314 ft in a wall's end) made GEOS drop the ground behind a gap from the + unseen area (test_final_opus_review).""" + v = float(v) + return 0.0 if abs(v) < OVERLAY_GRID_FT else v def _span(value: list[float], path: str) -> tuple[float, float]: @@ -526,13 +716,76 @@ def _error(item: dict[str, Any], default: float) -> float: return float(item["plus_minus_ft"]) if "plus_minus_ft" in item else default +# The most straight wall segments a scene may have, counted after nearly collinear taps merge. +# The solve grows faster than their square (house() and the unseen ground intersect every +# segment's strips): zigzag walls took 0.37 s at 200 segments, 2.7 s at 398 and 19-29 s at 798 +# (final review probe r_time2; this machine under load). A house's outline needs far fewer; the +# schema allows 50 walls. +MAX_WALL_PIECES = 200 + +# Grid the ground overlays snap to, far below any measurement. Unsnapped, subtracting the house +# from the band in front of two exact walls meeting at a 51 degree corner gave a polygon with a +# hole outside its shell, and the next overlay raised "side location conflict" +# (test_within_errors). +OVERLAY_GRID_FT = 1e-9 + + +def _minus(a: Geometry, b: Geometry) -> Geometry: + """a less b, snapped. Only their areas: a snapped overlay refuses the line left where two + walls' strips meet at a corner they don't quite share.""" + return shapely.difference(polygonal(a), polygonal(b), grid_size=OVERLAY_GRID_FT) + + +# Unseen ground thinner than twice this is a seam between polygons (float noise), not a gap. +SEAM_FT = 1e-4 + +# Seen ground grows by this on each side, closing gaps under 2 * (this + SEAM_FT): exactly the +# coverage tolerance, so the growth and the seam removal together never close a wider gap. +SEEN_GROWTH_FT = 0.005 - SEAM_FT + # Coverage gaps narrower than this (1/8 in) are float noise between a capture's rounded spans and # the unrolled walls, 25 times smaller than the smallest default position error (tape, 0.05 ft). COVERAGE_TOLERANCE_FT = 0.01 +# How far past a gap's ends a view may start or end and still count as pointed into the passage: +# a request's span can be widened by the tolerance when it is computed. +GAP_VIEW_SLACK_FT = 2 * COVERAGE_TOLERANCE_FT + # 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. @@ -595,6 +848,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N # Chain the walls. s is provisional (0 at the chain's left end) until the meter is placed. join_tol = rules.sweep.wall_join_ft.value pieces: list[Piece] = [] + spans: list[tuple[str, int, float, float, float | None]] = [] wall_ids: set[str] = set() s = 0.0 prev_end: Point2 | None = None @@ -614,10 +868,16 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N }[wall.get("source", "tap")] wall_err = _error(wall, default.value) wall_drift = 0.0 if "plus_minus_ft" in wall else drift + uploaded = pts pts = _merge_collinear(pts, COLLINEAR_FT, f"/walls/{wi}/baseline") if prev_end is not None: gap = _norm(_sub(pts[0], prev_end)) - if gap > join_tol: + # Two walls meet when the space between their ends is within both walls' errors (the + # taps may simply disagree), capped at sweep.wall_join_ft. A wider space is a real + # gap: a stretch with no wall, which s counts. Exact walls keep any gap above float + # noise (EPS); coverage rounding says nothing about where walls end. + meets = min(join_tol, max(prev_err + wall_err, EPS)) + if gap > meets: along = ( (pts[0][0] - prev_end[0]) / gap, (pts[0][1] - prev_end[1]) / gap, @@ -640,6 +900,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N ) ) s += gap + wall_s0 = s for i in range(len(pts) - 1): length = _norm(_sub(pts[i + 1], pts[i])) if length < 1e-6: @@ -661,6 +922,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N ) ) s += length + spans += _uploaded_spans(wid, uploaded, wall_s0, s, wall.get("height_ft")) prev_end, prev_err, prev_drift = pts[-1], wall_err, wall_drift # Place the meter: s = 0 at its projection onto its own wall. @@ -698,8 +960,17 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N ) shift = best[2] pieces = [replace(p, s0=p.s0 - shift, s1=p.s1 - shift) for p in pieces] - wall_spans = [(p.wall_id, p.s0, p.s1) for p in pieces if p.kind == "wall"] + wall_spans = [(wid, i, a - shift, b - shift, h) for wid, i, a, b, h in spans] pieces = _join_straight_walls(pieces) + walls_n = sum(1 for p in pieces if p.kind == "wall") + if walls_n > MAX_WALL_PIECES: + raise SceneError( + "/walls", + f"the walls have {walls_n} straight wall segments, more than the {MAX_WALL_PIECES} a " + "house's outline needs; the solve grows faster than their square, so a scene this " + "fragmented is refused before it starts (taps within 0.05 ft of a straight line are " + "already merged)", + ) meter_piece = next(p for p in pieces if p.kind == "wall" and p.s0 - EPS <= 0 <= p.s1 + EPS) # How far out from the walls the outdoor area matters: the largest clearance, plus the @@ -716,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( @@ -747,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, @@ -759,9 +1038,11 @@ 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, + wall_default_ft=rules.headroom.min_ft.value, ) # What each end is decides where an object without a footprint can be placed, so it is read @@ -790,7 +1071,8 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N # Set aside when nothing of a mark with length remains on the scanned wall (its span # can start exactly at the end, leaving a single point); a point mark stays. if hi < lo - EPS or (span[1] - span[0] > EPS and hi - lo <= EPS): - side = "right" if span[0] > scene.s_max else "left" + # A span starting exactly at the right end is past it too. + side = "right" if span[0] >= scene.s_max - EPS else "left" scene.set_aside.append((i, obj["type"], side, (span[0] + span[1]) / 2)) continue geom = scene.wall_line(lo, max(lo, hi)) @@ -812,6 +1094,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N + (drift * max(abs(span[0]), abs(span[1])) if obj["source"] != "tape" else 0), ), geom=geom, + in_plan="footprint" in obj, ) ) @@ -840,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/siteplan.py b/server/siteplan.py index 1173e1da3..0e8153c05 100644 --- a/server/siteplan.py +++ b/server/siteplan.py @@ -92,6 +92,7 @@ "vent": "Vent", "downspout": "Downspout", "pool": "Pool", + "battery": "Battery", } diff --git a/server/solver.py b/server/solver.py index 3d47b7757..dca57201a 100644 --- a/server/solver.py +++ b/server/solver.py @@ -19,7 +19,8 @@ from functools import partial from typing import Any -from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union +import shapely +from shapely import Geometry, LineString, Point, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value from scene import ( @@ -30,6 +31,7 @@ Scene, SceneObject, merge_intervals, + polygonal, subtract_intervals, ) from units import format_ft_in @@ -139,6 +141,26 @@ def _is_private(keys: tuple[str, ...], private_keys: frozenset[str]) -> bool: _CIRCUMSCRIBE = 1 / math.cos(math.pi / (4 * _BUFFER_SEGMENTS)) +# GEOS 3.13 can return an empty intersection for large polygons that share boundaries (an +# unseen area inside the coverable one gave 0 instead of 172 sq ft); snapping the overlay to a +# grid this fine keeps it exact to far below any measurement. +_OVERLAY_GRID = 1e-9 + + +def _meet(a: Geometry, b: Geometry) -> Geometry: + return shapely.intersection(a, b, grid_size=_OVERLAY_GRID) + + +def _overlap_depth(a: Geometry, b: Geometry) -> float: + """How deep two shapes overlap, as a lower bound on how far one must move to clear the other: + twice the radius of the largest circle inside their intersection (the circle and its + translate overlap until the move is at least its diameter). 0 when they only touch.""" + overlap = _meet(polygonal(a), polygonal(b)) + if overlap.area <= _MEASURE_EPS: + return 0.0 + return 2 * shapely.maximum_inscribed_circle(overlap, tolerance=1e-6).length + + def _within(fp: Polygon, radius: float) -> Geometry: """Every point within `radius` of the footprint, and slightly more at the corners.""" return fp.buffer(radius * _CIRCUMSCRIBE, quad_segs=_BUFFER_SEGMENTS) if radius > 0 else fp @@ -179,7 +201,8 @@ def reach_outcome(length: float, error: float, confident: float, maximum: float) maximum, UNSURE in between or on either line.""" if (length - error) - maximum > EPS: return FAIL - if confident - (length + error) > EPS: + # Clear of both lines: the rules keep confident <= maximum, and this holds regardless. + if min(confident, maximum) - (length + error) > EPS: return PASS return UNSURE @@ -211,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 @@ -231,22 +258,30 @@ 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 + # The meter may stand off its wall's line (up to sweep.meter_to_wall_max_ft); the cable + # starts at the meter, so that distance is part of every route. + mx, mz = scene.meter_xz + wx, wz = scene.point_at(0.0) + self.meter_offset = math.hypot(mx - wx, mz - wz) objs = scene.objects self.gas = [o for o in objs if o.type == "gas_meter"] self.ac = [o for o in objs if o.type == "ac"] self.batteries = [o for o in objs if o.type == "battery"] - # Without an existing battery, its check would only ask for the ground the AC check - # already needs (same band, and a radius no larger), so it is left out and answers for - # scenes without one stay as they were. + # A battery stands on the ground against the wall, so its check reads both bands. Without + # an existing battery it would only ask for what the gas check already needs (the same + # two bands, to the same height, with a radius no larger), so it is left out then. self.battery_check = bool(self.batteries) or ( - r.clearances.battery_ft.value > r.clearances.ac_ft.value + r.clearances.battery_ft.value > r.clearances.gas_ft.value ) self.pool = [o for o in objs if o.type == "pool"] self.openings = [o for o in objs if o.type in r.openings.types] @@ -259,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 @@ -276,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.band_polygon(scene.pieces[0].s0, scene.pieces[-1].s1, 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: @@ -333,8 +386,44 @@ def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: "of the wall or a stretch with no wall), so the battery can't sit flush." ) return c + # The battery sits within the wall's error of [s0, s1]; if that could put an edge past + # the segment's end, it may not be flush. + e = piece.plus_minus + if s0 - e < piece.s0 - EPS or s1 + e > piece.s1 + EPS: + c.outcome, c.unsure_cause = UNSURE, "margin" + c.reason = ( + "The footprint ends within the wall's measurement error of its straight segment's " + "end, so the battery may not sit flush." + ) + return c up_to = self.wall_height["backing"] - missing = self.scene.missing("wall", s0, s1, up_to) + # A declared wall height, taken as given (as out_ft heights are), against the battery's. + # The battery sits within the wall's error of [s0, s1], so a lower wall surely behind it + # fails, and one that only might be (within the error) leaves it UNSURE. + e = piece.plus_minus + sure = self.scene.lowest_wall(s0 + e, s1 - e) if s1 - s0 > 2 * e else None + maybe = self.scene.lowest_wall(s0 - e, s1 + e) + for height, surely in ((sure, True), (maybe, False)): + if height is None or at_least(height, 0.0, self.H) == PASS: + continue + fails = surely and at_least(height, 0.0, self.H) == FAIL + 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 = ( + f"The wall {'behind' if surely else 'within error of'} the battery is " + f"{ft(height)} tall, not taller than the battery's {ft(self.H)}." + ) + return c + # ...and the wall behind every such position must have been seen. + missing = self.scene.missing("wall", s0 - e, s1 + e, up_to) if missing: c.outcome, c.unsure_cause = UNSURE, "unobserved" c.missing = [View("wall", a, b, _above(up_to)) for a, b in missing] @@ -414,18 +503,42 @@ def _missing( ] out = [] for view, unseen in parts: - region = unseen.intersection(within).intersection(self.scene.coverable(view)) + region = _meet(unseen, within) + if view == "ground": + # Clipping can leave a line where edges touch (no ground to show), which the next + # overlay refuses beside an area (the real sim scene after the snapped overlays). + region = polygonal(region) + region = _meet(region, self.scene.coverable(view)) + if view == "ground": + # Behind a gap only a view pointed into the passage shows the ground (a span + # within the gap, Scene.view_polygon), so that part is asked for on its own. + for gap, passage in zip( + self.scene.gaps, self.scene.passages(self.scene.reach_ft), strict=True + ): + behind = polygonal(_meet(region, passage)) + if behind.area > 1e-12: + a, b, depth = self.scene.view_to_cover(behind, gap.s0, gap.s1) + out.append(View("ground", a, b, _up(depth))) + region = shapely.difference(region, passage, grid_size=_OVERLAY_GRID) # Where that meets an unexplored end exactly, a sliver with no length along the wall # is left; a request for it could never be settled. - extents = [ - e - for part in getattr(region, "geoms", [region]) - if (e := self.scene.s_extent(part)) and e[1] - e[0] >= COVERAGE_TOLERANCE_FT - ] + extents = [] + for part in getattr(region, "geoms", [region]): + e = self.scene.s_extent(part) + if not e: + continue + if e[1] - e[0] >= COVERAGE_TOLERANCE_FT: + extents.append(e) + elif part.area > COVERAGE_TOLERANCE_FT**2: + # A real area whose points all map to one s: the wedge in front of a convex + # corner. A view spanning the corner covers it. + mid = (e[0] + e[1]) / 2 + extents.append((mid - COVERAGE_TOLERANCE_FT, mid + COVERAGE_TOLERANCE_FT)) if extents: a, b = min(a for a, _ in extents), max(b for _, b in extents) if view == "ground": - depth = _up(self.scene.farthest_out(region)) + a, b, depth = self.scene.view_to_cover(region, a, b) + depth = _up(depth) else: depth = None if up_to is None else _above(up_to) out.append(View(view, a, b, depth)) @@ -446,23 +559,30 @@ def check_clearance( rule: Value, piece: Piece, fp: Polygon, - items: list[tuple[str, Geometry, float, bool | None]], + items: list[tuple[str, Geometry, float, bool | None, bool]], band: str, noun: str, wall_height: float | None = None, + along_err: float | None = None, + unknown_reason: str | None = None, ) -> Check: """Minimum plan distance from the footprint to each item. `items` holds (label, geometry, - error, counts) where counts None means an unknown attribute decides whether it applies. + error, counts, in plan) where counts None means an unknown attribute decides whether it + applies, and in plan says whether the item was placed in plan (the battery moves against + it by its full position error) or along the walls (by its error along them). `wall_height` is how high up the wall the face must have been seen, for a check that reads the wall band.""" t = rule.value c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") + if along_err is None: + along_err = piece.plus_minus worst_key: tuple[int, float] | None = None - for name, geom, err, counts in items: - if counts is False: + for name, geom, err, counts, in_plan in items: + # Nothing to measure to (no ground left unrecorded): its distance would be NaN. + if counts is False or geom.is_empty: continue d = fp.distance(geom) - e = err + piece.plus_minus + e = err + (piece.plus_minus if in_plan else along_err + self._chain_to(piece, geom)) outcome = at_least(d, e, t) cause = "margin" if counts is None and outcome != PASS: @@ -490,7 +610,7 @@ def check_clearance( ) if c.outcome == UNSURE: if c.unsure_cause == "unknown_attribute": - c.reason = ( + c.reason = unknown_reason or ( f"{c.subject} is within {ft(t)} of the battery, and whether the rule applies " "to it (for example whether a window opens) was not recorded." ) @@ -534,8 +654,9 @@ def _opening_counts(self, o: SceneObject) -> bool | None: return None return None if exempt is None else not exempt - def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: - """Wall-mounted boxes and vents directly above the battery, measured along the wall.""" + def check_along_wall(self, along_err: float, s0: float, s1: float) -> Check: + """Wall-mounted boxes and vents directly above the battery, measured along the wall, so + against the battery's error along the walls (_errors).""" rule = self.r.clearances.wall_equipment_ft t = rule.value c = Check( @@ -551,7 +672,9 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: worst_key: tuple[int, float] | None = None for o in self.equipment: gap = max(o.span[0] - s1, s0 - o.span[1]) - e = o.plus_minus + # Both ends of the gap carry error: the box's own, and the battery's position, which + # is known only to the wall's error. + e = o.plus_minus + along_err outcome = at_least(gap, e, t) key = (_SEVERITY[outcome], -(gap - e)) if worst_key is None or key > worst_key: @@ -561,7 +684,9 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: c.reason = f"{c.subject} is on the wall directly above the battery." return c up_to = self.wall_height["above"] - missing = self.scene.missing("wall", s0 - t, s1 + t, up_to) + # Wherever the battery may stand along the wall (the strict property's case: a meter + # move slid it 0.011 ft onto unseen wall). + missing = self.scene.missing("wall", s0 - t - along_err, s1 + t + along_err, up_to) if c.outcome == UNSURE: c.unsure_cause = "margin" c.reason = f"{c.subject} ends within measurement error of the battery's edge." @@ -583,14 +708,25 @@ 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", ) d = fp.distance(self.ws_poly) - if d <= EPS: + if d <= EPS and piece.s0 - EPS <= 0.0 <= piece.s1 + EPS: + # On the meter's own segment the battery can only slide along the wall, so the + # overlap along s is how far it must move: the exact measure there. d = min(0.0, max(self.ws_span[0] - s1, s0 - self.ws_span[1])) - e = self.scene.meter_plus_minus + piece.plus_minus + elif d <= EPS: + # From another wall s says nothing about the overlap (it was measured as 0, a tie that + # never failed); its depth does. + d = -_overlap_depth(fp, self.ws_poly) + # The working space is drawn in front of the wall under it, so that wall's error counts + # as well as the meter's and the battery's: from another wall the battery doesn't move + # with it. + lo, hi = self.ws_span + e = self.scene.meter_plus_minus + piece.plus_minus + self._walls_between(piece, lo, hi) c.measured, c.plus_minus = d, e c.outcome = at_least(d, e, 0.0) box = f"{ft(ws.width_ft.value)} wide by {ft(ws.depth_ft.value)} deep" @@ -622,7 +758,10 @@ def check_measured( """Facing gap or headroom: the smallest measurement over the battery's stretch of wall.""" t = rule.value c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") - missing = self.scene.missing(band, s0, s1) + # The battery may sit up to the wall's error either side of [s0, s1], so the space in + # front of or above every such position must have been seen. + lo, hi = s0 - wall_error, s1 + wall_error + missing = self.scene.missing(band, lo, hi) worst_key: tuple[int, float] | None = None # The lowest value surely under the battery, and whether the deciding entry only might be. surely: tuple[float, float] | None = None @@ -662,7 +801,12 @@ def check_measured( elif missing: c.outcome, c.unsure_cause = UNSURE, "unobserved" c.reason = f"The {noun} over the battery's stretch of wall was not measured everywhere." - elif (seen := self._seen_clear(band, entries, s0, s1)) <= t + subtract: + # The request must settle the check in one round, so it also names the stretches + # that were seen, but not far enough where no measurement covers them. + missing = merge_intervals( + missing + self._too_shallow(band, entries, lo, hi, t + subtract) + ) + elif (seen := self._seen_clear(band, entries, lo, hi)) <= t + subtract: # A view (a walked path, a tilt-up frame) proves the space clear only as far as it # reached; where nothing was measured, that is all that is known. c.outcome, c.unsure_cause = UNSURE, "unobserved" @@ -670,7 +814,7 @@ def check_measured( f"The {noun} was seen clear only to {ft(seen - subtract)} over part of the " f"battery's stretch of wall; the rule needs more than {ft(t)}." ) - missing = [(s0, s1)] + missing = [(lo, hi)] elif c.measured is None: c.reason = f"Nothing limits the {noun} over the battery's stretch of wall." else: @@ -680,6 +824,20 @@ def check_measured( c.missing = [View(band, a, b, _above(t + subtract)) for a, b in missing] return c + def _too_shallow( + self, band: str, entries: list[Measured], s0: float, s1: float, need: float + ) -> list[tuple[float, float]]: + """Stretches of [s0, s1] no measurement covers that were seen, but clear only to `need` + or less.""" + measured = merge_intervals([m.span for m in entries]) + free = subtract_intervals((s0, s1), measured) + seen = [ + part + for a, b in free + for part in subtract_intervals((a, b), self.scene.missing(band, a, b)) + ] + return [(a, b) for a, b in seen if self.scene.seen_to(band, a, b) <= need] + def _seen_clear(self, band: str, entries: list[Measured], s0: float, s1: float) -> float: """How far clear the band was seen over the parts of [s0, s1] no measurement covers: infinite where a view saw it all (no `out_ft`), and nothing needed where measurements @@ -743,15 +901,57 @@ 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 - e = self.scene.meter_plus_minus + piece.plus_minus + # 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 + # one-sidedly below (_meter_on_route). + e = piece.plus_minus + self._walls_between(piece, lo, hi) + # The battery's end of the route slides along the walls when the meter or a corner moves + # (_slide); spans in s near it are judged with that too. + slide = self._slide(piece, s0) + maybe_blockers: list[str] = [] for o in self.route_objects: - if path_line is None or min(hi, o.span[1]) - max(lo, o.span[0]) <= EPS: + if path_line is None: + continue + # The object's ends carry its error and the route's ends the meter's, the wall's and + # the battery's slide, so a crossing is definite only when the overlap survives all + # of them, and possible while any overlap is within them. + tol = o.plus_minus + self.scene.meter_plus_minus + piece.plus_minus + slide + overlap = min(hi, o.span[1]) - max(lo, o.span[0]) + if overlap + tol <= EPS: continue + 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: + # 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) + crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": "review"}) continue crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": effect}) effects.append(effect) @@ -771,16 +971,33 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C extra = min(options) if extra > EPS: detours.append({"subject": o.label, "extra_ft": extra}) + if not definite: + # A detour that may not be needed: counted, and its size added to + # the error so the run can still come out without it. + e += extra corners = sum( 1 for a, b in zip(self.scene.pieces, self.scene.pieces[1:], strict=False) if a.kind == "wall" and b.kind == "wall" and lo + EPS < a.s1 < hi - EPS ) length = ( - (hi - lo) + corners * r.corner_allowance_ft.value + sum(d["extra_ft"] for d in detours) + self.meter_offset + + (hi - lo) + + corners * r.corner_allowance_ft.value + + sum(d["extra_ft"] for d in detours) ) route_height = self.wall_height["route"] - missing = self.scene.missing("wall", lo, hi, route_height) + # Each end of the route may lie within its own error: the meter's at the meter, the + # wall's at the battery. Unseen wall there could hold a blocker, so it must be seen. + meter_end = self.scene.meter_plus_minus + near_end = piece.plus_minus + slide + lo_err, hi_err = (meter_end, near_end) if lo >= -EPS else (near_end, meter_end) + missing = self.scene.missing( + "wall", + max(lo - lo_err, self.scene.s_min), + min(hi + hi_err, self.scene.s_max), + route_height, + ) path = Check( "route_path", @@ -797,6 +1014,13 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C blockers = [x["subject"] for x in crossings if x["effect"] == "fail"] path.subject = blockers[0] path.reason = f"The cable would have to cross {', '.join(blockers)}." + elif maybe_blockers: + path.outcome, path.unsure_cause = UNSURE, "margin" + path.subject = maybe_blockers[0] + path.reason = ( + f"{path.subject} ends within measurement error of the cable's route, so it may be " + "in the way." + ) elif "review" in effects: path.outcome, path.unsure_cause = UNSURE, "rule_requires_review" path.subject = next(x["subject"] for x in crossings if x["effect"] == "review") @@ -823,7 +1047,13 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C else: path.reason = "The cable runs along continuous, observed wall with nothing blocking it." - outcome = reach_outcome(length, e, r.confident_reach_ft.value, r.max_ft.value) + # The meter's error changes the run one-sidedly (_meter_on_route): the check judges the + # range the run can truly lie in, reported as its middle +/- half its width. + up, down = self._meter_on_route(piece) + shortest, longest = length - e - down, length + e + up + e += up + measured, spread = (shortest + longest) / 2, (longest - shortest) / 2 + outcome = reach_outcome(measured, spread, r.confident_reach_ft.value, r.max_ft.value) # threshold_ft is the maximum, the line past which the run fails (at_most). A run past # the confident reach but under the maximum is UNSURE, so the cited rule names both lines # and is a placeholder if either is. @@ -840,19 +1070,25 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C "", "route.max_ft", rule, - measured=length, - plus_minus=e, + measured=measured, + plus_minus=spread, threshold=r.max_ft.value, review_threshold=cr.value, comparison="at_most", cites=("route.confident_reach_ft",), ) - run = f"{ft(length)} (± {ft(e)})" + run = ( + f"{ft(length)} (± {ft(e)})" + if abs(measured - length) <= EPS + else f"between {ft(shortest)} and {ft(longest)}" + ) confident = cr.value if reach.outcome == FAIL: reach.reason = f"The cable run is {run}, over the {ft(r.max_ft.value)} maximum." elif reach.outcome == UNSURE: - near_a_line = any(abs(length - line) <= e + EPS for line in (confident, r.max_ft.value)) + near_a_line = any( + abs(measured - line) <= spread + EPS for line in (confident, r.max_ft.value) + ) if near_a_line: reach.unsure_cause = "margin" reach.reason = ( @@ -871,9 +1107,12 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C outcome=worst([path.outcome, reach.outcome]), length=length, plus_minus=e, - polyline=self.scene.polyline(0.0, near), + # From the meter itself, which may stand off its wall's line. + polyline=([self.scene.meter_xz] if self.meter_offset > EPS else []) + + self.scene.polyline(0.0, near), detours=detours, crossings=crossings, + length_lower_bound=bool(unknown), ) return route, path, reach @@ -883,16 +1122,17 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: r = self.r s1 = s0 + self.W fp = self.footprint(wall_piece, s0) - # Checks see the wall's error at the battery's far edge from the meter, where AR drift - # is largest. - piece = replace( - wall_piece, plus_minus=wall_piece.error_at(max(abs(s0), abs(s1))), drift=0.0 - ) + # Checks see the battery's position error (_error): its wall's at its far edge from the + # meter, where AR drift is largest, and the meter's. The route and the meter's working + # space count the meter's error themselves, so they take the wall's alone. + plan_err, along_err = self._errors(wall_piece, s0) + piece = replace(wall_piece, plus_minus=max(plan_err, along_err), drift=0.0) + on_wall = replace(piece, plus_minus=wall_piece.error_at(max(abs(s0), abs(s1)))) c = r.clearances checks = [ self.check_backing(piece, s0, s1), self.check_ground(piece, fp), - self.check_meter_space(piece, fp, s0, s1), + self.check_meter_space(on_wall, fp, s0, s1), self.check_clearance( "gas_clearance", "Distance from gas equipment", @@ -900,11 +1140,12 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.gas_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True) for o in self.gas], + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.gas], # Gas meters hang on the wall as well as standing on the ground. "ground+wall", "gas meter or pipe", self.wall_height["above"], + along_err=along_err, ), self.check_clearance( "ac_clearance", @@ -913,9 +1154,10 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.ac_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True) for o in self.ac], + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.ac], "ground", "AC unit", + along_err=along_err, ), *( [ @@ -926,9 +1168,11 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.battery_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True) for o in self.batteries], - "ground", + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.batteries], + "ground+wall", "battery", + self.wall_height["above"], + along_err=along_err, ) ] if self.battery_check @@ -942,11 +1186,28 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: piece, fp, [ - (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True) - for g in self.drives + *( + (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True, True) + for g in self.drives + ), + # Seen ground with no recorded surface may be a driveway: unknown, as + # ground_surface treats it (final review: it counted as no driveway). + ( + "ground with no recorded surface", + self._unclassified, + self._ground_error, + None, + True, + ), ], "ground", "drivable surface", + unknown_reason=( + f"Ground within {ft(c.drive_ft.value)} of the battery was seen but its surface " + "was not recorded, so it may be a driveway. Recording what that ground is (a " + "`ground` patch with its type) settles it." + ), + along_err=along_err, ), self.check_clearance( "pool_clearance", @@ -955,9 +1216,10 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.pool_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True) for o in self.pool], + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.pool], "ground", "pool", + along_err=along_err, ), self.check_clearance( "opening_clearance", @@ -966,12 +1228,16 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.opening_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, self._opening_counts(o)) for o in self.openings], + [ + (o.label, o.geom, o.plus_minus, self._opening_counts(o), o.in_plan) + for o in self.openings + ], "wall", "door or window", self.wall_height["openings"], + along_err=along_err, ), - self.check_along_wall(piece, s0, s1), + self.check_along_wall(along_err, s0, s1), self.check_measured( "facing_gap", "Open space in front", @@ -985,7 +1251,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: "gap in front of the battery" if r.facing.measured_from == "battery_front" else "gap from the wall to whatever faces it", - piece.plus_minus, + along_err, ), self.check_measured( "headroom", @@ -998,19 +1264,83 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: s1, 0.0, "headroom", - piece.plus_minus, + along_err, ), ] - route, path, reach = self.route_for(piece, s0, s1) + route, path, reach = self.route_for(on_wall, s0, s1) checks += [path, reach] return Candidate(wall_piece, s0, s1, fp, checks, route, worst([x.outcome for x in checks])) + def _chain_to(self, piece: Piece, geom: Geometry) -> float: + """How far a hazard placed along the walls (no outline: it lies on its wall's line at its + s) can move against a battery on `piece`, from the walls between them other than the + battery's own: each can move its line by its error, and its corners slide what lies past + them along the chain by the error times the turn against the battery's wall. A gas meter + on another wall's line moved 0.17 ft when that wall's ends moved within 0.3 ft, which its + check left out (test_within_errors).""" + extent = self.scene.s_extent(geom) + if extent is None: + return 0.0 + lo, hi = min(piece.s0, extent[0]), max(piece.s1, extent[1]) + total = 0.0 + for p in self.scene.walls: + a, b = max(p.s0, lo), min(p.s1, hi) + if b - a <= EPS or (abs(p.s0 - piece.s0) <= EPS and abs(p.s1 - piece.s1) <= EPS): + continue + turn = math.hypot(piece.along[0] - p.along[0], piece.along[1] - p.along[1]) + total += p.error_at(max(abs(a), abs(b))) * (1 + turn) + return total + + def _walls_between(self, piece: Piece, lo: float, hi: float) -> float: + """The summed errors of the wall segments other than `piece` over [lo, hi], each at its + end furthest from the meter within the stretch.""" + total = 0.0 + for p in self.scene.walls: + a, b = max(p.s0, lo), min(p.s1, hi) + if b - a <= EPS or (abs(p.s0 - piece.s0) <= EPS and abs(p.s1 - piece.s1) <= EPS): + continue + total += p.error_at(max(abs(a), abs(b))) + return total + + def _meter_on_route(self, piece: Piece) -> tuple[float, float]: + """How much the meter's error e can lengthen and shorten a route to a battery on `piece`. + The battery is placed by its offset from the meter, so moving the meter by d (|d| <= e) + moves both. The route's start slides d's component along the meter's wall and the + battery d's along its own, so the run along the walls changes by sigma * d . v, with v the + difference of the two walls' directions and sigma the battery's side of the meter. The + meter's standoff o from its wall becomes |o + d . u| (u: from the wall to the meter). The + change sigma * d . v + |o + d . u| - o is the larger of d . (sigma v + u) and + d . (sigma v - u) - 2o, two linear functions, so over the disc it is at most + max(e |sigma v + u|, e |sigma v - u| - 2o): derived, and reached. On the meter's own wall + (v = 0) that is e; round a right-angle corner with the meter on its wall's line, sqrt(5) + e, the caretaker's witness in test_within_errors, where e * sqrt(1 + k^2) had treated the + two components as independent. Shorter: the standoff can't go below zero, so by at most + min(o, e) + |v| e, a bound, not the minimum.""" + e = self.scene.meter_plus_minus + m = self.scene.meter_piece + sigma = -1.0 if piece.s1 <= EPS else 1.0 + v = (sigma * (piece.along[0] - m.along[0]), sigma * (piece.along[1] - m.along[1])) + o = self.meter_offset + if o > EPS: + mx, mz = self.scene.meter_xz + wx, wz = self.scene.point_at(0.0) + u = ((mx - wx) / o, (mz - wz) / o) + else: + u = m.outward + longer = max( + e * math.hypot(v[0] + u[0], v[1] + u[1]), + e * math.hypot(v[0] - u[0], v[1] - u[1]) - 2 * o, + ) + return longer, min(longer, min(o, e) + math.hypot(*v) * e) + def reach_limit(self, piece: Piece) -> float: """Past this |s| of its near edge a battery's route fails the maximum length whatever else is true: the route is never shorter than |s|, and its error is at most the meter's, the wall's and every possible detour's.""" detour_err = sum(2 * o.plus_minus for o in self.route_objects) - e_fixed = self.scene.meter_plus_minus + piece.plus_minus + detour_err + # Every other wall's error may add to a route (at most, its error at its far end). + others = self._walls_between(piece, -math.inf, math.inf) + e_fixed = self._meter_on_route(piece)[1] + piece.plus_minus + detour_err + others if piece.drift >= 1: return math.inf # The route's error grows by the wall's drift at the battery's far edge, |s| + W from the @@ -1018,7 +1348,7 @@ def reach_limit(self, piece: Piece) -> float: e_fixed += piece.drift * self.W # The 1e-6 ft margin keeps the cutoff clear of the 6-decimal rounding of reported # starts, so a start reported past reach really fails when evaluated. - return (self.r.route.max_ft.value + e_fixed) / (1 - piece.drift) + 1e-6 + return (self.r.route.max_ft.value - self.meter_offset + e_fixed) / (1 - piece.drift) + 1e-6 def starts(self, piece: Piece) -> list[float]: """Start positions (left edge, in s) to evaluate on one straight segment.""" @@ -1034,37 +1364,87 @@ def starts(self, piece: Piece) -> list[float]: # treatment. k_lo, k_hi = math.floor(lo / step) - 1, math.ceil((hi + W) / step) + 1 points = [lo, hi] + [k * step - W / 2 for k in range(k_lo, k_hi + 1)] + # Where a start is first clear of the segment's ends by the wall's error. + # The battery's error beyond its wall's, as _error adds it: one value over a segment, as + # the same walls lie between it and the meter wherever it stands on it. + slide = self._slide(piece, lo) + extra = max(self.scene.meter_plus_minus, slide) + # Checks against spans take the error along the walls (_errors), this much smaller. + along = extra - slide + for e in {piece.plus_minus + extra, piece.error_at(max(abs(lo), abs(hi))) + extra}: + points += [piece.s0 + e, piece.s1 - W - e] # Along the wall: every place an interval can start or stop mattering, each with only # its own error offsets (combining every boundary with every error would grow as their # product). - ew = piece.error_at(max(abs(lo), abs(hi))) - around = (0.0, ew, -ew) + ew = piece.error_at(max(abs(lo), abs(hi))) + extra + # When the error drifts, offsets that include the battery's own error are placed with the + # error at each start instead (below), since that is the error a start is judged with. + drifts = piece.drift > 0 + around = (0.0,) if drifts else (0.0, ew, -ew, ew - along, along - ew) boundaries: list[tuple[float, tuple[float, ...]]] = [(0.0, around)] boundaries += [(b, around) for b in self.ws_span] + # Boundaries whose offset includes the battery's own error, with the fixed part of the + # offset: re-placed below with the error at each start when that error drifts. + drifting: list[tuple[float, float]] = [(0.0, 0.0), *((b, 0.0) for b in self.ws_span)] for o in self.scene.objects: e = o.plus_minus - boundaries += [(b, (0.0, e, -e, e + ew, -(e + ew))) for b in o.span] + near = e + ew - along + own = (0.0, e, -e) if drifts else (0.0, e, -e, e + ew, -(e + ew), near, -near) + boundaries += [(b, own) for b in o.span] + drifting += [(b, e) for b in o.span] + if along > EPS: + drifting += [(b, f - along) for b, f in drifting] 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)] for b, offsets in boundaries: for off in offsets: points += [b + off, b - W - off] + if drifts: + for b, fixed in drifting: + for k in (1.0, -1.0): + points.append( + self._settle( + lambda s, b=b, f=fixed, k=k: b + k * (f + self._error(piece, s)), + b + k * (fixed + ew), + ) + ) + points.append( + self._settle( + lambda s, b=b, f=fixed, k=k: b - W - k * (f + self._error(piece, s)), + b - W - k * (fixed + ew), + ) + ) rt = self.r.route - e_route = self.scene.meter_plus_minus + ew + # Where the run's range [|s| - (wall + down), |s| + wall + up] reaches each line. + up, down = self._meter_on_route(piece) for line in (rt.confident_reach_ft.value, rt.max_ft.value): - for x in (line - e_route, line + e_route, line): + for x in (line - (ew - extra) - up, line + (ew - extra) + down, line): points += [x, -x - W] # Plan clearances: where a footprint corner's track along the wall crosses the line at # the rule's distance (and within error of it) from any part of an object, including # the middle of a slanted edge, and where it crosses a ground patch's edge. tracks = [LineString([piece.point(lo, v), piece.point(hi + W, v)]) for v in (0.0, D)] strip = piece.rect(lo, hi + W, 0.0, D) - for geom, dist in self._clearance_edges(piece): + if drifts: + # The battery's error over this segment's starts: V-shaped in s, least where the + # battery straddles the meter. + errs = [self._error(piece, s) for s in (lo, hi, min(max(-W / 2, lo), hi))] + e_least, e_most = min(errs), max(errs) + for geom, base, fixed, k in self._clearance_edges(piece, along): + if drifts and k != 0: + points += self._drifting_outline( + piece, geom, base, fixed, k, tracks, strip, e_least, e_most + ) + continue + dist = base + k * (fixed + ew) + # A rule's outline needs a positive distance; a ground patch's may shrink it (base 0). + if base > 0 and dist <= 0: + continue region = geom.buffer(dist) if dist != 0 else geom boundary = region.boundary for track in tracks: @@ -1085,39 +1465,164 @@ def starts(self, piece: Piece) -> list[float]: mids = [(a + b) / 2 for a, b in itertools.pairwise(pts) if b - a > 1e-6] return sorted(set(pts) | set(mids)) - def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float]]: - """(geometry, offset) pairs whose offset outlines bound some check's outcome.""" + def _clearance_edges( + self, piece: Piece, along: float + ) -> list[tuple[Geometry, float, float, float]]: + """(geometry, base, fixed error, k): outlines at distance base + k * (fixed error + the + battery's error) from the geometry bound some check's outcome. An object placed along + the walls is judged with the battery's error along them, `along` less (_errors).""" c = self.r.clearances - ew = piece.error_at(max(abs(piece.s0), abs(piece.s1))) + + def fixed(o: SceneObject) -> float: + return ( + o.plus_minus if o.in_plan else o.plus_minus - along + self._chain_to(piece, o.geom) + ) + items = [ - *((c.gas_ft.value, o.geom, o.plus_minus) for o in self.gas), - *((c.ac_ft.value, o.geom, o.plus_minus) for o in self.ac), - *((c.battery_ft.value, o.geom, o.plus_minus) for o in self.batteries), - *((c.pool_ft.value, o.geom, o.plus_minus) for o in self.pool), - *((c.opening_ft.value, o.geom, o.plus_minus) for o in self.openings), + *((c.gas_ft.value, o.geom, fixed(o)) for o in self.gas), + *((c.ac_ft.value, o.geom, fixed(o)) for o in self.ac), + *((c.battery_ft.value, o.geom, fixed(o)) for o in self.batteries), + *((c.pool_ft.value, o.geom, fixed(o)) for o in self.pool), + *((c.opening_ft.value, o.geom, fixed(o)) for o in self.openings), *((c.drive_ft.value, g.polygon, g.plus_minus) for g in self.drives), ] - out: list[tuple[Geometry, float]] = [] - for t, geom, err in items: - for d in (t, t + err + ew, t - err - ew): - if d > 0: - out.append((geom, d)) + out = [(geom, t, err, k) for t, geom, err in items for k in (0.0, 1.0, -1.0) if t > 0] for g in self.scene.ground: - for d in (0.0, g.plus_minus + ew, -(g.plus_minus + ew)): - out.append((g.polygon, d)) + out += [(g.polygon, 0.0, g.plus_minus, k) for k in (0.0, 1.0, -1.0)] return out - 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" + def _error(self, piece: Piece, s0: float) -> float: + """The battery's position error for a start at s0 where a check compares it with both + kinds of position (a wall's end and the wall's spans of heights; seen areas): the larger + of _errors. The battery is placed by its offset from the meter (the AR view anchors it + there), so wherever the meter truly is, the battery goes with it: against anything placed + in plan it moves by up to the meter's error, and against anything placed along the walls + in s it slides (_slide). test_within_errors has a case of each.""" + return max(self._errors(piece, s0)) + + def _errors(self, piece: Piece, s0: float) -> tuple[float, float]: + """The battery's position error for a start at s0 against what was placed in plan (a + wall's end, a ground patch, an object's outline), and against what was placed along the + walls in s (a span): its wall's error at its far edge from the meter plus the meter's in + plan, plus the slide (_slide) along the walls. On the meter's own wall the slide is 0: a + span there moves with the meter as the battery does.""" + wall = piece.error_at(max(abs(s0), abs(s0 + self.W))) + return wall + self.scene.meter_plus_minus, wall + self._slide(piece, s0) + + def _slide(self, piece: Piece, s0: float) -> float: + """How far the battery's s can move against spans measured along the walls when it + stands on `piece` and the meter or a corner between them moves within its error. s = 0 + is the meter's projection onto its own wall, so moving the meter by d moves s = 0 by d's + component along that wall and the battery by d's along its own: d . (along - along_m). + Moving the far corner of a wall p between them by d lengthens p by d . along_p and moves + the battery's wall with it: d . (along_p - along). Each is at most the error times the + difference of the two directions, 0 on the meter's own wall.""" + + def turn(p: Piece) -> float: + return math.hypot(piece.along[0] - p.along[0], piece.along[1] - p.along[1]) + + near = s0 if s0 > 0 else min(s0 + self.W, 0.0) + lo, hi = min(0.0, near), max(0.0, near) + slide = self.scene.meter_plus_minus * turn(self.scene.meter_piece) + for p in self.scene.walls: + a, b = max(p.s0, lo), min(p.s1, hi) + if b - a <= EPS or (abs(p.s0 - piece.s0) <= EPS and abs(p.s1 - piece.s1) <= EPS): + continue + slide += p.error_at(max(abs(a), abs(b))) * turn(p) + return slide + + @staticmethod + def _settle(place: Callable[[float], float], start: float) -> float: + """A boundary whose position depends on the error at that position: iterate from the + largest-error estimate. Each round shrinks the difference by the drift rate.""" + for _ in range(8): + start = place(start) + return start + + def _drifting_outline( + self, + piece: Piece, + geom: Geometry, + base: float, + fixed: float, + k: float, + tracks: list[LineString], + strip: Polygon, + e_least: float, + e_most: float, + ) -> list[float]: + """Starts where the footprint is exactly base + k * (fixed + the error at that start) + from `geom`. A start's error lies between the segment's least and most, so each such + start lies between where the footprint meets the outline drawn with the least error and + where it meets the one drawn with the most: by a corner (along the corner tracks) or by + a side edge (the outline's extent within the footprint's strip). Those positions bracket + it, and bisection finds it however far apart they are.""" + W = self.W + ends: set[float] = set() + for e in (e_least, e_most): + dist = base + k * (fixed + e) + if base > 0 and dist <= 0: + continue + region = geom.buffer(dist) if dist != 0 else geom + for track in tracks: + for p in _coords_of(track.intersection(region.boundary)): + u = piece.local(p)[0] + ends |= {u, u - W} + inside = region.intersection(strip) + if not inside.is_empty: + us = [piece.local((x, z))[0] for x, z in get_coordinates(inside)] + ends |= {min(us), max(us), min(us) - W, max(us) - W} + + ordered = sorted(round(s, 9) for s in ends) + if base == 0: + # A ground patch's edge: the ground check rounds its erosion up to 0.1 ft, so there + # is no exact root to find; both bracketing positions are tested. + return ordered + + def gap(s: float) -> float: + # A rule's distance, from the whole footprint: corners and side edges alike. + return self.footprint(piece, s).distance(geom) - ( + base + k * (fixed + self._error(piece, s)) ) + + found: list[float] = [] + values = [gap(s) for s in ordered] + for (a, ga), (b, gb) in itertools.pairwise(zip(ordered, values, strict=True)): + if ga * gb > 0: + continue + for _ in range(40): + mid = (a + b) / 2 + gm = gap(mid) + if gm * ga <= 0: + b = mid + else: + a, ga = mid, gm + found.append((a + b) / 2) + return found + + def candidates(self, budget_s: float) -> list[Candidate]: + 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 " @@ -1141,8 +1646,7 @@ def out_of_reach(self) -> list[dict[str, Any]]: def fail_run(piece: Piece, a: float, b: float, check_id: str) -> dict[str, Any]: return { - "wall_id": piece.wall_id, - "segment": piece.index, + **_locator(self.scene, a, a + self.W), "start_ft": [_round(a), _round(b)], "outcome": FAIL, "failing": [check_id], @@ -1176,7 +1680,8 @@ def evaluate_start( """Evaluate one battery start position (its left edge at s0), even one that crosses a corner. The footprint follows the straight segment that contains s0.""" solver = Solver(scene, loaded) - walls = [p for p in scene.walls if wall_id is None or p.wall_id == wall_id] + # A wall joined into its collinear neighbour's piece has no piece of its own id. + walls = [p for p in scene.walls if wall_id is None or p.wall_id == wall_id] or scene.walls piece = next((p for p in walls if p.s0 - EPS <= s0 < p.s1 - EPS), walls[-1]) return solver.evaluate(piece, s0) @@ -1184,6 +1689,14 @@ def evaluate_start( # --- decision and result ------------------------------------------------------------------------- +def _unseen_near(solver: Solver, c: Candidate, end: list[float]) -> bool: + """Whether unseen ground past an unexplored end at `end` lies within reach of a check at c: + in that end's area (within reach of it), and within reach of c's footprint.""" + scene = solver.scene + area = _meet(polygonal(Point(end).buffer(scene.reach_ft)), polygonal(solver.unobserved_ground)) + return not area.is_empty and c.footprint.distance(area) < scene.reach_ft + + def _rank_pass(c: Candidate) -> tuple: return (c.route.length, abs((c.s0 + c.s1) / 2), c.s0) @@ -1220,8 +1733,7 @@ def _spot_json(solver: Solver, c: Candidate) -> dict[str, Any]: mx, mz = solver.scene.meter_xz return { "outcome": c.outcome, - "wall_id": solver.scene.wall_at((c.s0 + c.s1) / 2), - "segment": p.index, + **_locator(solver.scene, c.s0, c.s1), "span_ft": [_round(c.s0), _round(c.s1)], "width_ft": _round(solver.W), "depth_ft": _round(solver.D), @@ -1254,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, } @@ -1309,14 +1822,27 @@ def _past_end_hint(scene: Scene, a: float, b: float) -> str: ) +def _locator(scene: Scene, s0: float, s1: float) -> dict[str, Any]: + """The uploaded wall and segment under a battery's middle, from the same original segment.""" + wall_id, segment = scene.segment_at((s0 + s1) / 2) + return {"wall_id": wall_id, "segment": segment} + + def _sweep_json(cands: list[Candidate], scene: Scene, step: float) -> list[dict[str, Any]]: """Merge evaluated starts into runs. A run only grows by a start on the same straight piece within one sweep step of the previous one, so it never claims starts nobody evaluated.""" runs: list[dict[str, Any]] = [] prev: Candidate | None = None for c in sorted(cands, key=lambda c: c.s0): - wall = scene.wall_at((c.s0 + c.s1) / 2) - key = (wall, c.piece, c.outcome, sorted(c.failing()), sorted(c.unsure())) + where = _locator(scene, c.s0, c.s1) + key = ( + where["wall_id"], + where["segment"], + c.piece, + c.outcome, + sorted(c.failing()), + sorted(c.unsure()), + ) adjacent = prev is not None and prev.piece == c.piece and c.s0 - prev.s0 <= step + EPS prev = c if runs and adjacent and runs[-1]["_key"] == key: @@ -1325,12 +1851,11 @@ def _sweep_json(cands: list[Candidate], scene: Scene, step: float) -> list[dict[ runs.append( { "_key": key, - "wall_id": wall, - "segment": c.piece.index, + **where, "start_ft": [_round(c.s0), _round(c.s0)], "outcome": c.outcome, - "failing": key[3], - "unsure": key[4], + "failing": key[4], + "unsure": key[5], } ) for run in runs: @@ -1424,17 +1949,46 @@ def past_end_requests() -> list[dict[str, Any]]: } ) missing = _missing_json(best, scene) - if missing: + # An unseen check no view can settle waits on ground past an unexplored end: walking past + # it settles that, even an end too far for a spot past it to reach (the final review's + # ETH3D case: pool_clearance UNSURE with no request and no reason). + requested = {i for m in missing for i in m["checks"]} + leftover = [ + c.id + for c in best.checks + if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.id not in requested + ] + walk_to = [ + side + for side, e in ends.items() + if leftover + and e["kind"] == "unexplored" + and side not in open_ends + and _unseen_near(solver, best, e["point"]) + ] + if missing or leftover: reasons.append( { "code": "unobserved_area", - "checks": sorted({i for m in missing for i in m["checks"]}), + "checks": sorted(requested | set(leftover)), "message": "Part of the area the checks need was not seen.", } ) if open_ends: reasons.append(unexplored_reason) missing += past_end_requests() + missing += [ + { + "kind": "past_end", + "side": side, + "span_ft": [ends[side]["s_ft"], ends[side]["s_ft"]], + "message": ( + f"Walk past the {side} end of the scan ({where(ends[side]['s_ft'])}): ground " + "a check at the best spot needs lies past it." + ), + } + for side in walk_to + ] spot_at = where((best.s0 + best.s1) / 2) unseen = [c for c in best.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] if unseen: @@ -1491,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 = ( @@ -1516,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/conftest.py b/server/tests/conftest.py index efaeeb3cb..9a2878642 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -1,8 +1,18 @@ +import os + import pytest +from hypothesis import settings import api from rules import public_rules_dict, rules_from_dict +# CI runs every property test on a fixed set of examples, so a check can't pass on one run and +# fail on the next; a counterexample found there reproduces from the log alone. Local runs keep +# the random search (and its example database) for finding new cases. GitHub Actions sets CI. +# Loaded here, before the test modules import, so each @settings inherits it. +settings.register_profile("ci", derandomize=True, database=None, print_blob=True) +settings.load_profile("ci" if os.environ.get("CI") else "default") + @pytest.fixture(autouse=True) def public_api(monkeypatch: pytest.MonkeyPatch) -> None: 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_codex_review.py b/server/tests/test_codex_review.py new file mode 100644 index 000000000..9f846dbf4 --- /dev/null +++ b/server/tests/test_codex_review.py @@ -0,0 +1,250 @@ +"""Regression tests for the Codex review of #11 at 6c7ca23; each failed before its fix.""" + +from pathlib import Path + +import pytest +from helpers import at_start, observed_band, parsed, rect, shared_fixture +from test_s4_round import PUBLIC, answer + +from rules import deep_merge, load_rules, public_rules_dict, rules_from_dict +from solver import FAIL, PASS, UNSURE, reach_outcome + +W = 31 / 12 + + +# --- 1. a declared wall height --------------------------------------------------------------- + + +def test_a_wall_lower_than_the_battery_fails_the_backing() -> None: + # Before: height_ft was dropped, so a 2 ft wall passed a 3.29 ft battery. + raw = shared_fixture() + raw["walls"][0]["height_ft"] = 2 + raw["meter"]["pos"][1] = 1 + assert at_start(raw, 6.0, "wall_backing", PUBLIC).outcome == FAIL + + +def test_a_tall_enough_or_undeclared_wall_is_unchanged() -> None: + assert at_start(shared_fixture(), 6.0, "wall_backing", PUBLIC).outcome == PASS # 9 ft + raw = shared_fixture() + del raw["walls"][0]["height_ft"] + assert at_start(raw, 6.0, "wall_backing", PUBLIC).outcome == PASS + + +# --- 2. facing and headroom coverage over every position the battery may have ------------------ + + +def seen_only_over_the_nominal_spot() -> dict: + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["ground"] = [{"type": "lawn", "polygon": rect(-40, 40, 0, 30), "plus_minus_ft": 0}] + raw["facing"], raw["overheads"] = [], [] + for band in ("facing", "overhead"): + observed_band(raw, band, []) + raw["coverage"]["observed"].append({"band": band, "span_ft": [6, 6 + W], "out_ft": 9}) + return raw + + +def test_views_of_only_the_nominal_stretch_do_not_settle_facing_or_headroom() -> None: + # Before: coverage of exactly [6, 6 + W] passed, though the battery may sit 0.5 ft either way. + raw = seen_only_over_the_nominal_spot() + for check_id in ("facing_gap", "headroom"): + check = at_start(raw, 6.0, check_id, PUBLIC) + assert (check.outcome, check.unsure_cause) == (UNSURE, "unobserved"), check_id + + +def test_a_low_overhang_within_the_error_is_unsure() -> None: + raw = seen_only_over_the_nominal_spot() + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [5.6, 5.9], "clearance_ft": 2, "plus_minus_ft": 0} + ] + observed_band(raw, "overhead", [(-40, 40)]) + assert at_start(raw, 6.0, "headroom", PUBLIC).outcome == UNSURE + + +# --- 3. a real gap between walls ------------------------------------------------------------------ + + +def two_walls(error: float) -> dict: + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [3, 0]], "height_ft": 9, "plus_minus_ft": error}, + {"id": "w2", "baseline": [[3.5, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": error}, + ] + return raw + + +def test_a_gap_larger_than_the_walls_errors_is_a_gap() -> None: + # Before: exact walls 0.5 ft apart were joined (the tolerance is 0.6), the route passed and + # s skipped the gap. + scene = parsed(two_walls(0.0), PUBLIC) + assert [(p.s0, p.s1) for p in scene.gaps] == [(3.0, 3.5)] + assert scene.s_max == pytest.approx(40.0) # s counts the gap + assert at_start(two_walls(0.0), 6.0, "route_path", PUBLIC).outcome == FAIL + + +def test_a_gap_within_the_walls_errors_is_a_join() -> None: + scene = parsed(two_walls(0.3), PUBLIC) # 0.5 <= 0.3 + 0.3, under the 0.6 cap + assert scene.gaps == [] + assert scene.s_max == pytest.approx(39.5) # the walls meet; s continues without the gap + + +# --- 4. the spot's wall and segment belong together --------------------------------------------- + + +def test_a_spot_on_the_second_of_two_joined_walls_names_its_own_segment() -> None: + # Before: wall_id w2 with segment 1, the first wall's index; w2 has only segment 0. + raw = shared_fixture() + raw["walls"] = [ + { + "id": "w1", + "baseline": [[-40, -10], [-40, 0], [4, 0]], + "height_ft": 9, + "plus_minus_ft": 0, + }, + {"id": "w2", "baseline": [[4, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": 0}, + ] + spot = answer(raw)["spot"] + assert (spot["wall_id"], spot["segment"]) == ("w2", 0) + runs = {(r["wall_id"], r["segment"]) for r in answer(raw)["sweep"]} + assert ("w2", 1) not in runs + + +# --- 5. a zero sweep step ------------------------------------------------------------------------- + + +def test_a_zero_sweep_step_is_refused_when_the_rules_load() -> None: + # Before: it validated, and every solve then divided by zero. + data = deep_merge(public_rules_dict(), {"sweep": {"step_ft": {"value": 0.0}}}) + with pytest.raises(ValueError, match=r"sweep\.step_ft"): + rules_from_dict(data) + + +# --- 6. placeholders under partial private rules -------------------------------------------------- + + +def test_a_partial_private_policy_names_the_checks_still_on_placeholders(tmp_path: Path) -> None: + # Before: any private file cleared the notice, though the pool and driveway checks still ran + # on public placeholder values. + private = tmp_path / "rules.yaml" + private.write_text( + "policy: {id: p, version: '1', auto_approve: true, allow_reject: true}\n" + "clearances:\n gas_ft: {value: 3.0, source: 'test'}\n" + ) + notice = load_rules(private).rules.policy.notice + assert notice is not None + for check_id in ("pool_clearance", "drive_clearance", "route_length", "headroom"): + assert check_id in notice, notice + assert "gas_clearance" not in notice + + +def test_the_public_demo_notice_is_unchanged() -> None: + assert "not Base's" in (PUBLIC.rules.policy.notice or "") + + +# --- follow-ups from the caretaker's review of 903d86f ---------------------------------------- + + +def two_heights() -> dict: + """Collinear walls: w1 up to the meter, 2 ft tall; w2 from it, 9 ft. Joined into one piece.""" + raw = shared_fixture() + raw["meter"]["pos"][1] = 1 + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [0, 0]], "height_ft": 2, "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[0, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": 0}, + ] + return raw + + +def test_a_battery_on_the_taller_wall_uses_that_walls_height() -> None: + # Before: the joined piece kept the lower height everywhere, so a spot wholly on the 9 ft + # wall failed on 2 ft and the scene was rejected. + assert at_start(two_heights(), 6.0, "wall_backing", PUBLIC).outcome == PASS + assert answer(two_heights())["decision"] != "reject" + + +def test_a_battery_across_the_join_uses_the_lower_height() -> None: + assert at_start(two_heights(), -1.0, "wall_backing", PUBLIC).outcome == FAIL + + +def test_segments_are_numbered_as_uploaded() -> None: + # Before: collinear points were merged first, so the second uploaded segment reported 0. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [0, 0], [40, 0]] + scene = parsed(raw, PUBLIC) + assert scene.segment_at(7.3) == ("w1", 1) + assert scene.segment_at(-7.3) == ("w1", 0) + assert scene.segment_at(0.0) in {("w1", 0), ("w1", 1)} # on the boundary: either + spot = answer(raw)["spot"] + assert (spot["wall_id"], spot["segment"]) == ("w1", 1) + + +@pytest.mark.parametrize("gap", [0.005, 0.010001]) +def test_a_known_gap_between_exact_walls_stays_a_gap(gap: float) -> None: + # Before: exact walls 0.005 ft apart were joined by the 0.01 ft coverage floor. + raw = two_walls(0.0) + raw["walls"][0]["baseline"] = [[-40, 0], [3, 0]] + raw["walls"][1]["baseline"] = [[3 + gap, 0], [40, 0]] + scene = parsed(raw, PUBLIC) + assert len(scene.gaps) == 1 + assert at_start(raw, 6.0, "route_path", PUBLIC).outcome == FAIL + + +def test_walls_sharing_an_endpoint_still_meet() -> None: + raw = two_walls(0.0) + raw["walls"][1]["baseline"] = [[3, 0], [40, 0]] + assert parsed(raw, PUBLIC).gaps == [] + + +# --- Codex review of 7133283 ---------------------------------------------------------------------- + + +def test_a_confident_reach_past_the_maximum_is_refused() -> None: + data = deep_merge(public_rules_dict(), {"route": {"confident_reach_ft": {"value": 30.0}}}) + with pytest.raises(ValueError, match=r"route\.confident_reach_ft"): + rules_from_dict(data) + + +def test_a_run_that_may_exceed_the_maximum_never_passes() -> None: + # Before: with confident reach 30 and maximum 20, a 21 ± 2 ft run passed. + assert reach_outcome(21.0, 2.0, 30.0, 20.0) != PASS + assert reach_outcome(10.0, 2.0, 30.0, 20.0) == PASS + + +def test_a_wall_view_without_out_ft_reaches_headroom_height_only() -> None: + # Before: an omitted out_ft counted as infinite, so an 8 ft battery's back passed from it, + # while the same view declared at 6.5 ft (headroom height) went to review. + tall = rules_from_dict( + deep_merge(public_rules_dict(), {"battery": {"height_ft": {"value": 8.0}}}) + ) + raw = shared_fixture() + del raw["walls"][0]["height_ft"] + observed_band(raw, "wall", [(-40, 40)]) # no out_ft + omitted = at_start(raw, 6.0, "wall_backing", tall) + raw["coverage"]["observed"][-1]["out_ft"] = 6.5 + declared = at_start(raw, 6.0, "wall_backing", tall) + assert (omitted.outcome, omitted.unsure_cause) == (UNSURE, "unobserved") + assert (declared.outcome, declared.unsure_cause) == (UNSURE, "unobserved") + + +def test_an_omitted_wall_view_still_settles_every_public_check() -> None: + # Headroom height itself is enough for boxes and openings above the battery. + assert at_start(shared_fixture(), 6.0, "wall_equipment_above", PUBLIC).outcome == PASS + + +def test_a_partial_ground_override_keeps_ground_surface_in_the_notice(tmp_path: Path) -> None: + # Before: setting only ground.drivable and ground.source hid the public ground.allowed. + private = tmp_path / "rules.yaml" + private.write_text("ground:\n drivable: [drive]\n source: 'test'\n") + assert "ground_surface" in (load_rules(private).rules.policy.notice or "") + + +def test_at_exactly_the_needed_height_omitted_settles_and_explicit_does_not() -> None: + # wall_equipment_above needs the wall seen to headroom height (6.5). An omitted out_ft counts + # inclusively at that height; an explicit out_ft must be strictly higher. + needed = PUBLIC.rules.headroom.min_ft.value + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 40)]) # no out_ft + assert at_start(raw, 6.0, "wall_equipment_above", PUBLIC).outcome == PASS + raw["coverage"]["observed"][-1]["out_ft"] = needed + explicit = at_start(raw, 6.0, "wall_equipment_above", PUBLIC) + assert (explicit.outcome, explicit.unsure_cause) == (UNSURE, "unobserved") diff --git a/server/tests/test_final_opus_review.py b/server/tests/test_final_opus_review.py new file mode 100644 index 000000000..38586e64f --- /dev/null +++ b/server/tests/test_final_opus_review.py @@ -0,0 +1,194 @@ +"""Findings of the final review of 3baa338, each pinned by the reviewer's probe.""" + +import math +import time + +import pytest +import shapely +from helpers import everything_observed, pads_ground, parsed, rect, shared_fixture +from hypothesis import assume, given, settings +from hypothesis import strategies as st +from shapely import unary_union +from shapely.geometry import Point +from test_s4_round import PUBLIC + +from scene import MAX_WALL_PIECES, SceneError, parse_scene +from solver import PASS, evaluate_start, solve + + +def side_passage(error: float = 0.05) -> dict: + """The house wall, a 4 ft open passage to the side yard, then the garage wall, everything + observed by views along the walls (probe r_gap2).""" + return { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "house", "plus_minus_ft": error}, + "walls": [ + {"id": "house", "baseline": [[-20, 0], [10, 0]], "plus_minus_ft": error}, + {"id": "garage", "baseline": [[14, 0], [40, 0]], "plus_minus_ft": error}, + ], + "objects": [], + "ground": pads_ground([(6.5, 10)], -40, 60), + "overheads": [], + "facing": [], + "coverage": everything_observed(-60, 60), + } + + +def test_ground_behind_a_gap_between_walls_is_yard_until_seen() -> None: + # Before: the passage behind the gap's line was neither in front of a wall nor the house, + # so it never counted as unseen, and [6.6, 9.18], 1.65 ft from it, passed gas, AC and pool + # "and that whole area was seen" from views that only ran along the walls. + raw = side_passage() + scene = parsed(raw, PUBLIC) + assert scene.unobserved_ground().intersects(Point(10.5, -1.0)) + result = solve(scene, PUBLIC) + assert result["decision"] != PASS + checks = {c["id"]: c for c in result["checks"]} + for cid in ("gas_clearance", "ac_clearance", "pool_clearance"): + assert (checks[cid]["outcome"], checks[cid]["unsure_cause"]) == ("unsure", "unobserved") + + +def test_a_view_into_the_passage_settles_it() -> None: + # 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"], + "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 + + +def test_seen_ground_with_no_recorded_surface_may_be_a_driveway() -> None: + # Before (probe r_drive): ground seen 30 ft out but only the pad's lawn recorded, and + # drive_clearance passed "that whole area was seen", where ground_surface calls the same + # ground an unknown attribute. + raw = shared_fixture() + raw["meter"].pop("plus_minus_ft") + raw["ground"] = [{"type": "lawn", "polygon": rect(4, 11, 0, 4), "plus_minus_ft": 0.05}] + result = solve(parsed(raw, PUBLIC), PUBLIC) + drive = next(c for c in result["checks"] if c["id"] == "drive_clearance") + assert (drive["outcome"], drive["unsure_cause"]) == ("unsure", "unknown_attribute") + assert "Recording what that ground is" in drive["reason"] + + +def zigzag(walls: int, points: int) -> dict: + """Probe r_time2: walls end to end along x, each tap bowing 0.1 ft, so every tap is a + corner (more than the 0.05 ft a straight wall's taps may stray).""" + out, x = [], 0.0 + for k in range(walls): + pts = [] + for j in range(points): + pts.append([x, 0.1 * (j % 2)]) + x += 1.0 + x -= 1.0 + out.append({"id": f"w{k}", "baseline": pts}) + return { + "schema_version": "1.0", + "meter": {"pos": [5.0, 5, 0.0], "wall_id": "w0"}, + "walls": out, + "coverage": {"ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}}, + } + + +def test_a_scene_with_more_wall_pieces_than_the_bound_is_refused_at_once() -> None: + # Before: an 11 KB body of 4 zigzag walls (798 straight pieces) took 28.6 s to solve. + started = time.perf_counter() + with pytest.raises(SceneError, match="straight wall segments"): + parse_scene(zigzag(4, 200), PUBLIC.rules) + assert time.perf_counter() - started < 1.0 + # A scene at the bound still parses. + parse_scene(zigzag(1, MAX_WALL_PIECES + 1), PUBLIC.rules) + + +RULE_OF = {"gas_clearance": "gas_ft", "ac_clearance": "ac_ft", "pool_clearance": "pool_ft"} + + +@st.composite +def passages(draw: st.DrawFn) -> dict: + """Two walls with a gap wider than the join tolerance between them, the second at any angle, + at any errors, every band seen by views along the walls (none pointed into the passage).""" + c = draw(st.floats(1.0, 15.0)) # w1 reaches the meter at x = 0 + g = c + draw(st.floats(1.0, 8.0)) + angle = math.radians(draw(st.floats(-80.0, 80.0))) + end = [g + 20 * math.cos(angle), 20 * math.sin(angle)] + e1, e2 = draw(st.floats(0.0, 0.3)), draw(st.floats(0.0, 0.3)) + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = draw(st.floats(0.0, 0.3)) + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [c, 0]], "height_ft": 9, "plus_minus_ft": e1}, + {"id": "w2", "baseline": [[g, 0], end], "height_ft": 9, "plus_minus_ft": e2}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-80, 80, -80, 80), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = everything_observed(-60, 60) + return raw + + +@settings(max_examples=40, deadline=None) +@given(raw=passages(), data=st.data()) +def test_views_along_the_walls_never_show_a_side_passage(raw: dict, data) -> None: + """Ground behind a gap that no wall faces is shown only by a view pointed into the passage, + so views along the walls leave any clearance that reaches it unsettled.""" + scene = parsed(raw, PUBLIC) + assume(scene.gaps) + # What no wall faces: behind the gap's line, not the house, and not in front of a wall + # (a turned second wall faces part of that strip, and a view along it does show that part). + faced = scene.band_polygon(scene.pieces[0].s0, scene.pieces[-1].s1, scene.reach_ft) + behind = unary_union(scene.passages(scene.reach_ft)) + passage = shapely.difference(shapely.difference(behind, scene.house()), faced) + piece = data.draw(st.sampled_from(scene.walls), label="wall") + assume(piece.s1 - piece.s0 >= PUBLIC.rules.battery.width_ft.value) + s0 = data.draw(st.floats(piece.s0, piece.s1 - PUBLIC.rules.battery.width_ft.value)) + candidate = evaluate_start(scene, PUBLIC, s0, piece.wall_id) + for c in candidate.checks: + rule = RULE_OF.get(c.id) + if rule is None: + continue + reach = getattr(PUBLIC.rules.clearances, rule).value + if candidate.footprint.distance(passage) < reach: + assert c.outcome != PASS, (c.id, s0, c.reason) + + +def test_a_subnormal_coordinate_does_not_hide_a_passage() -> None: + # Found by the passage property: w2's far end at z = 7.8e-314 ft made the ground behind the + # gap vanish from the unseen area, and a battery 0.73 ft from the passage passed gas. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.0 + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [3.3096, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + { + "id": "w2", + "baseline": [[4.7683, 0], [24.7683, 7.7669729873e-314]], + "height_ft": 9, + "plus_minus_ft": 0.0, + }, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-80, 80, -80, 80), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = everything_observed(-60, 60) + scene = parsed(raw, PUBLIC) + assert scene.unobserved_ground().contains(Point(4.0, -0.5)) + gas = next( + c for c in evaluate_start(scene, PUBLIC, 0.0, "w1").checks if c.id == "gas_clearance" + ) + assert gas.outcome != PASS diff --git a/server/tests/test_final_review.py b/server/tests/test_final_review.py new file mode 100644 index 000000000..5cb92d9e4 --- /dev/null +++ b/server/tests/test_final_review.py @@ -0,0 +1,255 @@ +"""Regression tests for the Codex review of #11 at 9176125, and the scoreboard's S2-3 properties.""" + +import copy +import math + +from helpers import at_start, everything_observed, pads_ground, parsed, rect, shared_fixture +from hypothesis import example, given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +from solver import FAIL, PASS, SOLVE_BUDGET_S, UNSURE, Solver, at_least, evaluate_start + +W = 31 / 12 + + +# --- 4114314891: a spot within the wall's error of the wall's end ------------------------------ + + +def wall_ending_at_9() -> dict: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [9, 0]] + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, 9] + raw["coverage"]["ends"]["right"] = {"kind": "limit"} + return raw + + +def test_a_spot_within_error_of_the_wall_end_is_not_a_pass() -> None: + # Before: [6.1, 8.683] passed although its edge may be at 9.18, past the wall's end at 9. + backing = at_start(wall_ending_at_9(), 6.1, "wall_backing", PUBLIC) + assert (backing.outcome, backing.unsure_cause) == (UNSURE, "margin") + assert answer(wall_ending_at_9())["decision"] != "pass" + + +def test_a_spot_clear_of_the_end_by_its_error_still_passes_backing() -> None: + assert at_start(wall_ending_at_9(), 9 - W - 0.6, "wall_backing", PUBLIC).outcome == PASS + + +# --- 4114314918: the meter's offset from the wall ------------------------------------------------ + + +def meter_off_the_wall() -> dict: + raw = shared_fixture() + raw["meter"]["pos"] = [0.0, 5.0, 2.0] # 2 ft in front of the wall at z = 0 + raw["ground"] = [ + {"type": "lawn", "polygon": rect(14, 17, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, 14, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(17, 40, 0, 30), "plus_minus_ft": 0}, + ] + return raw + + +def test_the_route_starts_at_the_meter_not_its_projection() -> None: + # Before: 14 ± 0 from [0, 0], a pass; the real run from the meter is 16, past 15 confident. + reach = at_start(meter_off_the_wall(), 14.0, "route_length", PUBLIC) + assert reach.measured == 16.0 + assert reach.outcome == UNSURE + result = answer(meter_off_the_wall()) + assert result["decision"] != "pass" + assert result["route"] is None or result["route"]["polyline"][0] == [0.0, 2.0] + + +# --- scoreboard S2-3 ----------------------------------------------------------------------------- + + +@given(threshold=st.floats(0.1, 20), error=st.floats(0, 5)) +def test_equality_is_unsure(threshold: float, error: float) -> None: + # A margin exactly equal to the error decides nothing, either way. + assert at_least(threshold + error, error, threshold) == UNSURE + assert at_least(threshold - error, error, threshold) == UNSURE + + +RANK = {PASS: 0, UNSURE: 1, FAIL: 2} + + +@settings(max_examples=40, deadline=None) +@given(near=st.floats(0.0, 6.0), extra=st.floats(0.0, 4.0), error=st.floats(0.0, 0.5)) +def test_moving_an_obstacle_away_never_turns_pass_into_fail( + near: float, extra: float, error: float +) -> None: + """Monotonicity over one obstacle distance: an AC unit further from the battery never makes + the AC check, or the spot, worse.""" + + def spot_with_ac(gap: float): + raw = shared_fixture() + x = 6.0 + W + gap + raw["objects"] = [ + { + "type": "ac", + "wall_id": "w1", + "span_ft": [x, x + 2], + "source": "tape", + "plus_minus_ft": error, + "footprint": rect(x, x + 2, 0, 2), + } + ] + return evaluate_start(parsed(copy.deepcopy(raw), PUBLIC), PUBLIC, 6.0) + + closer, further = spot_with_ac(near), spot_with_ac(near + extra) + ac = {c.id: c.outcome for c in closer.checks}["ac_clearance"] + ac_far = {c.id: c.outcome for c in further.checks}["ac_clearance"] + assert RANK[ac_far] <= RANK[ac] + assert RANK[further.outcome] <= RANK[closer.outcome] + + +def test_the_wall_behind_every_possible_position_must_be_seen() -> None: + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != "wall"] + raw["coverage"]["observed"].append({"band": "wall", "span_ft": [-40, 6 + W]}) + backing = at_start(raw, 6.0, "wall_backing", PUBLIC) + assert (backing.outcome, backing.unsure_cause) == (UNSURE, "unobserved") + + +# --- S4 on 4a9fa44: a request is a fixed point --------------------------------------------------- + + +def supplied_exactly(raw: dict, result: dict) -> dict: + out = copy.deepcopy(raw) + for item in result["missing_evidence"]: + if item["kind"] == "band": + depth = {"out_ft": item["out_ft"]} if "out_ft" in item else {} + out["coverage"]["observed"].append( + {"band": item["band"], "span_ft": item["span_ft"], **depth} + ) + return out + + +def unseen_at(raw: dict, s0: float, wall_id: str) -> list[str]: + candidate = evaluate_start(parsed(raw, PUBLIC), PUBLIC, s0, wall_id) + return [c.id for c in candidate.checks if c.unsure_cause == "unobserved"] + + +def exact_start(raw: dict, result: dict) -> float: + scene = parsed(raw, PUBLIC) + target = result["spot"]["span_ft"][0] + return min( + (c.s0 for c in Solver(scene, PUBLIC).candidates(SOLVE_BUDGET_S)), + key=lambda s: abs(s - target), + ) + + +@st.composite +def cornered(draw: st.DrawFn) -> dict: + """A wall that turns at x = c by `angle` degrees (positive: an inside corner, turning + toward the yard), with gaps in every band's coverage and random view depths.""" + c = draw(st.floats(4.0, 14.0)) + angle = math.radians(draw(st.floats(-80.0, 80.0))) + end = [c + 20 * math.cos(angle), 20 * math.sin(angle)] + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [c, 0]], "height_ft": 9, "plus_minus_ft": 0.1}, + {"id": "w2", "baseline": [[c, 0], end], "height_ft": 9, "plus_minus_ft": 0.1}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [], + } + for band in ("wall", "ground", "overhead", "facing"): + cuts = draw(st.lists(st.floats(-10.0, c + 20.0), max_size=4)) + edges = [-30.0, *sorted(cuts[: len(cuts) // 2 * 2]), c + 20.0] + depth = draw(st.floats(1.0, 12.0)) + for a, b in zip(edges[::2], edges[1::2], strict=True): + if b > a: + item = {"band": band, "span_ft": [a, b]} + if band == "ground" or draw(st.booleans()): + item["out_ft"] = depth + raw["coverage"]["observed"].append(item) + return raw + + +def corner_108() -> dict: + """The example the final review saved (finding 6): a 108 degree corner, walls +/- 0.1 ft, + ground seen 1 ft out, overheads unseen over s [-2, 0].""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [6.1875, 0]], "height_ft": 9, "plus_minus_ft": 0.1}, + { + "id": "w2", + "baseline": [[6.1875, 0], [12.367839887498949, 19.02113032590307]], + "height_ft": 9, + "plus_minus_ft": 0.1, + }, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-30.0, 26.1875]}, + {"band": "ground", "span_ft": [-30.0, 26.1875], "out_ft": 1.0}, + {"band": "overhead", "span_ft": [-30.0, -2.0]}, + {"band": "overhead", "span_ft": [0.0, 26.1875]}, + {"band": "facing", "span_ft": [-30.0, 26.1875]}, + ], + } + return raw + + +# The saved example runs on every run, CI's fixed examples included. +@settings(max_examples=30, deadline=None) +@given(raw=cornered()) +@example(raw=corner_108()) +def test_capturing_exactly_what_is_requested_settles_it_in_one_round(raw: dict) -> None: + # Before: near a corner the ground depth was the distance from the chain line, less than + # the strip in front of the wall needs, so each capture fell short and the next answer + # asked for 0.005 ft more over the same stretch. + result = answer(raw) + if result["decision"] != "manual_review" or result["spot"] is None: + return + assert unseen_without_a_reason(result) == [] + s0 = exact_start(raw, result) + assert unseen_at(supplied_exactly(raw, result), s0, result["spot"]["wall_id"]) == [] + + +def unseen_without_a_reason(result: dict) -> list[str]: + """Unseen checks at the spot that the answer's unobserved_area reason doesn't name: the + result contract has every UNSURE check carry a request or a reason.""" + named = {i for r in result["reasons"] if r["code"] == "unobserved_area" for i in r["checks"]} + return [ + c["id"] + for c in result["checks"] + if c.get("unsure_cause") == "unobserved" and c["id"] not in named + ] + + +def test_a_request_leaves_no_lens_where_a_clearance_circle_meets_a_view() -> None: + # Before: the ground request's depth was accepted once under 1e-9 sq ft stayed unseen, and a + # 9.5e-10 sq ft lens where the pool circle meets the view's edge, 1.7e-5 ft inside the + # radius, left pool_clearance unseen after the exact capture. + raw = corner_108() + result = answer(raw) + s0 = exact_start(raw, result) + assert unseen_at(supplied_exactly(raw, result), s0, result["spot"]["wall_id"]) == [] + + +def test_ground_past_an_end_beyond_reach_is_asked_for() -> None: + # The manager's ETH3D case, rebuilt: the pool circle at the only allowed pad reaches past an + # unexplored end that no spot past it could reach. Before, pool_clearance was UNSURE with no + # request and no reason (only walking past the end shows that ground, and no past_end + # request was made for an end beyond reach). + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [30, 0]], "height_ft": 9, "plus_minus_ft": 0.0} + ] + raw["ground"] = pads_ground([(20.0, 23.0)], -40, 60) + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = everything_observed(-20, 30) + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = answer(raw) + assert unseen_without_a_reason(result) == [] + walk = [m for m in result["missing_evidence"] if m["kind"] == "past_end"] + assert [m["side"] for m in walk] == ["right"] diff --git a/server/tests/test_house_and_tolerance.py b/server/tests/test_house_and_tolerance.py new file mode 100644 index 000000000..5d36cb137 --- /dev/null +++ b/server/tests/test_house_and_tolerance.py @@ -0,0 +1,113 @@ +"""Regression tests for the caretaker's findings at 5a4b4f4; each failed before its fix.""" + +import math + +import pytest +from helpers import observed_band, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from shapely.geometry import Point +from test_s4_round import PUBLIC, answer, parsed + +from solver import PASS, evaluate_start + +GROUND_CHECKS = { + "ground_surface", + "gas_clearance", + "ac_clearance", + "drive_clearance", + "pool_clearance", + "battery_clearance", +} + + +def without_ground(raw: dict) -> dict: + raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != "ground"] + for o in raw["coverage"]["observed"]: + o["span_ft"] = [-100, 100] + return raw + + +def back_wall(error: float) -> dict: + """The caretaker's witness: w3 runs back parallel 2 ft behind w1.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [20, 0]], "height_ft": 9, "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[20, 0], [20, -2]], "height_ft": 9, "plus_minus_ft": 0}, + {"id": "w3", "baseline": [[20, -2], [-20, -2]], "height_ft": 9, "plus_minus_ft": error}, + ] + return without_ground(raw) + + +@pytest.mark.parametrize("error", [0.0, 4.0]) +def test_yard_in_front_of_a_wall_is_not_house(error: float) -> None: + # Before: w3's strip behind it reached across w1 into its yard, so (7, 3) counted as house. + assert not parsed(back_wall(error), PUBLIC).house().covers(Point(7, 3)) + assert parsed(back_wall(error), PUBLIC).house().covers(Point(7, -1)) # between the facades + + +@pytest.mark.parametrize("error", [0.0, 4.0]) +def test_a_spot_with_no_ground_seen_is_not_a_pass(error: float) -> None: + candidate = evaluate_start(parsed(back_wall(error), PUBLIC), PUBLIC, 6.0) + assert candidate.outcome != PASS + assert answer(back_wall(error))["decision"] != "pass" + + +# --- the seen-ground tolerance ------------------------------------------------------------------- + + +@pytest.mark.parametrize(("gap", "closes"), [(0.009, True), (0.011, False), (0.019, False)]) +def test_only_gaps_within_the_tolerance_close(gap: float, closes: bool) -> None: + # Before: growing seen ground by 0.005 each side and removing seams by another 0.005 closed + # gaps up to 0.02 ft, twice the 0.01 ft tolerance. + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 7), (7 + gap, 40)], out=30) + unseen = parsed(raw, PUBLIC).unobserved_ground() + assert unseen.intersects(Point(7 + gap / 2, 5)) != closes + + +# --- no ground seen, nothing that needs ground passes ------------------------------------------- + + +@st.composite +def facades(draw: st.DrawFn) -> dict: + """A facade with a corner, a wall running back parallel behind the first, or a gap between + two walls (a side passage), at any error; no ground observations at all.""" + raw = shared_fixture() + c = draw(st.floats(10.0, 25.0)) + angle = math.radians(draw(st.floats(-100.0, 100.0))) + e1, e2 = draw(st.floats(0.0, 1.0)), draw(st.floats(0.0, 5.0)) + walls = [{"id": "w1", "baseline": [[-20, 0], [c, 0]], "height_ft": 9, "plus_minus_ft": e1}] + layout = draw(st.sampled_from(["back", "corner", "gap"])) + if layout == "gap": + # Wider than the join tolerance (sweep.wall_join_ft, 0.6), so it stays a gap. + g = c + draw(st.floats(1.0, 8.0)) + end = [g + 15 * math.cos(angle), 15 * math.sin(angle)] + walls.append({"id": "w2", "baseline": [[g, 0], end], "height_ft": 9, "plus_minus_ft": e2}) + elif layout == "back": + depth = draw(st.floats(0.5, 20.0)) + walls += [ + {"id": "w2", "baseline": [[c, 0], [c, -depth]], "height_ft": 9, "plus_minus_ft": e1}, + { + "id": "w3", + "baseline": [[c, -depth], [-20, -depth]], + "height_ft": 9, + "plus_minus_ft": e2, + }, + ] + else: + end = [c + 15 * math.cos(angle), 15 * math.sin(angle)] + walls.append({"id": "w2", "baseline": [[c, 0], end], "height_ft": 9, "plus_minus_ft": e2}) + raw["walls"] = walls + raw["ground"] = [{"type": "lawn", "polygon": rect(-80, 80, -80, 80), "plus_minus_ft": 0}] + return without_ground(raw) + + +@settings(max_examples=40, deadline=None) +@given(raw=facades(), starts=st.lists(st.floats(-15.0, 8.0), min_size=1, max_size=3)) +def test_with_no_ground_seen_no_check_that_needs_it_passes(raw: dict, starts: list[float]) -> None: + scene = parsed(raw, PUBLIC) + for s0 in starts: + candidate = evaluate_start(scene, PUBLIC, s0) + passed = {c.id for c in candidate.checks if c.outcome == PASS} & GROUND_CHECKS + assert not passed, (s0, passed) diff --git a/server/tests/test_last_round.py b/server/tests/test_last_round.py new file mode 100644 index 000000000..603590705 --- /dev/null +++ b/server/tests/test_last_round.py @@ -0,0 +1,88 @@ +"""Regression tests for the caretaker's three findings at 4a9fa44; each failed before its fix.""" + +import pytest +from helpers import at_start, observed_band, pads_ground, shared_fixture +from test_s4_round import PUBLIC, answer + +from rules import deep_merge, public_rules_dict, rules_from_dict +from solver import PASS, UNSURE + +# --- 4114443089: every wall the cable runs along carries its error ------------------------------ + + +def three_walls(corner_x: float) -> dict: + """w1 and w2 known to ±1 ft, the candidate wall w3 exact; their shared corner at x.""" + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0 + raw["walls"] = [ + {"id": "w1", "baseline": [[-4, 0], [corner_x, 0]], "height_ft": 9, "plus_minus_ft": 1}, + {"id": "w2", "baseline": [[corner_x, 0], [4, -4]], "height_ft": 9, "plus_minus_ft": 1}, + {"id": "w3", "baseline": [[4, -4], [20, -4]], "height_ft": 9, "plus_minus_ft": 0}, + ] + ground = pads_ground([(9.5, 12.5)]) + for g in ground: + g["polygon"] = [[x, z - 4 if z == 0 else z] for x, z in g["polygon"]] + raw["ground"] = ground + raw["overheads"], raw["facing"] = [], [] + for band in ("wall", "ground", "overhead", "facing"): + observed_band(raw, band, [(-40, 60)]) + return raw + + +def test_a_route_near_the_reach_line_through_uncertain_walls_is_not_a_pass() -> None: + # Before: 14.5 ± 0 passed; the same battery with the shared corner moved 1 ft (within the + # walls' ±1) is a 15.6 ft run and goes to review. + result = answer(three_walls(4.0)) + reach = next(c for c in result["checks"] if c["id"] == "route_length") + assert reach["outcome"] != PASS, reach + assert reach["plus_minus_ft"] >= 2 + + +def test_moving_the_corner_within_its_error_does_not_flip_the_decision() -> None: + assert answer(three_walls(4.0))["decision"] == answer(three_walls(5.0))["decision"] + + +# --- 4114443091: unseen wall where the route may run --------------------------------------------- + + +def short_wall(door: bool) -> dict: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-4, 0], [20, 0]] + raw["meter"]["plus_minus_ft"] = 0.3 + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-4, 20] + observed_band(raw, "wall", [(0, 40)]) + if door: + raw["objects"] = [ + { + "type": "door", + "wall_id": "w1", + "span_ft": [-0.3, -0.1], + "bottom_ft": 0, + "top_ft": 7, + "source": "tape", + "plus_minus_ft": 0, + } + ] + return raw + + +def test_unseen_wall_within_the_meters_error_leaves_the_route_unsure() -> None: + # Before: the route passed while the wall at s -0.3 to 0, where the meter may be, was unseen; + # recording a door there made it UNSURE. + route = at_start(short_wall(door=False), 6.0, "route_path", PUBLIC) + assert (route.outcome, route.unsure_cause) == (UNSURE, "unobserved") + assert at_start(short_wall(door=True), 6.0, "route_path", PUBLIC).outcome == UNSURE + + +# --- 4114443100: a window exemption above the height the opening check requires ----------------- + + +def test_an_exemption_above_headroom_is_refused_when_the_rules_load() -> None: + data = deep_merge(public_rules_dict(), {"openings": {"exempt_bottom_above_ft": 10.0}}) + with pytest.raises(ValueError, match=r"openings\.exempt_bottom_above_ft"): + rules_from_dict(data) + + +def test_an_exemption_at_or_below_headroom_loads() -> None: + data = deep_merge(public_rules_dict(), {"openings": {"exempt_bottom_above_ft": 6.5}}) + assert rules_from_dict(data).rules.openings.exempt_bottom_above_ft == 6.5 diff --git a/server/tests/test_main_review.py b/server/tests/test_main_review.py new file mode 100644 index 000000000..20a9ab73e --- /dev/null +++ b/server/tests/test_main_review.py @@ -0,0 +1,217 @@ +"""Regression tests for the main-track review of #11 at 9edcd4b; each failed before its fix.""" + +import json +import math + +from fastapi.testclient import TestClient +from helpers import at_start, observed_band, pads_ground, parsed, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +import api +from rules import deep_merge, public_rules_dict, rules_from_dict +from solver import PASS, UNSURE, evaluate_start, solve + +W = 31 / 12 + + +# --- 1. wall equipment carries the battery's position error ----------------------------------- + + +def test_equipment_within_the_walls_error_is_not_a_pass() -> None: + # Before: a box 0.204 ft from the battery's edge passed, though the wall is known to ±0.5. + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["ground"] = pads_ground([(1, 30)]) + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [4.6, 5.6], + "bottom_ft": 4, + "top_ft": 5, + "source": "tape", + "plus_minus_ft": 0, + } + ] + check = at_start(raw, 1.8125, "wall_equipment_above", PUBLIC) + assert check.outcome == UNSURE + assert check.plus_minus >= 0.5 + + +# --- 2. a door that may or may not cross the route --------------------------------------------- + + +def door(span: list[float], wall_error: float = 0.0) -> dict: + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = wall_error + raw["objects"] = [ + { + "type": "door", + "wall_id": "w1", + "span_ft": span, + "bottom_ft": 0, + "top_ft": 7, + "source": "tape", + "plus_minus_ft": 0.5, + } + ] + return raw + + +def test_a_door_that_may_reach_the_route_is_not_a_pass() -> None: + # Before: its right edge at -0.4 ± 0.5 can reach past the meter at 0, yet the route passed. + assert at_start(door([-3.4, -0.4]), 6.0, "route_path", PUBLIC).outcome == UNSURE + + +def test_a_door_that_may_miss_the_route_is_not_a_reject() -> None: + # Before: its right edge at 0.1 ± 0.5 (wall ±0.5) can end left of the meter, yet every spot + # right of the meter failed and the scene was rejected. + raw = door([-2.9, 0.1], wall_error=0.5) + assert at_start(raw, 6.0, "route_path", PUBLIC).outcome == UNSURE + assert answer(raw)["decision"] != "reject" + + +def test_a_door_surely_across_the_route_still_fails() -> None: + assert at_start(door([1.0, 4.0]), 6.0, "route_path", PUBLIC).outcome == "fail" + + +# --- 3. the battery clearance reads the wall too ----------------------------------------------- + + +def six_ft_battery_rules(): + return rules_from_dict( + deep_merge( + public_rules_dict(), + {"clearances": {"battery_ft": {"value": 6.0, "source": "test"}}}, + ) + ) + + +def test_a_larger_battery_clearance_needs_the_wall_seen() -> None: + # Before: only ground was required, so a wall stretch unseen within 6 ft passed. + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 9.5), (13, 40)]) + check = at_start(raw, 6.0, "battery_clearance", six_ft_battery_rules()) + assert (check.outcome, check.unsure_cause) == (UNSURE, "unobserved") + assert any(v.band == "wall" for v in check.all_missing()) + + +def test_the_battery_check_is_left_out_only_when_the_gas_check_covers_it() -> None: + ids = {c.id for c in evaluate_start(parsed(shared_fixture(), PUBLIC), PUBLIC, 6.0).checks} + assert "battery_clearance" not in ids # battery_ft 3 <= gas_ft 3: gas reads both bands + loaded = six_ft_battery_rules() + ids = {c.id for c in evaluate_start(parsed(shared_fixture(), loaded), loaded, 6.0).checks} + assert "battery_clearance" in ids + + +# --- 4. start positions where the error grows with drift --------------------------------------- + + +def drift_window(lo: float = 8.005, hi: float = 8.030, z: float = 0.9) -> tuple[dict, float, float]: + """Two exact gas-meter points placed so a start passes only inside (lo, hi): each is 3 ft + plus the battery's error at that end away from the battery's nearest part. The error grows + 0.16 ft per foot from the meter (default wall error). `z` is how far out from the wall the + points stand; past the battery's depth the nearest part of it is a front corner.""" + + def e(s: float) -> float: + return 0.3 + 0.16 * (s + W) + + def along(s: float) -> float: + out = max(0.0, z - 22 / 12) + return math.sqrt((3 + e(s)) ** 2 - out**2) + + raw = shared_fixture() + del raw["walls"][0]["plus_minus_ft"] # default error with drift + raw["ground"] = pads_ground([(lo - e(lo) - 0.01, hi + W + e(hi) + 0.01)]) + raw["objects"] = [ + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [x, x], + "bottom_ft": 0, + "top_ft": 0.1, + "source": "tape", + "plus_minus_ft": 0, + "footprint": [[x, z]], + } + for x in (lo - along(lo), hi + W + along(hi)) + ] + return raw, lo, hi + + +def test_the_narrow_interval_is_found_with_the_meters_in_front() -> None: + # The caretaker's follow-up: the same interval with the gas points 1 ft in front of the + # battery, so the nearest part is a front corner and the distance runs diagonally. + raw, lo, hi = drift_window(z=22 / 12 + 1) + assert evaluate_start(parsed(raw, PUBLIC), PUBLIC, (lo + hi) / 2).outcome == PASS + assert solve(parsed(raw, PUBLIC), PUBLIC)["stats"]["pass"] > 0 + + +@settings(max_examples=25, deadline=None) +@given( + lo=st.floats(min_value=3.0, max_value=12.0), + width=st.floats(min_value=0.004, max_value=0.2), + z=st.floats(min_value=0.1, max_value=4.0), +) +def test_a_start_that_passes_is_found_by_the_solve(lo: float, width: float, z: float) -> None: + raw, lo, hi = drift_window(lo, lo + width, z) + scene = parsed(raw, PUBLIC) + if evaluate_start(scene, PUBLIC, (lo + hi) / 2).outcome == PASS: + assert solve(scene, PUBLIC)["stats"]["pass"] > 0 + + +def test_a_passing_interval_narrower_than_the_grid_is_found() -> None: + # Before: the start positions used the segment's largest error, so the passing interval + # (8.005, 8.030) had no start in it and the scene went to manual review. + raw, lo, hi = drift_window() + assert evaluate_start(parsed(raw, PUBLIC), PUBLIC, (lo + hi) / 2).outcome == PASS + result = solve(parsed(raw, PUBLIC), PUBLIC) + assert result["stats"]["pass"] > 0 + + +# --- 5. the site plan draws an existing battery ------------------------------------------------ + + +def test_the_site_plan_draws_an_existing_battery() -> None: + raw = shared_fixture() + raw["objects"] = [ + { + "wall_id": "w1", + "type": "battery", + "span_ft": [12, 14.6], + "source": "tape", + "plus_minus_ft": 0.05, + "footprint": rect(12, 14.6, 0, 1.83), + } + ] + resp = TestClient(api.app).post( + "/v1/placements/site-plan.svg", + content=json.dumps(raw), + headers={"content-type": "application/json"}, + ) + assert resp.status_code == 200 + assert "Battery" in resp.text + + +# --- 7. an exact-end span is labelled with its own side ---------------------------------------- + + +def test_a_span_starting_at_the_right_end_is_labelled_right() -> None: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [12, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, 12] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + raw["objects"] = [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [12, 20], + "bottom_ft": 3, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0.1, + } + ] + assert [side for _, _, side, _ in parsed(raw, PUBLIC).set_aside] == ["right"] diff --git a/server/tests/test_more_space.py b/server/tests/test_more_space.py new file mode 100644 index 000000000..57e7a9267 --- /dev/null +++ b/server/tests/test_more_space.py @@ -0,0 +1,156 @@ +"""Scoreboard S2-3: more space never makes an answer worse. + +The monotone statement the rules support is per check, at a fixed battery start (the chosen spot +itself may move, since another start can improve too). Rank PASS < UNSURE < FAIL. With +everything else fixed and the same start evaluated: + +1. Moving one ground-standing obstacle (a gas meter, AC unit or pool with a plan footprint) + straight out from the wall the battery backs onto, which can only increase its plan distance + from the footprint, never raises any check's rank, nor the start's overall outcome. +2. Adding one more observed span to any band never raises any check's rank, nor the start's + overall outcome. + +And the strict rule holds on both sides: a check that PASSes with a measurement clears its +threshold by more than its error, so a FAIL can only become a PASS once its margin exceeds its +error. +""" + +import copy + +import pytest +from helpers import parsed, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_final_review import cornered +from test_s4_round import PUBLIC + +from solver import EPS, PASS, evaluate_start + +RANK = {"pass": 0, "unsure": 1, "fail": 2} + + +def outcomes(raw: dict, s0: float) -> tuple[dict, str, list]: + candidate = evaluate_start(parsed(copy.deepcopy(raw), PUBLIC), PUBLIC, s0, "w1") + return {c.id: c.outcome for c in candidate.checks}, candidate.outcome, candidate.checks + + +def strict_rule_holds(checks: list) -> None: + for c in checks: + if c.outcome != PASS or c.measured is None or c.threshold is None: + continue + error = c.plus_minus or 0.0 + if c.comparison == "at_least": + assert c.measured - error - c.threshold > -EPS, c + elif c.comparison == "at_most": + assert c.threshold - (c.measured + error) > -EPS, c + + +def never_worse(before: tuple, after: tuple) -> None: + by_check, overall, checks = before + by_check_after, overall_after, checks_after = after + for check_id, outcome in by_check.items(): + assert RANK[by_check_after[check_id]] <= RANK[outcome], (check_id, outcome, by_check_after) + assert RANK[overall_after] <= RANK[overall], (overall, overall_after) + strict_rule_holds(checks) + strict_rule_holds(checks_after) + + +def first_wall_end(raw: dict) -> float: + return raw["walls"][0]["baseline"][1][0] + + +@settings(max_examples=40, deadline=None) +@given(raw=cornered(), data=st.data()) +def test_moving_an_obstacle_away_never_makes_a_check_worse(raw: dict, data) -> None: + end = first_wall_end(raw) + s0 = data.draw(st.floats(-25.0, end - 3.5), label="s0") + kind = data.draw(st.sampled_from(["gas_meter", "ac", "pool"]), label="kind") + x = data.draw(st.floats(-28.0, end), label="x") + z = data.draw(st.floats(0.0, 6.0), label="z") + farther = data.draw(st.floats(0.05, 8.0), label="farther") + error = data.draw(st.floats(0.0, 0.5), label="error") + + def with_obstacle(out: float) -> dict: + scene = copy.deepcopy(raw) + scene["objects"] = [ + { + "type": kind, + "wall_id": "w1", + "span_ft": [x, x + 1], + "bottom_ft": 0, + "top_ft": 1, + "source": "tape", + "plus_minus_ft": error, + "footprint": [[x, out], [x + 1, out], [x + 1, out + 1], [x, out + 1]], + } + ] + return scene + + never_worse(outcomes(with_obstacle(z), s0), outcomes(with_obstacle(z + farther), s0)) + + +@settings(max_examples=40, deadline=None) +@given(raw=cornered(), data=st.data()) +def test_seeing_more_never_makes_a_check_worse(raw: dict, data) -> None: + end = first_wall_end(raw) + s0 = data.draw(st.floats(-25.0, end - 3.5), label="s0") + band = data.draw(st.sampled_from(["wall", "ground", "overhead", "facing"]), label="band") + a = data.draw(st.floats(-35.0, end + 20), label="a") + b = data.draw(st.floats(a, end + 25), label="b") + item = {"band": band, "span_ft": [a, b]} + if band == "ground" or data.draw(st.booleans(), label="depth"): + item["out_ft"] = data.draw(st.floats(0.5, 20.0), label="out") + more = copy.deepcopy(raw) + more["coverage"]["observed"].append(item) + never_worse(outcomes(raw, s0), outcomes(more, s0)) + + +@pytest.mark.xfail( + strict=True, + reason="Open finding (listed, not fixed): an obstacle moved off the route turns UNSURE to FAIL", +) +def test_moving_a_gas_meter_off_the_route_start_never_makes_route_length_worse() -> None: + # Found by the property above in a random run (the same at 22292a8). Against the wall at + # x [0, 1] with +/- 0.5 ft, the gas meter may sit over the route's start at the meter, so the + # 20.42 ft run is +/- 1.1 ft of the 20 ft maximum: UNSURE. Moved 1 ft out it can't touch the + # route, the error drops to +/- 0.1 ft and the run is confidently over: FAIL. The FAIL is + # right; the statement above is stronger than the rules support when a move removes an error. + raw = cornered_example() + obstacle = { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [0, 1], + "bottom_ft": 0, + "top_ft": 1, + "source": "tape", + "plus_minus_ft": 0.5, + } + + def at(z: float) -> dict: + scene = copy.deepcopy(raw) + footprint = [[0, z], [1, z], [1, z + 1], [0, z + 1]] + scene["objects"] = [{**obstacle, "footprint": footprint}] + return scene + + never_worse(outcomes(at(0.0), -23.0), outcomes(at(1.0), -23.0)) + + +def cornered_example() -> dict: + """The shrunk scene: two collinear walls, limits at both ends, the ground seen 1 ft out.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.1}, + {"id": "w2", "baseline": [[4.0, 0], [24.0, 0.0]], "height_ft": 9, "plus_minus_ft": 0.1}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-30.0, 24.0]}, + {"band": "ground", "span_ft": [-30.0, 24.0], "out_ft": 1.0}, + {"band": "overhead", "span_ft": [-30.0, 24.0]}, + {"band": "facing", "span_ft": [-30.0, 24.0]}, + ], + } + return raw diff --git a/server/tests/test_policy_and_auth.py b/server/tests/test_policy_and_auth.py index e3dcce315..0d4acb201 100644 --- a/server/tests/test_policy_and_auth.py +++ b/server/tests/test_policy_and_auth.py @@ -49,11 +49,11 @@ def test_each_example_gets_its_decision_under_the_demo_policy(name: str, decisio def test_the_unsure_example_asks_for_views() -> None: - assert [ - m - for m in decide("review-corner-not-walked.json")["missing_evidence"] - if m["kind"] == "band" - ] + # The walk stopped at the unexplored right end, so the view it lacks is past that end. It + # used to ask for ground at s [-3.93, -3] as well, which its capture had already seen past + # the left limit end. + missing = decide("review-corner-not-walked.json")["missing_evidence"] + assert [m["kind"] for m in missing] == ["past_end"] def test_the_strict_policy_sends_everything_to_a_person(monkeypatch: pytest.MonkeyPatch) -> None: @@ -79,8 +79,11 @@ def test_private_rules_load_from_base64(monkeypatch: pytest.MonkeyPatch) -> None loaded = load_rules() assert loaded.sources == ("public", "private") assert loaded.rules.clearances.gas_ft.value == 4.0 - # The demo notice belongs to the public policy; private rules don't inherit it. - assert loaded.rules.policy.notice is None + # The demo notice belongs to the public policy; private rules replace it with one naming the + # checks still on public placeholders (this synthetic file sets only gas_ft). + notice = loaded.rules.policy.notice or "" + assert "not Base's" not in notice + assert "pool_clearance" in notice def test_bad_base64_is_a_startup_error(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/server/tests/test_properties.py b/server/tests/test_properties.py index 65ac89f0f..aae4ee9d6 100644 --- a/server/tests/test_properties.py +++ b/server/tests/test_properties.py @@ -124,7 +124,14 @@ def scene( wall_err: float = 0.0, drive: float | None = None, ) -> dict[str, Any]: - ground = pads_ground([pad], LO, HI) + # Deck past each end too, where a view past a limit end sees both sides: seen ground with + # no recorded surface may be a driveway (drive_clearance), so a fully observed scene records + # it. + ground = [ + *pads_ground([pad], LO, HI), + {"type": "deck", "polygon": rect(LO - 40, LO, -40, 40)}, + {"type": "deck", "polygon": rect(HI, HI + 40, -40, 40)}, + ] if drive is not None: ground.append({"type": "drive", "polygon": rect(drive, drive + 4, 0, 30)}) return { diff --git a/server/tests/test_review_hardening.py b/server/tests/test_review_hardening.py new file mode 100644 index 000000000..1737d4ff1 --- /dev/null +++ b/server/tests/test_review_hardening.py @@ -0,0 +1,429 @@ +"""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. 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 +import json +import zipfile +from pathlib import Path +from typing import Any + +import jsonschema +import pytest +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 +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, -12, -11), # far behind the gap, past the 10 ft rule + } + ] + 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": 2.0}}, PUBLIC.rules + ).reach_ft + assert wider == pytest.approx(base + 2.0) + + # 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 + 3, base + 3.5), + } + seen = {**raw, "objects": [pool]} + observed_band(seen, "ground", [(-40, 40)], out=base + 0.5) + 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 ----------------------------------- + + +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) + 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)] + 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.measured == 1.0 and c.threshold == c.rule.value + + +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) + + +# --- 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) diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py index b2b44672f..f7965b9f2 100644 --- a/server/tests/test_review_regressions.py +++ b/server/tests/test_review_regressions.py @@ -479,8 +479,9 @@ def test_sweep_runs_only_claim_evaluated_starts_on_one_piece() -> None: scene = parsed(raw) result = run(raw) for r in result["sweep"]: - piece = next(p for p in scene.walls if p.index == r["segment"]) + # The run's segment is the uploaded one; the piece it was evaluated on holds its start. a, b = r["start_ft"] + piece = next(p for p in scene.walls if p.s0 - 1e-6 <= a < p.s1 - 1e-6) assert piece.s0 - 1e-6 <= a <= b <= max(piece.s1 - W, piece.s0) + 1e-6, r if r["outcome"] == "pass": for s0 in (a, (a + b) / 2, b): diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index 560c7fb1a..dce7a06ce 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -6,6 +6,7 @@ from helpers import at_start, observed_band, parsed, shared_fixture from hypothesis import given, settings from hypothesis import strategies as st +from shapely.geometry import Point import solver from rules import LoadedRules, public_rules_dict, rules_from_dict @@ -114,6 +115,47 @@ def test_a_captured_request_never_comes_back(raw: dict) -> None: raw = captured(raw, result) +def request_past_a_limit_end() -> dict: + """One wall from s = -0.5 to 12, a limit at the left end and an unexplored right end whose + disc (radius reach, about 12.8 ft) reaches past the left end.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-0.5, 0], [12.0, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-0.5, 12.0] + raw["coverage"]["ends"] = {"left": {"kind": "limit"}, "right": {"kind": "unexplored"}} + for band in ("wall", "ground", "overhead", "facing"): + observed_band(raw, band, [(-0.5, 12.0)], 1.0) + return raw + + +def test_a_request_past_a_limit_end_is_settled_by_capturing_it() -> None: + # Before: the first answer asked for ground from s = -4.01, past the left limit end; after + # that capture [-0.833, -0.5] 2.85 ft out (pool_clearance) was asked for on every round. + # That ground is in the right end's disc, where ground seen past the limit end didn't count. + raw = request_past_a_limit_end() + for _ in range(3): + result = answer(raw) + assert repeated_requests(raw, result) == [], result["missing_evidence"] + raw = captured(raw, result) + + +def test_ground_seen_past_a_limit_end_counts_in_an_unexplored_ends_disc() -> None: + # Inside the right end's disc, past the left limit end: seen once a view there reaches it. + raw = request_past_a_limit_end() + probe = Point(-0.7, 1.5) + assert parsed(raw, PUBLIC).unobserved_ground().contains(probe) + raw["coverage"]["observed"].append({"band": "ground", "span_ft": [-1.0, -0.5], "out_ft": 2.0}) + assert not parsed(raw, PUBLIC).unobserved_ground().contains(probe) + + +def test_ground_seen_past_an_unexplored_end_still_counts_for_nothing() -> None: + # Past the unexplored right end the extended line is a guess, so a view along it shows + # nothing about where the walls went. + raw = request_past_a_limit_end() + probe = Point(13.0, 1.5) + raw["coverage"]["observed"].append({"band": "ground", "span_ft": [12.0, 15.0], "out_ft": 5.0}) + assert parsed(raw, PUBLIC).unobserved_ground().contains(probe) + + # --- 2. ground past a limit end is not clear until it is seen ------------------------------------ diff --git a/server/tests/test_smoke_script.py b/server/tests/test_smoke_script.py index 079c785a5..4835b0106 100644 --- a/server/tests/test_smoke_script.py +++ b/server/tests/test_smoke_script.py @@ -4,6 +4,7 @@ import http.server import importlib.util import threading +import urllib.request from pathlib import Path from typing import ClassVar @@ -85,3 +86,14 @@ def test_a_keyed_request_does_not_follow_a_redirect() -> None: finally: target.shutdown() redirecting.shutdown() + + +def test_a_keyed_request_never_goes_through_a_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + # CodeRabbit on 9edcd4b: with http_proxy set, a keyed request to localhost could go through + # the proxy, key included. Load the script with a proxy configured: the keyed opener must + # still have no proxy handler (an empty proxy table registers none). + monkeypatch.setenv("http_proxy", "http://127.0.0.1:9") + monkeypatch.setenv("https_proxy", "http://127.0.0.1:9") + fresh = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fresh) + assert not [h for h in fresh._KEYED.handlers if isinstance(h, urllib.request.ProxyHandler)] diff --git a/server/tests/test_within_errors.py b/server/tests/test_within_errors.py new file mode 100644 index 000000000..0490dafe5 --- /dev/null +++ b/server/tests/test_within_errors.py @@ -0,0 +1,603 @@ +"""A PASS holds in every world the declared errors allow. + +A scene is what the phone measured, each position to within its declared error. A check that +PASSes must stay clear of its rule wherever the true positions lie within those errors, so its +error has to include the error of everything its measurement depends on, not only the candidate +wall's. The property moves every input within its declared error and re-evaluates the same +battery: + +- The battery stays where the answer puts it: at its offset from the meter, where the AR view + anchors it (AGENTS.md), flush against its own wall. +- A position moves in its own coordinates: wall ends and corners, the meter, and object and + ground outlines anywhere within their error in plan; an object's span along s by its error; + a measured height or depth by its error. The contract defines an error as a distance ("the true + value lies within this distance"), so the extremes lie on a circle of that radius (sixteen + directions), not at the corners of a box, which are sqrt(2) times further. A corner shared by two + walls moves as one point, within both walls' errors. +- Coverage spans have no declared error and keep their s. +- Moves that contradict each other (the meter past the end of its own wall) describe no real + house and are discarded. + +Then, for every check that PASSed: in the perturbed scene read as exact (every error zero, so the +check reports whether the rule truly holds there) it still PASSes, its measurement on the pass +side of every threshold, the confident reach as well as the maximum. Only a measurement exactly +on a threshold (within float noise) may read as a tie there. In the perturbed scene with its +declared errors it is not FAIL. +""" + +import copy +import math + +import pytest +from helpers import parsed, rect, shared_fixture +from hypothesis import assume, given, reject, settings, target +from hypothesis import strategies as st +from shapely.geometry import Point +from test_final_review import cornered +from test_s4_round import PUBLIC + +from scene import SceneError +from solver import FAIL, PASS, UNSURE, evaluate_start, solve + +W = PUBLIC.rules.battery.width_ft.value +DIRECTIONS = [(math.cos(k * math.pi / 8), math.sin(k * math.pi / 8)) for k in range(16)] +# A measurement this close to a threshold is on it: float noise, not a side. +TIE_FT = 1e-9 +OBJECT_TYPES = ["gas_meter", "ac", "pool", "window", "door", "elec_box", "vent", "battery"] +ERROR = st.sampled_from([0.0, 0.05, 0.1, 0.2, 0.3]) + + +def error_of(item: dict) -> float: + return item.get("plus_minus_ft", 0.0) + + +@st.composite +def uncertain_corners(draw: st.DrawFn) -> dict: + """The fixed-point test's corner scenes with the meter anywhere up to the corner, declared + errors on the walls, the meter and every object, objects with and without plan outlines, a + second ground patch, measured overheads and facing gaps, and, half the time, everything + observed so checks can pass.""" + raw = draw(cornered()) + # Anywhere along its wall up to the corner, so a battery round the corner can come near the + # meter's working space. + corner = raw["walls"][0]["baseline"][1][0] + raw["meter"]["pos"][0] = draw(st.floats(-3.0, corner)) + scene = parsed(raw, PUBLIC) + # Two walls a join treats as one straight wall have no corner to move. + assume(sum(1 for p in scene.walls) == 2) + for wall in raw["walls"]: + wall["plus_minus_ft"] = draw(ERROR) + raw["meter"]["plus_minus_ft"] = draw(ERROR) + lo, hi = scene.walls[0].s0, scene.walls[-1].s1 + if draw(st.booleans()): + raw["coverage"]["observed"] = [ + {"band": b, "span_ft": [lo, hi], **({"out_ft": 30.0} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ] + + def wall_at(s: float): + return next(p for p in scene.walls if p.s0 - 1e-9 <= s <= p.s1 + 1e-9) + + objects = [] + for _ in range(draw(st.integers(0, 3))): + kind = draw(st.sampled_from(OBJECT_TYPES)) + a = draw(st.floats(lo, hi - 1.0)) + length = draw(st.floats(0.5, 4.0)) + b = min(a + length, hi) + wall = wall_at(a) + obj = { + "type": kind, + "wall_id": wall.wall_id, + "span_ft": [a, b], + "bottom_ft": draw(st.sampled_from([0.0, 1.0, 3.0])), + "top_ft": 7.0, + "source": "tape", + "plus_minus_ft": draw(ERROR), + } + if kind in ("gas_meter", "ac", "pool", "battery") and draw(st.booleans()): + out = draw(st.floats(0.0, 8.0)) + depth = draw(st.floats(0.5, 3.0)) + # A rectangle along the wall its span starts on (one crossing the corner keeps going + # straight rather than folding into a self-intersecting outline). + corners = [(a, out), (b, out), (b, out + depth), (a, out + depth)] + obj["footprint"] = [list(wall.point(s, o)) for s, o in corners] + if kind == "window": + obj["attrs"] = {"operable": draw(st.booleans())} + objects.append(obj) + raw["objects"] = objects + if draw(st.booleans()): + kind = draw(st.sampled_from(["drive", "deck", "concrete"])) + x0, z0 = draw(st.floats(-20, 20)), draw(st.floats(0.5, 12)) + raw["ground"].append( + {"type": kind, "polygon": rect(x0, x0 + 6, z0, z0 + 4), "plus_minus_ft": draw(ERROR)} + ) + for key, value_key in (("overheads", "clearance_ft"), ("facing", "depth_ft")): + raw[key] = [] + for _ in range(draw(st.integers(0, 2))): + a = draw(st.floats(lo, hi - 1.0)) + raw[key].append( + { + "wall_id": wall_at(a).wall_id, + "span_ft": [a, min(a + draw(st.floats(0.5, 6.0)), hi)], + value_key: draw(st.floats(1.0, 12.0)), + "plus_minus_ft": draw(ERROR), + } + ) + return raw + + +RULE_OF = { + "gas_meter": "gas_ft", + "ac": "ac_ft", + "pool": "pool_ft", + "battery": "battery_ft", + "window": "opening_ft", + "door": "opening_ft", + "elec_box": "wall_equipment_ft", + "vent": "wall_equipment_ft", +} + + +def near_misses(raw: dict, piece, s0: float, data) -> dict: + """Up to two objects just past their rule from this battery, by a fraction of the errors + (up to twice the largest), beside it along the wall or in front of it: margins where an error + left out of a check shows.""" + out = copy.deepcopy(raw) + errors = [error_of(out["meter"]), *(error_of(w) for w in out["walls"])] + for i in range(data.draw(st.integers(0, 2), label="near misses")): + kind = data.draw(st.sampled_from(sorted(RULE_OF)), label=f"near {i} type") + rule = getattr(PUBLIC.rules.clearances, RULE_OF[kind]).value + gap = rule + data.draw(st.floats(0.0, 2.0), label=f"near {i} margin") * max(errors) + e = data.draw(ERROR, label=f"near {i} error") + obj = { + "type": kind, + "wall_id": piece.wall_id, + "bottom_ft": 3.0, + "top_ft": 7.0, + "source": "tape", + "plus_minus_ft": e, + } + where = data.draw( + st.sampled_from(["left", "right", "front", "other wall"]), label=f"near {i} where" + ) + others = [p for p in parsed(out, PUBLIC).walls if p.wall_id != piece.wall_id] + if where == "other wall" and others: + # On another wall's line, no outline, where its plan distance from the battery is + # just past the rule: it moves with that wall and the corners between. + other = others[0] + fp = piece.rect(s0, s0 + W, 0.0, PUBLIC.rules.battery.depth_ft.value) + near = [ + s + for s in (other.s0 + x * 0.05 for x in range(int((other.s1 - other.s0) / 0.05))) + if fp.distance(Point(other.point(s))) >= gap + e + ] + if not near: + continue + a = min(near, key=lambda s: fp.distance(Point(other.point(s)))) + obj["wall_id"] = other.wall_id + obj["span_ft"] = [a, a + 0.05] + elif where == "front" and kind in ("gas_meter", "ac", "pool", "battery"): + a = s0 + data.draw(st.floats(-1.0, W), label=f"near {i} along") + o = PUBLIC.rules.battery.depth_ft.value + gap + e + corners = [(a, o), (a + 1, o), (a + 1, o + 1), (a, o + 1)] + obj["span_ft"] = [a, a + 1] + obj["footprint"] = [list(piece.point(s, v)) for s, v in corners] + elif where == "left": + obj["span_ft"] = [s0 - gap - e - 1, s0 - gap - e] + else: + obj["span_ft"] = [s0 + W + gap + e, s0 + W + gap + e + 1] + if kind == "window": + obj["attrs"] = {"operable": True} + out["objects"].append(obj) + return out + + +def moved(point: list[float], radius: float, k: int | None) -> list[float]: + if k is None: + return list(point) + dx, dz = DIRECTIONS[k] + return [point[0] + radius * dx, point[1] + radius * dz] + + +def corner_moved(point: list[float], wall: dict, nxt: dict, next_error: float, k) -> list[float]: + """A corner two walls share, moved in direction k by up to the larger of their errors, as + far as it stays within each wall's error across that wall's line: along an exact wall it may + still move by the other wall's error (the caretaker's hazard-wall case).""" + if k is None: + return list(point) + dx, dz = DIRECTIONS[k] + limits = [] + for w, e in ((wall, error_of(wall)), (nxt, next_error)): + (ax, az), (bx, bz) = w["baseline"][0], w["baseline"][-1] + length = math.hypot(bx - ax, bz - az) + across = abs(dx * -(bz - az) / length + dz * (bx - ax) / length) + limits.append(e / across if across > 1e-12 else math.inf) + radius = min(max(error_of(wall), next_error), *limits) + return [point[0] + radius * dx, point[1] + radius * dz] + + +def perturbed(raw: dict, data) -> tuple[dict, tuple[float, float]]: + """The scene with every input moved within its declared error (the docstring's model), and + the meter's plan displacement.""" + out = copy.deepcopy(raw) + direction = st.none() | st.integers(0, len(DIRECTIONS) - 1) + sign = st.sampled_from([-1, 0, 1]) + walls, given = out["walls"], raw["walls"] + + def joined(i: int) -> bool: + """Whether wall i starts exactly where wall i - 1 ends: one corner, moved once.""" + return i > 0 and given[i - 1]["baseline"][-1] == given[i]["baseline"][0] + + for i, wall in enumerate(walls): + pts = wall["baseline"] + for j in range(len(pts)): + if j == 0 and joined(i): + pts[0] = list(walls[i - 1]["baseline"][-1]) + continue + k = data.draw(direction, label=f"wall {i} point {j}") + if j == len(pts) - 1 and i + 1 < len(walls) and joined(i + 1): + pts[j] = corner_moved(pts[j], wall, given[i + 1], error_of(walls[i + 1]), k) + else: + pts[j] = moved(pts[j], error_of(wall), k) + m = out["meter"] + k = data.draw(direction, label="meter") + x, _, z = m["pos"] + mx, mz = moved([x, z], error_of(m), k) + m["pos"] = [mx, m["pos"][1], mz] + for i, obj in enumerate(out["objects"]): + e = error_of(obj) + shift = data.draw(sign, label=f"object {i} span") * e + obj["span_ft"] = [obj["span_ft"][0] + shift, obj["span_ft"][1] + shift] + if "footprint" in obj: + k = data.draw(direction, label=f"object {i} outline") + if k is not None: + d = moved([0.0, 0.0], e, k) + obj["footprint"] = [[p[0] + d[0], p[1] + d[1]] for p in obj["footprint"]] + for i, patch in enumerate(out["ground"]): + k = data.draw(direction, label=f"ground {i}") + if k is not None: + d = moved([0.0, 0.0], error_of(patch), k) + patch["polygon"] = [[p[0] + d[0], p[1] + d[1]] for p in patch["polygon"]] + for key, value_key in (("overheads", "clearance_ft"), ("facing", "depth_ft")): + for i, entry in enumerate(out[key]): + change = data.draw(sign, label=f"{key} {i}") * error_of(entry) + entry[value_key] = max(0.0, entry[value_key] + change) + return out, (mx - x, mz - z) + + +def exact(raw: dict) -> dict: + """The same scene read as exact: every declared error zero.""" + out = copy.deepcopy(raw) + for item in [out["meter"], *out["walls"], *out["objects"], *out["ground"]]: + item["plus_minus_ft"] = 0.0 + for item in [*out["overheads"], *out["facing"]]: + item["plus_minus_ft"] = 0.0 + return out + + +def same_battery(raw: dict, s0: float, wall_id: str, moved_raw: dict, meter_shift) -> dict: + """The battery at its offset from the meter in the perturbed scene: the point of the wall + behind its middle moves with the meter, and it stands flush against the moved wall there. + Returns its checks by id.""" + scene = parsed(raw, PUBLIC) + candidate = evaluate_start(scene, PUBLIC, s0, wall_id) + middle = candidate.piece.point(s0 + W / 2) + target = (middle[0] + meter_shift[0], middle[1] + meter_shift[1]) + other = parsed(moved_raw, PUBLIC) + # A move within the errors can make two nearly collinear walls one straight wall, which keeps + # the first wall's id. + pieces = [p for p in other.walls if p.wall_id == candidate.piece.wall_id] or other.walls + piece = min(pieces, key=lambda p: abs(p.local(target)[1])) + s_mid = piece.local(target)[0] + # The round trip through plan coordinates adds float noise (1e-16 ft) that can cross the + # solver's 1e-9 ft thresholds; a start that didn't move keeps its value. Every nonzero error + # here is 0.05 ft or more. + start = s0 if abs(s_mid - W / 2 - s0) < 1e-6 else s_mid - W / 2 + result = evaluate_start(other, PUBLIC, start, piece.wall_id) + return {c.id: c for c in result.checks} + + +@settings(max_examples=40, deadline=None) +@given(raw=uncertain_corners(), data=st.data()) +def test_a_pass_holds_wherever_the_declared_errors_put_things(raw: dict, data) -> None: + scene = parsed(raw, PUBLIC) + piece = data.draw(st.sampled_from(scene.walls), label="wall") + assume(piece.s1 - piece.s0 >= W) + s0 = data.draw(st.floats(piece.s0, piece.s1 - W), label="s0") + if data.draw(st.integers(0, 2), label="near the confident reach") == 0: + # The route just short of the confident reach, by a fraction of the errors, where a route + # error left out shows (the caretaker's witness): shift the battery by what its nominal + # route lacks. + reach = PUBLIC.rules.route.confident_reach_ft.value + errors = [error_of(raw["meter"]), *(error_of(w) for w in raw["walls"])] + gap = data.draw(st.floats(0.0, 1.0), label="short of the reach") * 3 * max(errors) + length = evaluate_start(scene, PUBLIC, s0, piece.wall_id).route.length + start = s0 + (1 if piece.s0 >= 0 else -1) * (reach - gap - length) + if piece.s0 <= start <= piece.s1 - W: + s0 = start + raw = near_misses(raw, piece, s0, data) + scene = parsed(raw, PUBLIC) + before = {c.id: c for c in evaluate_start(scene, PUBLIC, s0, piece.wall_id).checks} + passing = [cid for cid, c in before.items() if c.outcome == PASS] + assume(passing) + moved_raw, shift = perturbed(raw, data) + try: + declared = same_battery(raw, s0, piece.wall_id, moved_raw, shift) + truth = same_battery(raw, s0, piece.wall_id, exact(moved_raw), shift) + except SceneError: + # Moves that contradict each other (the meter past the end of its own wall) describe no + # real house. + reject() + # Steer the search toward near misses: the least slack any passing check keeps in the exact + # scene. + slack = [slack_of(truth[cid]) for cid in passing if slack_of(truth[cid]) is not None] + if slack: + target(-min(slack), label="closest to breaking") + for cid in passing: + c = declared[cid] + assert c.outcome != FAIL, (cid, "declared errors", c.measured, c.plus_minus, c.reason) + c = truth[cid] + slack = slack_of(c) + tie = c.outcome == UNSURE and slack is not None and abs(slack) <= TIE_FT + assert c.outcome == PASS or tie, (cid, "exact", c.measured, c.reason) + + +def slack_of(c) -> float | None: + """How far a check's measurement is from the nearest threshold it must clear (the maximum, + and the confident reach for the route), positive on the pass side.""" + if c.measured is None or c.threshold is None: + return None + if c.comparison == "at_least": + return c.measured - c.threshold + lines = [c.threshold] + ([c.review_threshold] if c.review_threshold is not None else []) + return min(lines) - c.measured + + +def test_a_scene_whose_overlays_meet_at_a_far_corner_is_answered() -> None: + # Found by the property: these exact walls (w1's ends moved within 0.3 ft of a corner scene) + # made GEOS raise "side location conflict" while subtracting the seen ground, a 500 for a + # valid scene. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.0 + corner = [8.382847167508826, -0.035355339059327376] + raw["walls"] = [ + { + "id": "w1", + "baseline": [[-30.212132034355964, 0.21213203435596426], corner], + "height_ft": 9, + "plus_minus_ft": 0.0, + }, + { + "id": "w2", + "baseline": [corner, [23.96112173570757, -12.586407820996747]], + "height_ft": 9, + "plus_minus_ft": 0.0, + }, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + end = 28.418202506568154 + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": b, "span_ft": [-30.0, end], **({"out_ft": 1.0} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ], + } + assert solve(parsed(raw, PUBLIC), PUBLIC)["decision"] in ("pass", "reject", "manual_review") + + +def meter_corner(meter_wall_z: float = 0.0, meter_wall_error: float = 0.5) -> dict: + """The caretaker's witness: the meter on w1 [[-4, 0], [2, 0]] (+/- 0.5 ft), the battery on the + exact wall w2 [[2, 0], [2, 10]] over lawn, everything seen.""" + raw = shared_fixture() + raw["meter"] = {"pos": [0.0, 5.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0} + raw["walls"] = [ + { + "id": "w1", + "baseline": [[-4, meter_wall_z], [2, meter_wall_z]], + "height_ft": 9, + "plus_minus_ft": meter_wall_error, + }, + {"id": "w2", "baseline": [[2, 0], [2, 10]], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(0.1, 2.1, 3.2, 6), "plus_minus_ft": 0.0}] + raw["objects"], raw["overheads"], raw["facing"] = [], [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": b, "span_ft": [-4, 12], **({"out_ft": 20} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ], + } + return raw + + +def working_space(raw: dict, s0: float = 5.2): + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, s0, "w2").checks + return next(c for c in checks if c.id == "meter_working_space") + + +def test_the_meter_walls_error_counts_toward_its_working_space() -> None: + # Before: 0.2 ft clear (+/- 0) was a PASS, though the working space is drawn in front of w1, + # which may lie 0.5 ft closer to the battery. + c = working_space(meter_corner()) + assert (c.measured, c.outcome) == (pytest.approx(0.2), "unsure") + assert c.plus_minus >= 0.5 + + +def test_a_battery_in_the_working_space_from_another_wall_fails() -> None: + # w1 moved 0.3 ft toward the battery, read as exact: the battery on w2 still starts at + # z = 3.2 (s = 5.5, as w2 now follows a 0.3 ft gap) and stands 0.1 ft inside the working + # space. Before, the overlap was measured along s as 0, a tie, never a FAIL. + c = working_space(meter_corner(meter_wall_z=0.3, meter_wall_error=0.0), s0=5.5) + assert c.measured == pytest.approx(-0.1) + assert c.outcome == "fail" + + +def test_the_meters_error_counts_toward_where_the_battery_stands() -> None: + # Found by the property: on the exact wall w2 the battery ended 0.042 ft short of the wall's + # end, a PASS, but it is placed by its offset from the meter, and the meter (+/- 0.05 ft) may + # stand 0.05 ft further along: the battery then runs past the end. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.05 + end = [25.121953903127825, 0.34904812874567026] + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [5.125, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[5.125, 0], end], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 22.5, "w2").checks + backing = next(c for c in checks if c.id == "wall_backing") + assert backing.outcome == "unsure" + + +def test_a_meter_move_slides_a_battery_round_the_corner_along_its_wall() -> None: + # The meter (+/- 0.3 ft) on w1 and the battery on the exact wall w2 at a right angle: a box + # on w2 cleared the battery along the wall by 0.35 ft, a PASS with 0.3 ft of error. Moving + # the meter 0.3 ft at 135 degrees moves s = 0 back along w1 and the battery up w2, 0.42 ft in + # s together (0.3 * sqrt(2)), and the box then sits over the battery. + t = PUBLIC.rules.clearances.wall_equipment_ft.value + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.3 + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4.0, 0], [4.0, 20]], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + box = 10.0 + W + t + 0.35 + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w2", + "span_ft": [box, box + 1], + "bottom_ft": 3, + "top_ft": 5, + "source": "tape", + "plus_minus_ft": 0.0, + } + ] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 10.0, "w2").checks + above = next(c for c in checks if c.id == "wall_equipment_above") + assert (above.measured, above.outcome) == (pytest.approx(0.35), "unsure") + + +def test_ground_requests_ignore_lines_left_where_edges_touch() -> None: + # Reduced from a simulator capture: with the snapped overlays, clipping its unseen ground to + # a check's radius left a line beside the area, and the next overlay raised "mixed-dimension". + raw = { + "meter": {"pos": [5.3468, 4.9213, 15.6219], "wall_id": "wall", "plus_minus_ft": 0.9843}, + "walls": [{"id": "wall", "baseline": [[2.7079, 4.9432], [8.9454, 30.1839]]}], + "objects": [ + { + "type": "window", + "wall_id": "wall", + "span_ft": [6.7257, 9.6785], + "bottom_ft": 3.1168, + "top_ft": 6.0696, + "source": "tap", + "plus_minus_ft": 0.9843, + "attrs": {"operable": True}, + } + ], + "ground": [], + "overheads": [], + "facing": [], + "coverage": { + "ends": {"left": {"kind": "unexplored"}, "right": {"kind": "unexplored"}}, + "observed": [{"band": "ground", "span_ft": [-1.5, 0], "out_ft": 3.5}], + }, + } + assert solve(parsed(raw, PUBLIC), PUBLIC)["decision"] == "manual_review" + + +def route_corner(meter: tuple[float, float] = (0.0, 0.0), meter_error: float = 0.3) -> dict: + """The caretaker's route witness: exact right-angle walls, the meter (+/- 0.3 ft) on w1's + line, lawn only where the battery stands on w2, everything seen.""" + raw = shared_fixture() + raw["meter"] = {"pos": [meter[0], 5.0, meter[1]], "wall_id": "w1", "plus_minus_ft": meter_error} + raw["walls"] = [ + {"id": "w1", "baseline": [[-4, 0], [4, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4, 0], [4, 20]], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(1.5, 4.5, 9.5, 13.5), "plus_minus_ft": 0.0}] + raw["objects"], raw["overheads"], raw["facing"] = [], [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": b, "span_ft": [-40, 40], **({"out_ft": 30} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ], + } + return raw + + +def test_a_meter_move_across_and_along_its_wall_lengthens_the_route_by_sqrt_5_e() -> None: + # Before: the route to [13.924, 16.508] passed within the 15 ft confident reach, bounded + # above by 14.944 ft with the meter's error counted as e * sqrt(1 + k^2) = sqrt(3) * e. Moving + # the meter (-0.212, +0.212), within its 0.3 ft, slides the battery up w2 by dz - dx and puts + # the meter dz off its wall: 15.061 ft read as exact, past the reach. The worst move gives + # sqrt(5) * e. + c = next( + c + for c in evaluate_start(parsed(route_corner(), PUBLIC), PUBLIC, 13.924, "w2").checks + if c.id == "route_length" + ) + assert c.outcome == "unsure" + moved = exact(route_corner(meter=(-0.212, 0.212))) + truth = same_battery(route_corner(), 13.924, "w2", moved, (-0.212, 0.212))["route_length"] + assert truth.measured > 15.0 + assert c.measured + c.plus_minus >= truth.measured + + +def test_the_wall_above_the_battery_is_seen_wherever_the_battery_may_stand() -> None: + # Found by the strict property: the wall seen from s = 15 on, the battery on w2 from 15, the + # meter +/- 0.2 on w1. Moving the meter 0.2 ft along w1 slides the battery 0.011 ft onto + # unseen wall, where a box could hang; the check only looked over [s0, s1]. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.2 + end = [22.910371511986337, 6.511363089143134] + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4.0, 0], end], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + raw["coverage"]["observed"] = [ + o for o in raw["coverage"]["observed"] if o["band"] != "wall" + ] + [{"band": "wall", "span_ft": [15.0, 24.0]}] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 15.0, "w2").checks + above = next(c for c in checks if c.id == "wall_equipment_above") + assert above.outcome == "unsure" + assert above.unsure_cause == "unobserved" + + +def test_a_hazard_on_another_walls_line_moves_with_that_wall() -> None: + # The caretaker's class (3.037 PASS became 2.951 FAIL at 3baa338): the meter and battery on + # exact w1, a gas meter with no outline on w2 (+/- 0.3 ft), 3.037 ft away. It lies on w2's + # line at its s: moving w2's corner 0.3 ft along w1 and its far end back puts it 2.866 ft + # from the battery, read as exact. Its check left w2's error out. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.0 + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4.0, 0], [4.0, 20]], "height_ft": 9, "plus_minus_ft": 0.3}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["objects"] = [ + { + "type": "gas_meter", + "wall_id": "w2", + "span_ft": [8.52, 9.52], + "bottom_ft": 0, + "top_ft": 3, + "source": "tape", + "plus_minus_ft": 0.0, + } + ] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 0.0, "w1").checks + gas = next(c for c in checks if c.id == "gas_clearance") + assert gas.measured == pytest.approx(3.0373, abs=1e-3) + assert gas.outcome == "unsure" + moved = copy.deepcopy(raw) + moved["walls"][0]["baseline"][1] = [4.3, 0] + moved["walls"][1]["baseline"] = [[4.3, 0], [3.7, 20]] + truth = same_battery(raw, 0.0, "w1", exact(moved), (0.0, 0.0))["gas_clearance"] + assert truth.measured < 3.0 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 000000000..7f639d2d1 Binary files /dev/null and b/smoke/placement-request.zip differ 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 diff --git a/tools/scene-witness-reducer/conftest.py b/tools/scene-witness-reducer/conftest.py new file mode 100644 index 000000000..e9263cb8c --- /dev/null +++ b/tools/scene-witness-reducer/conftest.py @@ -0,0 +1,6 @@ +"""Make the tool package importable from the tests without installing anything: +the reducer is a flat directory of modules, not a distribution.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) diff --git a/tools/scene-witness-reducer/contract.py b/tools/scene-witness-reducer/contract.py new file mode 100644 index 000000000..3fea2c840 --- /dev/null +++ b/tools/scene-witness-reducer/contract.py @@ -0,0 +1,272 @@ +"""Executable predicate contracts for scene-witness reduction. + +A contract declares how to run a predicate program once on one scene file: a plain +executable name, an argv list with exactly one scene placeholder, and an exit-code +protocol. `evaluate` runs it and classifies the result into exactly three outcomes: + +- REPRODUCES — an exit code listed under `reproduces_exit_codes` +- NOT_REPRODUCES — an exit code listed under `not_reproduces_exit_codes` +- UNKNOWN — anything else: undeclared exit codes, signals, timeouts, spawn + failures. Unknown never counts as a refusal and never as a + reproduction; the reducer treats it as "this candidate told us + nothing". + +There is no shell anywhere: the contract is argv, passed to subprocess with +`shell=False`. Contracts are plain JSON, validated strictly so a typo fails loudly +instead of silently changing what runs. +""" + +from __future__ import annotations + +import enum +import json +import signal +import subprocess +from dataclasses import dataclass +from pathlib import Path + +_CONTRACT_VERSION = "1.0" +_MAX_TIMEOUT_S = 600 +_ALLOWED_KEYS = { + "contract_version", "description", "executable", "args", "scene_placeholder", + "cwd", "outcome", "timeout_seconds", "determinism_repeats", +} +_SHELL_CHARS = set("|&;<>()$`\\\"'*?~#") + + +class ContractError(ValueError): + """The contract file is malformed or unsafe.""" + + +@dataclass(frozen=True) +class Contract: + executable: str + args: tuple[str, ...] + scene_placeholder: str + reproduces_exit_codes: frozenset[int] + not_reproduces_exit_codes: frozenset[int] + timeout_seconds: float + determinism_repeats: int + cwd: str + description: str = "" + path: str = "" + + def argv_for(self, scene_path: str | Path) -> list[str]: + """Full argv for one run: the executable, then the args with the placeholder + argument replaced by the scene path.""" + return [self.executable] + [ + scene_path if a == self.scene_placeholder else a for a in self.args + ] + + +class Outcome(enum.Enum): + REPRODUCES = "reproduces" + NOT_REPRODUCES = "not_reproduces" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class Evaluation: + """One classified predicate run. `transcript()` is the record kept in evidence: + everything the run did, and never how long it took.""" + outcome: Outcome + exit_code: int | None + signal_name: str | None + timed_out: bool + stdout: str + stderr: str + unknown_reason: str | None + spawn_error: str | None + + def transcript(self) -> dict: + return { + "stdout": self.stdout, + "stderr": self.stderr, + "exit_code": self.exit_code, + "signal": self.signal_name, + "timed_out": self.timed_out, + "unknown_reason": self.unknown_reason, + "spawn_error": self.spawn_error, + } + + +def _check_exit_codes(raw: object, field: str) -> list[int]: + if raw is None: + return [] + if (not isinstance(raw, list) or not raw + or not all(isinstance(c, int) and not isinstance(c, bool) and 0 <= c < 256 + for c in raw)): + raise ContractError(f"{field} must be a nonempty list of exit codes 0..255") + return list(dict.fromkeys(raw)) + + +def load_contract(path: Path) -> Contract: + """Parse and validate a contract file. Unknown fields are rejected, so a typo in + the contract fails loudly instead of silently changing what runs.""" + path = Path(path) + try: + raw = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"{path}: unreadable contract ({exc})") from exc + _require(isinstance(raw, dict), f"{path}: contract must be a JSON object") + assert isinstance(raw, dict) + unknown = sorted(set(raw) - _ALLOWED_KEYS) + _require(not unknown, f"{path}: unknown contract fields {unknown}") + _require(raw.get("contract_version") == _CONTRACT_VERSION, + f"{path}: contract_version must be {_CONTRACT_VERSION!r}, " + f"saw {raw.get('contract_version')!r}") + + executable = raw.get("executable") + _require(isinstance(executable, str) and executable, f"{path}: executable must be a name") + assert isinstance(executable, str) + _require(not (set(executable) & _SHELL_CHARS), + f"{path}: executable {executable!r} must be a plain program name, not shell text") + + placeholder = raw.get("scene_placeholder") + _require(isinstance(placeholder, str) and placeholder, + f"{path}: scene_placeholder must be a nonempty string") + assert isinstance(placeholder, str) + _require(not (set(placeholder) & set(" \t\r\n")), + f"{path}: scene_placeholder must be one argument, without whitespace") + + args = raw.get("args") + _require(isinstance(args, list) and args and all(isinstance(a, str) for a in args), + f"{path}: args must be a nonempty list of strings (argv, never shell text)") + assert isinstance(args, list) and all(isinstance(a, str) for a in args) + hits = [i for i, a in enumerate(args) if a == placeholder] + _require(len(hits) == 1, + f"{path}: exactly one argument must equal the scene placeholder {placeholder!r}") + _require(all(placeholder not in a for i, a in enumerate(args) if i not in hits), + f"{path}: no other argument may contain the placeholder {placeholder!r}") + + # Path-like arguments before the placeholder (typically the predicate script) resolve + # against the contract file's own directory, so a contract works from any working + # directory. Args that name no file there are left untouched (e.g. "-c"). + contract_dir = path.resolve().parent + first = hits[0] + args = [ + str((contract_dir / a).resolve()) + if i < first and not Path(a).is_absolute() and (contract_dir / a).is_file() + else a + for i, a in enumerate(args) + ] + + outcome = raw.get("outcome") + _require(isinstance(outcome, dict), f"{path}: outcome must be an object") + assert isinstance(outcome, dict) + reproduces = frozenset(_check_exit_codes(outcome.get("reproduces_exit_codes", None), + "reproduces_exit_codes")) + not_reproduces = frozenset(_check_exit_codes(outcome.get("not_reproduces_exit_codes", None), + "not_reproduces_exit_codes")) + _require(reproduces or not_reproduces, + f"{path}: outcome must declare at least one of reproduces_exit_codes, " + "not_reproduces_exit_codes") + _require(not (reproduces & not_reproduces), + f"{path}: outcome exit-code sets overlap: {sorted(reproduces & not_reproduces)}") + + timeout = raw.get("timeout_seconds") + _require(isinstance(timeout, (int, float)) and not isinstance(timeout, bool) + and 0 < timeout <= _MAX_TIMEOUT_S, + f"{path}: timeout_seconds must be a number in (0, {_MAX_TIMEOUT_S}]") + + repeats = raw.get("determinism_repeats") + _require(isinstance(repeats, int) and not isinstance(repeats, bool) and 1 <= repeats <= 10, + f"{path}: determinism_repeats must be an integer in 1..10") + + cwd = raw.get("cwd", ".") + _require(isinstance(cwd, str) and cwd and not cwd.startswith("/") + and ".." not in Path(cwd).parts, + f"{path}: cwd must be a relative path without '..' (resolved against the " + "reducer's working directory)") + + return Contract( + executable=executable, + args=tuple(args), + scene_placeholder=placeholder, + reproduces_exit_codes=reproduces, + not_reproduces_exit_codes=not_reproduces, + timeout_seconds=float(timeout), + determinism_repeats=repeats, + cwd=cwd, + description=raw.get("description", ""), + path=str(path), + ) + + +def evaluate(contract: Contract, scene_path: str | Path, cwd: Path) -> Evaluation: + """Run the predicate on one scene file and classify the result. Never raises for + anything the predicate or the environment does: every failure mode becomes an + explicit outcome.""" + argv = contract.argv_for(scene_path) + try: + proc = subprocess.run( + argv, + capture_output=True, + timeout=contract.timeout_seconds, + cwd=str(cwd), + shell=False, # explicit: the contract is argv, never shell text + ) + except subprocess.TimeoutExpired as exc: + # subprocess.run has already killed the child and waited for it. + return Evaluation( + outcome=Outcome.UNKNOWN, + exit_code=None, + signal_name=None, + timed_out=True, + stdout=_decode(exc.stdout), + stderr=_decode(exc.stderr), + unknown_reason=(f"timeout: the predicate exceeded " + f"{contract.timeout_seconds}s and was killed"), + spawn_error=None, + ) + except OSError as exc: + return Evaluation( + outcome=Outcome.UNKNOWN, + exit_code=None, + signal_name=None, + timed_out=False, + stdout="", + stderr="", + unknown_reason=f"spawn failed: {exc}", + spawn_error=str(exc), + ) + + if proc.returncode < 0: # negative return code: the child died to a signal + name = signal.Signals(-proc.returncode).name + return Evaluation( + outcome=Outcome.UNKNOWN, + exit_code=None, + signal_name=name, + timed_out=False, + stdout=_decode(proc.stdout), + stderr=_decode(proc.stderr), + unknown_reason=f"terminated by signal {name}", + spawn_error=None, + ) + + code = proc.returncode + if code in contract.reproduces_exit_codes: + outcome = Outcome.REPRODUCES + elif code in contract.not_reproduces_exit_codes: + outcome = Outcome.NOT_REPRODUCES + else: + outcome = Outcome.UNKNOWN + unknown_reason = None + if outcome is Outcome.UNKNOWN: + declared = sorted(contract.reproduces_exit_codes | contract.not_reproduces_exit_codes) + unknown_reason = f"undeclared exit code {code}; declared codes are {declared}" + return Evaluation( + outcome=outcome, + exit_code=code, + signal_name=None, + timed_out=False, + stdout=_decode(proc.stdout), + stderr=_decode(proc.stderr), + unknown_reason=unknown_reason, + spawn_error=None, + ) + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise ContractError(message) diff --git a/tools/scene-witness-reducer/tests/test_contract.py b/tools/scene-witness-reducer/tests/test_contract.py new file mode 100644 index 000000000..af7f102bb --- /dev/null +++ b/tools/scene-witness-reducer/tests/test_contract.py @@ -0,0 +1,177 @@ +"""Contract tests: strict validation, the three-outcome split, and failure modes. + +Every test here uses real subprocess runs against tiny inline predicates, so the +exit-code protocol, timeouts, and spawn failures are exercised for real. +""" + +import json +import sys +from pathlib import Path + +import pytest + +from contract import ContractError, Outcome, evaluate, load_contract + +BASE_CONTRACT: dict = { + "contract_version": "1.0", + "description": "test predicate", + "executable": sys.executable, + "args": ["-c", "import sys; sys.exit(0)", "@SCENE"], + "scene_placeholder": "@SCENE", + "cwd": ".", + "outcome": { + "reproduces_exit_codes": [0], + "not_reproduces_exit_codes": [1], + "unknown": "anything else", + }, + "timeout_seconds": 20, + "determinism_repeats": 2, +} + + +def write_contract(tmp_path, **overrides): + raw = json.loads(json.dumps(BASE_CONTRACT)) + raw.update(overrides) + path = tmp_path / "predicate.json" + path.write_text(json.dumps(raw)) + return path + + +def scene_file(tmp_path, content="{}\n"): + path = tmp_path / "scene.json" + path.write_text(content) + return path + + +def code_contract(tmp_path, code, timeout=20): + return write_contract(tmp_path, args=["-c", code, "@SCENE"], timeout_seconds=timeout) + + +def test_loads_a_valid_contract(tmp_path): + contract = load_contract(write_contract(tmp_path)) + assert contract.executable == BASE_CONTRACT["executable"] + assert contract.scene_placeholder == "@SCENE" + assert contract.reproduces_exit_codes == frozenset({0}) + assert contract.not_reproduces_exit_codes == frozenset({1}) + assert contract.determinism_repeats == 2 + + +def test_rejects_unknown_fields(tmp_path): + with pytest.raises(ContractError, match="unknown contract fields"): + load_contract(write_contract(tmp_path, shell="python3 -c 'x'")) + + +def test_rejects_wrong_version(tmp_path): + with pytest.raises(ContractError, match="contract_version"): + load_contract(write_contract(tmp_path, contract_version="2.0")) + + +def test_rejects_shell_text_in_executable(tmp_path): + with pytest.raises(ContractError, match="plain program name"): + load_contract(write_contract(tmp_path, executable="python3 -c 'x'")) + + +def test_rejects_placeholder_with_whitespace(tmp_path): + with pytest.raises(ContractError, match="without whitespace"): + load_contract(write_contract(tmp_path, scene_placeholder="@ SCENE")) + + +def test_rejects_missing_or_duplicate_placeholder(tmp_path): + dup = dict(BASE_CONTRACT, args=["-c", "@SCENE", "@SCENE"]) + (tmp_path / "c1.json").write_text(json.dumps(dup)) + with pytest.raises(ContractError, match="exactly one argument"): + load_contract(tmp_path / "c1.json") + none = dict(BASE_CONTRACT, args=["-c", "x"]) + (tmp_path / "c2.json").write_text(json.dumps(none)) + with pytest.raises(ContractError, match="exactly one argument"): + load_contract(tmp_path / "c2.json") + + +def test_rejects_overlapping_exit_code_sets(tmp_path): + with pytest.raises(ContractError, match="overlap"): + load_contract(write_contract( + tmp_path, outcome={"reproduces_exit_codes": [0, 1], + "not_reproduces_exit_codes": [1]})) + + +def test_rejects_out_of_range_timeout_and_repeats(tmp_path): + with pytest.raises(ContractError, match="timeout_seconds"): + load_contract(write_contract(tmp_path, timeout_seconds=0)) + with pytest.raises(ContractError, match="timeout_seconds"): + load_contract(write_contract(tmp_path, timeout_seconds=601)) + with pytest.raises(ContractError, match="determinism_repeats"): + load_contract(write_contract(tmp_path, determinism_repeats=0)) + with pytest.raises(ContractError, match="determinism_repeats"): + load_contract(write_contract(tmp_path, determinism_repeats=11)) + + +def test_rejects_unreadable_or_non_object_contract(tmp_path): + (tmp_path / "bad.json").write_text("{not json") + with pytest.raises(ContractError, match="unreadable"): + load_contract(tmp_path / "bad.json") + (tmp_path / "arr.json").write_text("[]") + with pytest.raises(ContractError, match="JSON object"): + load_contract(tmp_path / "arr.json") + + +def test_evaluate_maps_declared_exit_codes(tmp_path): + contract = load_contract(code_contract(tmp_path, "import sys; sys.exit(0)")) + assert evaluate(contract, scene_file(tmp_path), tmp_path).outcome is Outcome.REPRODUCES + contract = load_contract(code_contract(tmp_path, "import sys; sys.exit(1)")) + assert evaluate(contract, scene_file(tmp_path), tmp_path).outcome is Outcome.NOT_REPRODUCES + + +def test_undeclared_exit_code_is_unknown(tmp_path): + contract = load_contract(code_contract(tmp_path, "import sys; sys.exit(2)")) + ev = evaluate(contract, scene_file(tmp_path), tmp_path) + assert ev.outcome is Outcome.UNKNOWN + assert "undeclared exit code 2" in ev.unknown_reason + + +def test_signal_death_is_unknown_with_signal_name(tmp_path): + code = "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" + contract = load_contract(code_contract(tmp_path, code)) + ev = evaluate(contract, scene_file(tmp_path), tmp_path) + assert ev.outcome is Outcome.UNKNOWN + assert ev.signal_name == "SIGTERM" + + +def test_spawn_failure_is_unknown(tmp_path): + contract = load_contract( + write_contract(tmp_path, executable="definitely-not-a-real-program-xyz")) + ev = evaluate(contract, scene_file(tmp_path), tmp_path) + assert ev.outcome is Outcome.UNKNOWN + assert ev.spawn_error + + +def test_timeout_is_unknown_and_kills_the_child(tmp_path): + contract = load_contract(code_contract(tmp_path, "import time; time.sleep(30)", timeout=2)) + ev = evaluate(contract, scene_file(tmp_path), tmp_path) + assert ev.outcome is Outcome.UNKNOWN + assert ev.timed_out and "timeout" in ev.unknown_reason + + +def test_transcript_has_no_timing_information(tmp_path): + contract = load_contract(code_contract(tmp_path, "print('out'); import sys; sys.exit(0)")) + record = evaluate(contract, scene_file(tmp_path), tmp_path).transcript() + assert record["stdout"] == "out\n" + assert "duration" not in json.dumps(record) + + +def test_script_arg_resolves_relative_to_contract_file(tmp_path): + """A path arg before the placeholder resolves against the contract file's own + directory, so the same contract works from any working directory.""" + preds = tmp_path / "preds" + preds.mkdir() + (preds / "helper.py").write_text("import sys; sys.exit(0)\n") + contract_path = write_contract(preds, args=["helper.py", "@SCENE"]) + contract = load_contract(contract_path) + assert contract.args[0] == str((preds / "helper.py").resolve()) + evaluation = evaluate(contract, scene_file(tmp_path), cwd=tmp_path) + assert evaluation.outcome is Outcome.REPRODUCES + + +def test_arg_that_names_no_contract_relative_file_is_left_alone(tmp_path): + contract = load_contract( + write_contract(tmp_path, args=["-c", "import sys; sys.exit(0)", "@SCENE"])) + assert contract.args[0] == "-c"