From 0f7158a11fc81ae0913d8ed030071d1a76b8229c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 02:46:42 -0500 Subject: [PATCH 01/52] server: publish scene and result JSON schemas (contracts C1, C2) scene.schema.json is docs/01's scene.json in feet, with the optional additions the overnight plan lists (schema_version, coverage, per-item plus_minus_ft, tape as a source) plus an optional plan footprint per object, which golden tests 05 and 06 need to measure gas clearance to parts that stand off the wall. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/pyproject.toml | 11 +- server/schemas/result.schema.json | 219 ++++++++ server/schemas/scene.schema.json | 221 ++++++++ server/tests/fixtures/example-scene.json | 44 ++ server/tests/test_schemas.py | 60 ++ server/uv.lock | 678 +++++++++++++++++++++++ 6 files changed, 1232 insertions(+), 1 deletion(-) create mode 100644 server/schemas/result.schema.json create mode 100644 server/schemas/scene.schema.json create mode 100644 server/tests/fixtures/example-scene.json create mode 100644 server/tests/test_schemas.py diff --git a/server/pyproject.toml b/server/pyproject.toml index 3d32bde13..2d3b20039 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -4,10 +4,19 @@ version = "0.1.0" description = "FastAPI server that turns a phone capture into a battery placement (lanes B and C)" readme = "README.md" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "fastapi>=0.141.1", + "jsonschema>=4.26.0", + "python-multipart>=0.0.32", + "pyyaml>=6.0.3", + "shapely>=2.1.2", + "uvicorn>=0.54.0", +] [dependency-groups] dev = [ + "httpx>=0.28.1", + "hypothesis>=6.168.1", "pytest>=9.1.1", "ruff>=0.16.9", ] diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json new file mode 100644 index 000000000..a334b8c2e --- /dev/null +++ b/server/schemas/result.schema.json @@ -0,0 +1,219 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/SamGu-NRX/house-scanning/server/schemas/result.schema.json", + "title": "Battery placement result", + "description": "The placement server's answer for one scene (contract C2). Lengths are in feet; plan coordinates [x, z] and s follow scene.schema.json. The result settles placement only, never the whole installation: electrical checks (panel, meter) are outside it.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "decision", "summary", "reasons", "policy", "spot", "route", "checks", "missing_evidence", "ends", "sweep", "stats"], + "properties": { + "schema_version": {"const": "1.0"}, + "decision": { + "enum": ["pass", "manual_review", "reject"], + "description": "pass: a fully observed spot passes every check under an approved policy. manual_review: a person must decide, because the best spot has an UNSURE check, the policy is not approved for automatic decisions, or an area that could hold a valid spot was not seen. reject: every spot within reach fails by a clear margin, and both ends of the walk are known." + }, + "summary": {"type": "string", "description": "One sentence for the homeowner or reviewer."}, + "reasons": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { + "enum": ["all_checks_pass", "policy_not_approved", "unsure_checks", "unobserved_area", "unexplored_end", "all_spots_fail", "no_wall_segment_fits"] + }, + "message": {"type": "string"}, + "checks": {"type": "array", "items": {"type": "string"}, "description": "Check ids behind this reason."} + } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "auto_approve", "sources", "rules_sha256"], + "properties": { + "id": {"type": ["string", "null"]}, + "version": {"type": ["string", "null"]}, + "auto_approve": {"type": "boolean", "description": "False while the rules hold placeholder values; a would-be pass then becomes manual_review."}, + "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."} + } + }, + "spot": { + "description": "The chosen battery position: the best passing spot, or for manual_review the best spot with no failing check. Null when no such spot exists, including every reject.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] + }, + "route": { + "description": "Cable route from the meter to the chosen spot. Null when `spot` is null.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/route"}] + }, + "checks": { + "description": "Every check at the chosen spot, or, when `spot` is null, at the nearest spot considered (see `nearest_considered`).", + "type": "array", + "items": {"$ref": "#/$defs/check"} + }, + "nearest_considered": { + "description": "When `spot` is null: the spot with the shortest route that was evaluated, and why it fails. Null otherwise.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] + }, + "missing_evidence": { + "description": "Views that would settle an UNSURE result. Only areas nobody observed appear here; an UNSURE caused by a measurement inside its error band needs a person, not more photos. Empty for pass and reject.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "message"], + "properties": { + "kind": {"enum": ["band", "past_end"]}, + "band": {"enum": ["wall", "ground", "overhead", "facing"]}, + "span_ft": {"$ref": "#/$defs/span"}, + "side": {"enum": ["left", "right"]}, + "checks": {"type": "array", "items": {"type": "string"}}, + "message": {"type": "string"} + } + } + }, + "ends": { + "type": "object", + "additionalProperties": false, + "required": ["left", "right"], + "properties": { + "left": {"$ref": "#/$defs/end"}, + "right": {"$ref": "#/$defs/end"} + } + }, + "sweep": { + "description": "Outcome of every battery start position along the wall, merged into runs of equal outcome. Shows why each stretch fails; the site plan colours the wall with it.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wall_id", "start_ft", "outcome", "failing", "unsure"], + "properties": { + "wall_id": {"type": "string"}, + "start_ft": {"$ref": "#/$defs/span", "description": "Range of battery start positions (left edge, in s) in this run."}, + "outcome": {"$ref": "#/$defs/outcome"}, + "failing": {"type": "array", "items": {"type": "string"}}, + "unsure": {"type": "array", "items": {"type": "string"}} + } + } + }, + "stats": { + "type": "object", + "additionalProperties": false, + "required": ["candidates", "pass", "unsure", "fail", "elapsed_ms", "input_sha256"], + "properties": { + "candidates": {"type": "integer", "minimum": 0}, + "pass": {"type": "integer", "minimum": 0}, + "unsure": {"type": "integer", "minimum": 0}, + "fail": {"type": "integer", "minimum": 0}, + "elapsed_ms": {"type": "number", "minimum": 0}, + "input_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hash of the scene JSON as uploaded."} + } + } + }, + "$defs": { + "point2": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "span": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "outcome": {"enum": ["pass", "fail", "unsure"]}, + "end": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "s_ft", "point"], + "properties": { + "kind": {"enum": ["limit", "unexplored"]}, + "s_ft": {"type": "number", "description": "Where the wall chain ends, in s."}, + "point": {"$ref": "#/$defs/point2"}, + "beyond_reach": {"type": "boolean", "description": "True when this end is so far along the wall that no spot past it could pass the route-length check, so an unexplored end there does not block a reject."} + } + }, + "check": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "outcome", "reason", "measured_ft", "plus_minus_ft", "threshold_ft", "comparison", "rule"], + "properties": { + "id": {"type": "string", "description": "Stable identifier, for example gas_clearance or route_length."}, + "label": {"type": "string"}, + "outcome": {"$ref": "#/$defs/outcome"}, + "unsure_cause": { + "enum": ["margin", "unobserved", "unknown_attribute"], + "description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen, more photos fix it. unknown_attribute: a fact the camera did not establish (for example whether a window opens)." + }, + "reason": {"type": "string"}, + "measured_ft": {"type": ["number", "null"], "description": "The deciding measurement, for example the gap to the nearest gas meter. Null when nothing relevant was found or it was not observed."}, + "plus_minus_ft": {"type": ["number", "null"]}, + "threshold_ft": {"type": ["number", "null"]}, + "comparison": {"enum": ["at_least", "at_most", null], "description": "at_least: pass needs measured - error > threshold. at_most: pass needs measured + error < threshold."}, + "subject": {"type": ["string", "null"], "description": "What the measurement is to, for example \"objects[2] gas_meter\"."}, + "rule": { + "type": "object", + "additionalProperties": false, + "required": ["key", "source", "placeholder"], + "properties": { + "key": {"type": "string", "description": "Parameter name in rules.yaml."}, + "source": {"type": "string", "description": "Citation for the value."}, + "placeholder": {"type": "boolean", "description": "True for a demo value with no public source."} + } + } + } + }, + "spot": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "wall_id", "segment", "span_ft", "width_ft", "depth_ft", "height_ft", "footprint", "center", "along", "outward", "meter_offset_ft", "route_length_ft"], + "properties": { + "outcome": {"$ref": "#/$defs/outcome"}, + "wall_id": {"type": "string"}, + "segment": {"type": "integer", "minimum": 0, "description": "Index of the straight baseline segment of `wall_id` the battery backs onto."}, + "span_ft": {"$ref": "#/$defs/span", "description": "Battery's stretch of wall in s, left edge first."}, + "width_ft": {"type": "number"}, + "depth_ft": {"type": "number"}, + "height_ft": {"type": "number"}, + "footprint": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"$ref": "#/$defs/point2"}, "description": "Plan corners [x, z]: back-left, back-right, front-right, front-left."}, + "center": {"$ref": "#/$defs/point2", "description": "Plan centre of the footprint."}, + "along": {"$ref": "#/$defs/point2", "description": "Unit vector along the wall toward +s."}, + "outward": {"$ref": "#/$defs/point2", "description": "Unit vector from the wall out toward the battery's front."}, + "meter_offset_ft": { + "type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}, + "description": "Footprint centre minus the meter's plan position, [dx, dz] in the scene frame's axes. scene.json carries no ground height, so the app sets the height by resting the box on its detected ground plane. Attach AR content to the meter's anchor with this offset, rotated by the anchor's orientation if the anchor is not axis-aligned with the scene frame." + }, + "route_length_ft": {"type": ["number", "null"]} + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "length_ft", "plus_minus_ft", "height_ft", "polyline", "detours", "crossings"], + "properties": { + "outcome": {"$ref": "#/$defs/outcome"}, + "length_ft": {"type": "number", "description": "Length along the supported wall from the meter to the battery's near edge, including detours around wall objects."}, + "plus_minus_ft": {"type": "number"}, + "height_ft": {"type": "number", "description": "Height above ground the cable runs at, from the rules."}, + "polyline": {"type": "array", "items": {"$ref": "#/$defs/point2"}, "description": "Plan points from the meter along the wall to the battery."}, + "detours": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "extra_ft"], + "properties": {"subject": {"type": "string"}, "extra_ft": {"type": "number"}} + } + }, + "crossings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "span_ft", "effect"], + "properties": { + "subject": {"type": "string"}, + "span_ft": {"$ref": "#/$defs/span"}, + "effect": {"enum": ["fail", "review", "detour", "allow"]} + } + } + } + } + } + } +} diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json new file mode 100644 index 000000000..f0477033e --- /dev/null +++ b/server/schemas/scene.schema.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/SamGu-NRX/house-scanning/server/schemas/scene.schema.json", + "title": "House scan scene", + "description": "What the phone measured around the electric meter (contract C1, docs/01-feature-map.md). Every length is in feet. Plan coordinates [x, z] are the horizontal axes of one gravity-aligned scene frame (ARKit .gravity world alignment: +y up), so [x, z] ignores height. `s` is the distance along the unrolled wall chain from the meter, negative to the left as seen by someone outside facing the wall. Unknown fields are rejected so a misspelled field fails loudly instead of being ignored.", + "type": "object", + "additionalProperties": false, + "required": ["meter", "walls"], + "properties": { + "schema_version": { + "description": "Contract version. Absent means \"1.0\". Minor versions only add optional fields.", + "type": "string", + "pattern": "^1\\.[0-9]+$" + }, + "meter": { + "type": "object", + "additionalProperties": false, + "required": ["pos", "wall_id"], + "properties": { + "pos": {"$ref": "#/$defs/point3", "description": "Meter position in the scene frame, feet. Its [x, z] is projected onto `wall_id` to set s = 0."}, + "wall_id": {"type": "string", "minLength": 1}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft"} + } + }, + "walls": { + "description": "Wall chain in order from the left end to the right end, as seen from outside. Consecutive walls that share an endpoint meet at a corner; a gap between the end of one wall and the start of the next is a stretch with no wall, which no battery can back onto and no cable can cross. The first wall starts at the chain's left end.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "baseline"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "baseline": { + "description": "The wall's line along the ground, [x, z] points ordered left to right as seen from outside. The outward direction of each segment is therefore its direction turned 90 degrees clockwise in the x-z plane viewed from above (+y). Each straight segment is where a battery can sit flush.", + "type": "array", + "minItems": 2, + "items": {"$ref": "#/$defs/point2"} + }, + "height_ft": {"type": "number", "exclusiveMinimum": 0}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of this wall's line. Absent means the rules' default for AR taps."} + } + } + }, + "objects": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "wall_id", "span_ft", "source"], + "properties": { + "type": {"enum": ["window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool"]}, + "wall_id": {"type": "string", "minLength": 1}, + "span_ft": {"$ref": "#/$defs/span", "description": "Stretch of the wall the object covers, in s."}, + "bottom_ft": {"type": "number", "minimum": 0, "description": "Height of the object's lowest edge above the ground."}, + "top_ft": {"type": "number", "minimum": 0}, + "attrs": { + "type": "object", + "description": "Recognised attributes. A missing attribute is unknown, never false.", + "properties": { + "operable": {"type": "boolean"}, + "well": {"type": "boolean"} + } + }, + "source": {"enum": ["vlm", "tap", "tape"], "description": "How the position was measured: a vision model, an AR tap, or a tape measure. Sets the default error."}, + "conf": {"type": "number", "minimum": 0, "maximum": 1, "description": "Recognition confidence. Not a distance error and never used as one."}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error. Absent means the rules' default for `source`."}, + "footprint": { + "description": "Optional ground outline of the object in plan, [x, z] points. Give it for anything that stands off the wall (a gas regulator, pipe run, AC unit, pool edge) so clearances are measured to every part. Without it the object is the stretch of wall line over `span_ft`.", + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/point2"} + } + } + } + }, + "ground": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "polygon"], + "properties": { + "type": {"enum": ["drive", "concrete", "gravel", "lawn", "mulch", "deck"]}, + "polygon": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft"} + } + } + }, + "overheads": { + "description": "Anything overhead (porch roof, eave, stairs, landing) along the wall, with the clear height beneath it.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wall_id", "span_ft", "clearance_ft"], + "properties": { + "wall_id": {"type": "string", "minLength": 1}, + "span_ft": {"$ref": "#/$defs/span"}, + "clearance_ft": {"type": "number", "minimum": 0, "description": "Clear height from the ground to the underside."}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Absent means the rules' default for mesh measurements."} + } + } + }, + "facing": { + "description": "Gap from the wall straight out to the nearest fence, hedge or wall.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wall_id", "span_ft", "depth_ft"], + "properties": { + "wall_id": {"type": "string", "minLength": 1}, + "span_ft": {"$ref": "#/$defs/span"}, + "depth_ft": {"type": "number", "minimum": 0}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Absent means the rules' default for mesh measurements."} + } + } + }, + "coverage": { + "description": "What the capture actually saw. Anything not listed was not observed, and a check that depends on an unobserved area is UNSURE, never PASS. Absent means nothing is known to be observed.", + "type": "object", + "additionalProperties": false, + "properties": { + "ends": { + "type": "object", + "additionalProperties": false, + "properties": { + "left": {"$ref": "#/$defs/end"}, + "right": {"$ref": "#/$defs/end"} + } + }, + "observed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["band", "span_ft"], + "properties": { + "band": { + "enum": ["wall", "ground", "overhead", "facing"], + "description": "wall: the wall face from the ground to battery height, which also covers the cable route. 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."}, + "out_ft": {"type": "number", "minimum": 0, "description": "Required for the ground band: how far out from the wall the ground was seen."} + }, + "if": {"properties": {"band": {"const": "ground"}}}, + "then": {"required": ["out_ft"]} + } + } + } + }, + "keyframes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "pose", "intrinsics", "w", "h", "img"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "pose": { + "description": "Camera-to-world transform in the scene frame, 16 numbers in column-major order, translation in feet.", + "type": "array", "minItems": 16, "maxItems": 16, "items": {"type": "number"} + }, + "intrinsics": { + "description": "[fx, fy, cx, cy] in pixels of the unrotated (landscape, as the sensor reads it) image.", + "type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "number"} + }, + "w": {"type": "integer", "minimum": 1}, + "h": {"type": "integer", "minimum": 1}, + "img": {"type": "string", "minLength": 1, "description": "File name of the JPEG inside the uploaded bundle."} + } + } + }, + "stills": { + "type": "object", + "description": "Close-up photo file names in the bundle, keyed by purpose (meter_close, meter_surround, panel_wall, panel_open).", + "additionalProperties": {"type": "string"} + }, + "gps": { + "type": "object", + "additionalProperties": false, + "required": ["lat", "lon"], + "properties": { + "lat": {"type": "number", "minimum": -90, "maximum": 90}, + "lon": {"type": "number", "minimum": -180, "maximum": 180} + } + }, + "heading": { + "type": "object", + "additionalProperties": false, + "required": ["deg"], + "properties": { + "deg": {"type": "number"}, + "accuracy_deg": {"type": "number", "minimum": 0} + } + } + }, + "$defs": { + "point2": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "point3": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}, + "span": { + "description": "[start, end] in s, feet from the meter, start <= end.", + "type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"} + }, + "plus_minus_ft": {"type": "number", "minimum": 0, "description": "Measurement error in feet: the true value lies within this distance of the reported one."}, + "end": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "enum": ["limit", "unexplored"], + "description": "limit: the walk reached a real end, where there is no more wall a battery could use (the wall meets a fence, a neighbour's property or an impassable obstruction). unexplored: the wall continues (often round a corner) but was not walked. Absent means unexplored." + }, + "note": {"type": "string"} + } + } + } +} diff --git a/server/tests/fixtures/example-scene.json b/server/tests/fixtures/example-scene.json new file mode 100644 index 000000000..873a92674 --- /dev/null +++ b/server/tests/fixtures/example-scene.json @@ -0,0 +1,44 @@ +{ + "schema_version": "1.0", + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "side", "plus_minus_ft": 0.3}, + "walls": [ + {"id": "back", "baseline": [[-14.0, -18.0], [-14.0, 0.0]], "height_ft": 9, "plus_minus_ft": 0.3}, + {"id": "side", "baseline": [[-14.0, 0.0], [22.0, 0.0]], "height_ft": 9, "plus_minus_ft": 0.3} + ], + "objects": [ + {"type": "gas_meter", "wall_id": "side", "span_ft": [-5.0, -4.0], "bottom_ft": 0.5, "top_ft": 2.5, + "source": "tap", "plus_minus_ft": 0.3, + "footprint": [[-5.0, 0.0], [-4.0, 0.0], [-4.0, 1.0], [-5.0, 1.0]]}, + {"type": "window", "wall_id": "side", "span_ft": [3.0, 6.0], "bottom_ft": 3.0, "top_ft": 7.0, + "attrs": {"operable": true, "well": false}, "source": "vlm", "conf": 0.86}, + {"type": "door", "wall_id": "side", "span_ft": [17.0, 20.0], "bottom_ft": 0.0, "top_ft": 6.8, + "source": "tap"}, + {"type": "ac", "wall_id": "back", "span_ft": [-20.0, -17.0], "source": "tap", + "footprint": [[-17.0, -6.0], [-14.0, -6.0], [-14.0, -3.0], [-17.0, -3.0]]} + ], + "ground": [ + {"type": "lawn", "polygon": [[-28.0, -18.0], [-14.0, -18.0], [-14.0, 14.0], [-28.0, 14.0]]}, + {"type": "mulch", "polygon": [[-14.0, 0.0], [22.0, 0.0], [22.0, 4.0], [-14.0, 4.0]]}, + {"type": "lawn", "polygon": [[-14.0, 4.0], [22.0, 4.0], [22.0, 14.0], [-14.0, 14.0]]}, + {"type": "drive", "polygon": [[22.5, 0.0], [34.0, 0.0], [34.0, 30.0], [22.5, 30.0]]} + ], + "overheads": [], + "facing": [{"wall_id": "side", "span_ft": [-14.0, 22.0], "depth_ft": 14.0, "plus_minus_ft": 0.5}], + "coverage": { + "ends": {"left": {"kind": "unexplored", "note": "back wall continues past the patio"}, + "right": {"kind": "limit", "note": "front corner, driveway"}}, + "observed": [ + {"band": "wall", "span_ft": [-26.0, 22.0]}, + {"band": "ground", "span_ft": [-26.0, 26.0], "out_ft": 14.0}, + {"band": "overhead", "span_ft": [-26.0, 22.0]}, + {"band": "facing", "span_ft": [-26.0, 22.0]} + ] + }, + "keyframes": [ + {"id": "k1", "pose": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2.0, 4.5, 9.0, 1], + "intrinsics": [1450.0, 1450.0, 960.0, 720.0], "w": 1920, "h": 1440, "img": "k1.jpg"} + ], + "stills": {"meter_close": "meter_close.jpg"}, + "gps": {"lat": 30.2672, "lon": -97.7431}, + "heading": {"deg": 181.0, "accuracy_deg": 12.0} +} diff --git a/server/tests/test_schemas.py b/server/tests/test_schemas.py new file mode 100644 index 000000000..f5fb76c0b --- /dev/null +++ b/server/tests/test_schemas.py @@ -0,0 +1,60 @@ +import copy +import json +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator + +SERVER = Path(__file__).resolve().parents[1] +SCENE_SCHEMA = json.loads((SERVER / "schemas" / "scene.schema.json").read_text()) +RESULT_SCHEMA = json.loads((SERVER / "schemas" / "result.schema.json").read_text()) +EXAMPLE = json.loads((SERVER / "tests" / "fixtures" / "example-scene.json").read_text()) + + +@pytest.mark.parametrize("schema", [SCENE_SCHEMA, RESULT_SCHEMA], ids=["scene", "result"]) +def test_schema_is_valid_draft_2020_12(schema) -> None: + Draft202012Validator.check_schema(schema) + + +def test_example_scene_validates() -> None: + Draft202012Validator(SCENE_SCHEMA).validate(EXAMPLE) + + +def test_docs01_minimal_scene_validates() -> None: + # The docs/01 fields alone, without any optional addition, are a valid scene. + minimal = { + "meter": {"pos": [0, 5, 0], "wall_id": "w1"}, + "walls": [{"id": "w1", "baseline": [[-10, 0], [10, 0]], "height_ft": 9}], + "objects": [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [2, 4], + "bottom_ft": 3.1, + "top_ft": 6.2, + "attrs": {"operable": True, "well": False}, + "source": "vlm", + "conf": 0.8, + } + ], + "ground": [{"type": "lawn", "polygon": [[-10, 0], [10, 0], [10, 5]]}], + "overheads": [{"wall_id": "w1", "span_ft": [1, 2], "clearance_ft": 3.0}], + "facing": [{"wall_id": "w1", "span_ft": [1, 2], "depth_ft": 4.5}], + } + Draft202012Validator(SCENE_SCHEMA).validate(minimal) + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (lambda s: s["objects"][0].update(plusminus_ft=0.3), "Additional properties"), + (lambda s: s["coverage"]["observed"][1].pop("out_ft"), "'out_ft' is a required property"), + (lambda s: s["objects"][0].update(type="fence"), "is not one of"), + (lambda s: s["walls"][0].update(baseline=[[0, 0]]), "is too short"), + ], +) +def test_scene_schema_rejects_loudly(mutate, message: str) -> None: + scene = copy.deepcopy(EXAMPLE) + mutate(scene) + errors = [e.message for e in Draft202012Validator(SCENE_SCHEMA).iter_errors(scene)] + assert any(message in e for e in errors), errors diff --git a/server/uv.lock b/server/uv.lock index 9a0f1c8a1..6734267c3 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -2,6 +2,64 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,25 +69,183 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "house-scanning-server" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "jsonschema" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "shapely" }, + { name = "uvicorn" }, +] [package.dev-dependencies] dev = [ + { name = "httpx" }, + { name = "hypothesis" }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.141.1" }, + { name = "jsonschema", specifier = ">=4.26.0" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "shapely", specifier = ">=2.1.2" }, + { name = "uvicorn", specifier = ">=0.54.0" }, +] [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "hypothesis", specifier = ">=6.168.1" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.16.9" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/31/b54cd138ee36a32d0047d40fe7bdcb63cefd57ce098a26024161332d15fd/hypothesis-6.168.1.tar.gz", hash = "sha256:fd8acb5c67f260dfbdcc1b2dc95ff48b2b3f90708ae595c76f591bbf3f6f8eeb", size = 510823, upload-time = "2026-09-23T04:36:04.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/95/3091b9ad24baa9666d49feea433a8c4060ad47245f0d529d736af3672ddd/hypothesis-6.168.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5d9cc2d2a4779c92788389564fc6760df7c2909185f6431378133dea6933194f", size = 791345, upload-time = "2026-09-23T04:35:39.379Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b3/b954ffa69f91d111f801030c28d6721f422222af245a846571fc87874242/hypothesis-6.168.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d0e1ec15d32af11da6784d2506de0c901e4c1abf97a3cdcfc144225457783f4e", size = 787104, upload-time = "2026-09-23T04:35:21.799Z" }, + { url = "https://files.pythonhosted.org/packages/e5/dc/732df08f570d845a480d3729bfb46beccc441bfabe2804bb3a458649be73/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cc38519a57c4b919b2c59714a67501d20b46b0c96a0078cbdc9f86e728efce3", size = 1123848, upload-time = "2026-09-23T04:33:48.857Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e1e937df42243267ba25ede5e9b29921186f290f6c0515729481cc41726e/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e229e937e761830c7fa6437fb12cb801616a1737af0898abf5edba57d2ad691", size = 1147698, upload-time = "2026-09-23T04:35:33.439Z" }, + { url = "https://files.pythonhosted.org/packages/be/d1/3b41c008dc2a49e43bc4893dcd27ea9d9fef64fef50382ca5f7107680b1f/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:569ba21e5543840895d17633cc6716a9578ac83b1193a2978b3f24cc16b93085", size = 1149280, upload-time = "2026-09-23T04:35:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/71/ea/d54781058fe6a487b81ec6c636f3806fff6a417b48cf3fcd2b003d80f3ce/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a232665716607c3c50c9a3aeeac5929be1d4e58703f5e7cb51db7099445a8c6", size = 1191693, upload-time = "2026-09-23T04:35:57.896Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f2/43bdd07450978611d25989ffdcbe1579acd46c1500a484f8838359fe7880/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9022fe330d8839259da9b496573c4b83ea248bf32029eba23ff589f3c25754bf", size = 1169725, upload-time = "2026-09-23T04:34:32.59Z" }, + { url = "https://files.pythonhosted.org/packages/d1/72/fd4b657cba66940f208bc0eccaceaa777553009d26e86068ff3ab5e55b0b/hypothesis-6.168.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:69be660b831d55ab35619b30abd311a6cb8cd0068a1192210d194a776b211094", size = 1129181, upload-time = "2026-09-23T04:35:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3f/516a29d7a2bd31e28972de635350eab086c94faa07afcdd3ad0f06e70db8/hypothesis-6.168.1-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e2cccca80ee4ffe8f66edf5b2adb2a16b8cd3c5110b3a774d70f2fc75a42aa", size = 1160162, upload-time = "2026-09-23T04:35:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/e2c7e32f281b31d4c19cc2fa935bfdde5b31ad4848a485e5ebb8de2389da/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:11cbd2a2539194f0960de4ed9f980882e3a3f55c476afcc6f4cc342eec5a07d2", size = 1299714, upload-time = "2026-09-23T04:35:12.529Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bc/85850bdb1fc35d0fa9e299ca3b2e354f403959666c7ef4d0bfda9c308eee/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:aa1620111616c660a1db03347b1790b5914ef30f86c6e8ddd6c765785291f0b4", size = 1425335, upload-time = "2026-09-23T04:35:23.557Z" }, + { url = "https://files.pythonhosted.org/packages/40/c0/66bdb24f32d32aa5636655b6037af07962118b89fae16ee74bc9a1bb982e/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:a07c8665c95bb856f99ecd60b6f50d94dcf8157415387c32dcd5bb5b5061d032", size = 1376916, upload-time = "2026-09-23T04:34:59.742Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/7ad0b17bda81c473cbc539009148f3e0950880c4618028691bcf62eeb8d2/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:8a50b6335d5fa2312dd9f5d50faeb953e61475b301d2c073cd7a4c5efd7e6171", size = 1280975, upload-time = "2026-09-23T04:34:21.966Z" }, + { url = "https://files.pythonhosted.org/packages/e2/01/8d04d1eecf03a6097e26400e8393eff7a81fd52c877a664ee4e97adf7880/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:4ccb0276db8ad197a63dff25c4868e11123346347df6900625d2b1f794ce7c19", size = 1300238, upload-time = "2026-09-23T04:34:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/f5/74/eda0c70b3845789c713fe2ac72d3d4586da83f38a34f1f7aefbda0b5b01e/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85ca7788574e06d0591d51e19ecbb1d09bc82d50c6eae5de0fe8317590b1e138", size = 1336088, upload-time = "2026-09-23T04:33:42.002Z" }, + { url = "https://files.pythonhosted.org/packages/80/c9/43d8528e6d43eebb7688d36e530d1fc7763809b8f923b62cc8b4d95f99ff/hypothesis-6.168.1-cp310-abi3-win32.whl", hash = "sha256:77378e04aa47a22a615af80f42970dc44782f02b8a82ac105b619a2cc7f5eee2", size = 678004, upload-time = "2026-09-23T04:34:29.564Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8b/f7d4de5b57972f7f68a6c7fb5698f60425589eae6fcb175f285f2525e8c0/hypothesis-6.168.1-cp310-abi3-win_amd64.whl", hash = "sha256:f94d3dbc31112f8fbcb3af19a662ee5e90e380e6ae676357903caa14a1604763", size = 684722, upload-time = "2026-09-23T04:34:41.279Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/528eb221a82da0aa477e34c7af6ed2b4a5109b326638d4418b9e49435e96/hypothesis-6.168.1-cp310-abi3-win_arm64.whl", hash = "sha256:9bd7e8c6e2c3f6a5befee6b03ebd2170da2e50658075c6decad184777c72a726", size = 682711, upload-time = "2026-09-23T04:33:46.085Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6f/9d5ae55d81e10f0fc46868c19ab9d468e9959e82ff8cc8c26990b16114f3/hypothesis-6.168.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:dfe14a88b1ed03ce47333d800cc37949da73feb0cd9767e7ef076d2202768928", size = 793124, upload-time = "2026-09-23T04:33:52.589Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/54c19d259d7f92176aa2199c90f7951e4e396a26898a596dc875ccb9c389/hypothesis-6.168.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8acd000c3bf233cdcd28c1ff03f1bf56cb03821b1c742d00d57978b20fc6e6f8", size = 784674, upload-time = "2026-09-23T04:34:38.137Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ae/f80a9810f88bd654ab0d0ed0652c274e4c0384e22d4f9442b6753b9200a6/hypothesis-6.168.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29280d4743546ddcf74644f9acdc5726972cba9182699a86b7e77f3f8f6e5dde", size = 1122860, upload-time = "2026-09-23T04:33:51.375Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5d/aa5b7ffd57ec475a9252c55985c07a366823c3f85267d44648e94c1ec321/hypothesis-6.168.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd9a488d46b8c7da3103058c7569376a3a08994aee9a7d6f1d160d3d3b49b82d", size = 1168922, upload-time = "2026-09-23T04:34:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/4862448192d4c9cc61a3b8a620730800f56b1e83b0bfda63dbc728526eb3/hypothesis-6.168.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451ee6c42955887a9e1850c53813e31182eab1663e4d3dacea4e535dedc45b5d", size = 1298568, upload-time = "2026-09-23T04:35:31.191Z" }, + { url = "https://files.pythonhosted.org/packages/15/ad/55e73fda9afbfcf3336b2eee564833bc9a4fee1f7a32fe5f9d183742da39/hypothesis-6.168.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bc2c036f4a9534af64977e38b2976295136f83417fdb95bed816a52cc3023497", size = 1335091, upload-time = "2026-09-23T04:34:23.42Z" }, + { url = "https://files.pythonhosted.org/packages/ef/99/b6340fa21a89b0088ed023fe984109e1c47a07dfd741fd6d8e17a9e2795f/hypothesis-6.168.1-cp312-cp312-win_amd64.whl", hash = "sha256:d603d7c96d030a63701b6ad9bff0bc4870e4c226335b3db509f1eed1f7fc061b", size = 682011, upload-time = "2026-09-23T04:34:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/f5/23/093b9dc768e89d2cec74d29115f4004fe1be64218bac65f8d817d727d30b/hypothesis-6.168.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aebf7e9d7ba9f920e154abc65851d4affab497daeaea285474bed93840a983ea", size = 793062, upload-time = "2026-09-23T04:33:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/df/d7/f191686b44b3f892ae1404e238a55260152a645a2deafe8b2949c7e90fe9/hypothesis-6.168.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b26616604cac458dd83e75900fefd9190f1074e67d5d9dc4d9e321d30f295eca", size = 784518, upload-time = "2026-09-23T04:34:02.012Z" }, + { url = "https://files.pythonhosted.org/packages/10/25/b7a5dca1911c9012a564d0b7aaeb44e393ade9424a695dea179ff32d79fe/hypothesis-6.168.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb6d89cff2edcd45e5af957c7b6a71ae7887419561f7b9c7055e1c50dea41017", size = 1122844, upload-time = "2026-09-23T04:35:06.897Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/ddacd0d244e6719ea9a9d322d4d4b6b054fd27ada94ec7aa7dce7ef11297/hypothesis-6.168.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a4ce6310ec21000446ae75cdc6667b81d4ee0f2d226a0dfaf8baca42dd151d0", size = 1168779, upload-time = "2026-09-23T04:33:56.885Z" }, + { url = "https://files.pythonhosted.org/packages/dc/07/343bab6676a41f38cb7c5912ab3ca059258bf40e41c181d1b77cc7251801/hypothesis-6.168.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a22c6e730c327d22a4d61b53f66639cdf6f0fe4a5a1b3595c36533e5b8127169", size = 1298434, upload-time = "2026-09-23T04:34:48.576Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/4b72fafa5f46a455024a9242b62505b1a53fad95ace162e831c4ffb233b9/hypothesis-6.168.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcd66493fe180d38641b02dd0456cc1e429655f8940dd1396ab874808f16dd34", size = 1334975, upload-time = "2026-09-23T04:35:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/48643915cda400ca2dccb9bcdc363690b13bcd5c179172bc6450c3d76ce8/hypothesis-6.168.1-cp313-cp313-win_amd64.whl", hash = "sha256:a8a96f2e9fa2a57f95865aaf092943775e7ea96f72535371b2be20fe5464298c", size = 681978, upload-time = "2026-09-23T04:36:00.056Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0e/e6999cddbaef975ecc1b0a7e9028a3d1a1014b542fa003889d0cb3870fa9/hypothesis-6.168.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:35b6582ecc22ed682ea39bbf972f8b8b1798043a3bc624ae1dce02d42fcf3ad4", size = 793097, upload-time = "2026-09-23T04:35:03.374Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f3/2ff6bf4bf43ab12641b163bd8fea4b6cddc9a0f07499c4779519099034c9/hypothesis-6.168.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48e467c73b7fb46bc072323c5d67e8e683cf32218fbba47c4e6ee0fe0d72687f", size = 784662, upload-time = "2026-09-23T04:35:08.976Z" }, + { url = "https://files.pythonhosted.org/packages/10/3a/b3653de877981cdab6861476c3b68d02b7c682c2875557cff7b92dbde068/hypothesis-6.168.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c82fcc3dece367bd43f3eca5c2746cf60b73f61bba071ef9421d4cfd6188a85", size = 1123106, upload-time = "2026-09-23T04:34:26.476Z" }, + { url = "https://files.pythonhosted.org/packages/49/25/24c725cb94c8953a98d8bdec83971f76c8f4b103070df1fdacd8b7f37253/hypothesis-6.168.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36031c8f8fd4bec7185cf70ed080ba54d45c106cf4f575bc8c2482c01f5f9b21", size = 1168942, upload-time = "2026-09-23T04:35:41.237Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e4/cbe45b9e78e4dd9c18ed2328ff95f659f59235b04ae3c73cf7e9c498adc9/hypothesis-6.168.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ddfe21791579fdc90423973c733578efa23b010cbfb685dcd5201cf7b0a928b6", size = 1298937, upload-time = "2026-09-23T04:33:50.181Z" }, + { url = "https://files.pythonhosted.org/packages/93/e9/cacaa3f58ee6eb98ce29a2182483d57686011bba35c8fb8a9bc8bf981c7f/hypothesis-6.168.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ecf86b1c9c8700f6d45324becbbe25dd581c69dc03e1357a64f4bf9a1afe823f", size = 1335198, upload-time = "2026-09-23T04:36:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/31/d8/ca51626959f59a557656cf8024d9afda4d213257c170412fa3193d11e11f/hypothesis-6.168.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:d5ab336a7dd233858bc3066a69479e295da788cef345d7e4efd7b77dad724db0", size = 624105, upload-time = "2026-09-23T04:34:08.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/61/6ce96e7d58fc7a592ad7c84d8c7cf9ea36db51468b30ac8306bbf4c3e6ba/hypothesis-6.168.1-cp314-cp314-win_amd64.whl", hash = "sha256:63244ba76464202d3d24126d2e4c2c1abf36db60fffe16ebaa93292572c2a42a", size = 681859, upload-time = "2026-09-23T04:34:50.801Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b1/6c7345e2b37ea8a1df2520a0ecfbdc0243a897895ff950309ca7f687429a/hypothesis-6.168.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6cec76c8cf76738ad4c0c0c5d6e4632a32d74748639ea6ce26fa672490d9bf95", size = 791685, upload-time = "2026-09-23T04:33:54.243Z" }, + { url = "https://files.pythonhosted.org/packages/72/8c/4f6cfafde4b31add263d6a562f615939cd49649e87a3695ac8693506c811/hypothesis-6.168.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9da10ba2dbbcaba93c6a11eb394fd1a0cfee6e2ba21345c3d6f2fc7401d80670", size = 783237, upload-time = "2026-09-23T04:34:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/a0/25/646c301a9d1fe3825a2ff8362594c4aee0ceb2ce192f49469f7f2824a742/hypothesis-6.168.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3218c6d18a041c0eba0cccd88b20d74be24687e42a73338abd0a31ec5294c265", size = 1121414, upload-time = "2026-09-23T04:34:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/27/4f/f84ec8434ef765e6a5c4f9942bbb7652be444b23b9ff868da43e96e61e86/hypothesis-6.168.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760cad0526a84122bbe2a0157217e16d76d649d333422142853c7f1ba43ca44d", size = 1167536, upload-time = "2026-09-23T04:33:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/38/74/c53334d793b0c54054ffc781cdf79ccae6ee51daf347bd9cfa7e0015cece/hypothesis-6.168.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50a60b57b485dff68b56f99e4506b7f1b52994f801ee1aaa4cb9125a90bf7834", size = 1297122, upload-time = "2026-09-23T04:34:17.235Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/b115a1faea6d40bddce6342da703b7536d803d2384fa50d277dc55170c64/hypothesis-6.168.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68017d433ca5c558c34e3308a6653fc63077749af8bf86c10850cb6bb3cb559f", size = 1334055, upload-time = "2026-09-23T04:35:37.298Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/f272620d304dd67ff7815509745e2fb8d2bf58e4e1da4b7842a83f1de2da/hypothesis-6.168.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff92099a0185ba14bf4bc2111299b456074596199148ae84590eee4d480fac2e", size = 681804, upload-time = "2026-09-23T04:35:35.374Z" }, + { url = "https://files.pythonhosted.org/packages/54/e9/45595951f8bc9bd03f032325e7b176ca990a0c86bf2e09d481e8ee5a5cab/hypothesis-6.168.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:e9510e33467d586a7700d56b9a6ee65b44a06832c1cab3d270369d7c53381276", size = 791046, upload-time = "2026-09-23T04:35:55.724Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3e/718431d0ab72c4c07454f71040b4de298a63e765f20088557dc1bef0a48b/hypothesis-6.168.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:99970e3a75f11dde0414d3d6724bd8b7a2bd1deeb9a509ec9236c91d41a5cff0", size = 782982, upload-time = "2026-09-23T04:34:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/d7/05/c60db6df1456a70a3c589e42acaa446c22f56ed342c1b8dd04b8e49df8fd/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5ea78773de5bcc25266e37eb086472e90a85fc81b8d4a9ca8afc15858ee15fb", size = 1120961, upload-time = "2026-09-23T04:33:43.494Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fe/d943250395750ecc3b4ec95b1ab3c8119f6cbad08c1ba9169ffee7d89049/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13f502ce2423e46dfcd2af38950a86ecf97084d94e721f7f13dd5a70afb36e0a", size = 1143877, upload-time = "2026-09-23T04:34:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9d/8c1f8e11f20fd81ce40caed7ad6d6a9776e24775070e87f3aa9ba3683272/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b4bcaa8b0e753fc8921011d5d10c8a7c3caf814c6a968bc140a08917973fdc", size = 1146503, upload-time = "2026-09-23T04:34:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/71/d8/ac2e9fa653d2f05a13724837007e857d376d81fdf728543ab85f6c7f7350/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ed811908d38ca8e05c20ad25ecb61edc1e20a519667d4b668be49dd09e6bf3e", size = 1189216, upload-time = "2026-09-23T04:34:36.271Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7b/640952448781c7df5098880e2e2d6e7702b3a1c0badd4c0ea5fca6d6ab53/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9239c868418088d58ae18d041f9f424bd7a75dd143f209c35fde38770b65f74f", size = 1166919, upload-time = "2026-09-23T04:35:16.498Z" }, + { url = "https://files.pythonhosted.org/packages/b6/de/0d46b330a28f7614850fb1a0564cf88af7c0b29cbd791e9d04ade7f66f13/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:5cac279798ecc92d3301ec4421c0458e22627f8a79fdde8a6c98ab1d4b8811f6", size = 1126652, upload-time = "2026-09-23T04:35:49.432Z" }, + { url = "https://files.pythonhosted.org/packages/92/36/6adeca1724ed4966095406ffe7308fcff338cf9f5b7443d5ea34a01ab6e1/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bc660dc14d633641e22e407417d35bf2925331273ed71bc6ab5536dfca5c72d", size = 1155687, upload-time = "2026-09-23T04:35:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/e9b7c7e4a0a75d1621be95a126999352e01a16d68b0a59b396695c7b4991/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:157a29e4d6bcb77ae732cb83d6546beef47265ecac88408fdd5d5b2bd325685d", size = 1296471, upload-time = "2026-09-23T04:34:12.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/52cb320e9e07cc67f718506617b5900344370394e39f4affada317aab313/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:b125aac89594ee2a76687b724f057b2423ea83dfee6b8ff59f54289d6eef19a1", size = 1421838, upload-time = "2026-09-23T04:34:20.451Z" }, + { url = "https://files.pythonhosted.org/packages/9b/93/969c33a87cd2865beffebe491294b8115e7e7421c6840f79a67486535bfc/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:4b960c9d31b85dbc6a25888897c02bbea5f498844932841612925184fc78d9eb", size = 1373994, upload-time = "2026-09-23T04:34:00.608Z" }, + { url = "https://files.pythonhosted.org/packages/34/c9/5fe4ade4bd23baa2e4cd27668bb8ac10e840ce19fbcfc74116ea529cbaa7/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:d4d76f35874471fe9b8e1bb696fb532363ef3d9588161a06d30520f638f15a39", size = 1278216, upload-time = "2026-09-23T04:34:07.253Z" }, + { url = "https://files.pythonhosted.org/packages/a1/17/30b9a1ad975e90700dea74ce8b08ab07b9e58c4d83918422bc8841942c1e/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:d5147c5f7497d109eed0267c498adaf5cad6e4beeaca6fad6057f2007bae70a7", size = 1297647, upload-time = "2026-09-23T04:35:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2f/233a9d5adafad6e187ab8f4d9275592cf120b56e63ea1cfb02213c01021e/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a909f89758e184c4f86418598de297a2ced5b922979c0f98c5d73ff6bafe38e5", size = 1333790, upload-time = "2026-09-23T04:34:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/10/d7/4c9d4d597f70316d781cd99b600adae090a6f5d675487196e10510a23afe/hypothesis-6.168.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:66b9fb6f02840f8ffa2a4e6359aff6ecc1b2c6630c8a2066771044fa22956c7a", size = 675206, upload-time = "2026-09-23T04:35:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a8/f9fc0c0dbb9e80238fc7145cb4ce52e0897ea417a23217022911855bccda/hypothesis-6.168.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:9246eaa80044c33a182be661cbed7e6a7314c61359ef501ea9e9efd90d50ca3b", size = 681503, upload-time = "2026-09-23T04:34:11.223Z" }, + { url = "https://files.pythonhosted.org/packages/11/b4/962ae350c10860be37c9abf98c0ef1ef23e4baca3ee7ad77208941e370e5/hypothesis-6.168.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:52e0c804cbf697d4760400c84444a91cbd5dfe7ae0ef1bb3228f74fb5547eb9e", size = 679199, upload-time = "2026-09-23T04:34:03.472Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -39,6 +255,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -57,6 +373,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -82,6 +488,171 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + [[package]] name = "ruff" version = "0.16.9" @@ -106,3 +677,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] From 69c6c3c8fe99d90d3141f2df666625171d418c08 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:04:20 -0500 Subject: [PATCH 02/52] server: result schema gains rule_requires_review and clarifies auto_approve Additive: a new unsure_cause value for situations a policy sends to a person (a cable routed over a door), and auto_approve now says it also covers rejects. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/schemas/result.schema.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index a334b8c2e..db19e6070 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -35,7 +35,7 @@ "properties": { "id": {"type": ["string", "null"]}, "version": {"type": ["string", "null"]}, - "auto_approve": {"type": "boolean", "description": "False while the rules hold placeholder values; a would-be pass then becomes manual_review."}, + "auto_approve": {"type": "boolean", "description": "False while the rules hold placeholder values or no policy is selected; every would-be pass or reject then becomes manual_review."}, "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."} } @@ -137,8 +137,8 @@ "label": {"type": "string"}, "outcome": {"$ref": "#/$defs/outcome"}, "unsure_cause": { - "enum": ["margin", "unobserved", "unknown_attribute"], - "description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen, more photos fix it. unknown_attribute: a fact the camera did not establish (for example whether a window opens)." + "enum": ["margin", "unobserved", "unknown_attribute", "rule_requires_review"], + "description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen, more photos fix it. unknown_attribute: a fact the camera did not establish (for example whether a window opens). rule_requires_review: the policy sends this situation to a person (for example a cable routed over a door)." }, "reason": {"type": "string"}, "measured_ft": {"type": ["number", "null"], "description": "The deciding measurement, for example the gap to the nearest gas meter. Null when nothing relevant was found or it was not observed."}, From cb9ad5a96f0bf94895d9a2e9f2cf75ccd05cc5c7 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:09:36 -0500 Subject: [PATCH 03/52] server: placement solver, rules and scene ingest rules.yaml holds every threshold with its citation (placeholders labelled) and deep-merges a git-ignored private/rules.yaml. scene.py chains the walls left to right, unrolls s from the meter and keeps what was observed as geometry. solver.py sweeps the footprint over each straight segment (2 in grid plus every position where a check changes outcome, plus midpoints), runs every check under the strict rule, routes the cable along the wall and decides. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/rules.py | 178 +++++ server/rules.yaml | 99 +++ server/scene.py | 561 ++++++++++++++++ server/schemas/scene.schema.json | 2 +- server/solver.py | 1053 ++++++++++++++++++++++++++++++ server/tests/helpers.py | 94 +++ 6 files changed, 1986 insertions(+), 1 deletion(-) create mode 100644 server/rules.py create mode 100644 server/rules.yaml create mode 100644 server/scene.py create mode 100644 server/solver.py create mode 100644 server/tests/helpers.py diff --git a/server/rules.py b/server/rules.py new file mode 100644 index 000000000..75dabb703 --- /dev/null +++ b/server/rules.py @@ -0,0 +1,178 @@ +"""Load rules.yaml, merge the private override over it, and validate the result. + +Every threshold the solver compares against lives in the rules file. A missing or misspelled key is +a startup error naming the key, never a silent default. +""" + +import copy +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field + +SERVER_DIR = Path(__file__).resolve().parent +PUBLIC_RULES = SERVER_DIR / "rules.yaml" +PRIVATE_RULES_ENV = "HOUSESCAN_PRIVATE_RULES" +DEFAULT_PRIVATE_RULES = SERVER_DIR.parent / "private" / "rules.yaml" + +Effect = Literal["fail", "review", "detour", "allow"] +ObjectType = Literal[ + "window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool" +] +GroundType = Literal["drive", "concrete", "gravel", "lawn", "mulch", "deck"] + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class Value(_Strict): + value: float = Field(ge=0, allow_inf_nan=False) + source: str = Field(min_length=1) + placeholder: bool = False + + +class Policy(_Strict): + id: str | None + version: str | None + auto_approve: bool + allow_reject: bool + + +class Battery(_Strict): + width_ft: Value + depth_ft: Value + height_ft: Value + + +class Errors(_Strict): + tap_ft: Value + vlm_ft: Value + mesh_ft: Value + tape_ft: Value + wall_ft: Value + meter_ft: Value + + +class Sweep(_Strict): + step_ft: Value + wall_join_ft: Value + meter_to_wall_max_ft: Value + + +class Clearances(_Strict): + gas_ft: Value + ac_ft: Value + opening_ft: Value + drive_ft: Value + pool_ft: Value + wall_equipment_ft: Value + + +class Openings(_Strict): + types: list[ObjectType] + exempt_fixed_windows: bool + exempt_bottom_above_ft: float | None + + +class WallEquipment(_Strict): + types: list[ObjectType] + + +class Facing(_Strict): + min_ft: Value + measured_from: Literal["battery_front", "wall"] + + +class Headroom(_Strict): + min_ft: Value + + +class MeterWorkingSpace(_Strict): + width_ft: Value + depth_ft: Value + + +class Ground(_Strict): + allowed: list[GroundType] + drivable: list[GroundType] + source: str = Field(min_length=1) + placeholder: bool + + +class Route(_Strict): + max_ft: Value + confident_reach_ft: Value + height_ft: Value + corner_allowance_ft: Value + crossing: dict[ObjectType, Effect] + + +class Rules(_Strict): + policy: Policy + battery: Battery + errors: Errors + sweep: Sweep + clearances: Clearances + openings: Openings + wall_equipment: WallEquipment + facing: Facing + headroom: Headroom + meter_working_space: MeterWorkingSpace + ground: Ground + route: Route + + +@dataclass(frozen=True) +class LoadedRules: + rules: Rules + sources: tuple[str, ...] + sha256: str + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Mappings merge key by key; any other override value replaces the base value.""" + merged = copy.deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = deep_merge(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def _read_yaml(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text()) + if not isinstance(data, dict): + raise ValueError(f"{path}: expected a mapping at the top level") + return data + + +def rules_from_dict(data: dict[str, Any], sources: tuple[str, ...] = ("public",)) -> LoadedRules: + rules = Rules.model_validate(data) + canonical = json.dumps(rules.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + return LoadedRules(rules, sources, hashlib.sha256(canonical.encode()).hexdigest()) + + +def public_rules_dict() -> dict[str, Any]: + return _read_yaml(PUBLIC_RULES) + + +def load_rules(private_path: Path | None = None) -> LoadedRules: + """Public rules, with the private file merged over them when it exists.""" + data = public_rules_dict() + sources: tuple[str, ...] = ("public",) + if private_path is None: + env = os.environ.get(PRIVATE_RULES_ENV) + private_path = Path(env) if env else DEFAULT_PRIVATE_RULES + if env and not private_path.is_file(): + raise FileNotFoundError(f"{PRIVATE_RULES_ENV}={env} does not name a file") + if private_path.is_file(): + data = deep_merge(data, _read_yaml(private_path)) + sources = ("public", "private") + return rules_from_dict(data, sources) diff --git a/server/rules.yaml b/server/rules.yaml new file mode 100644 index 000000000..4655e8298 --- /dev/null +++ b/server/rules.yaml @@ -0,0 +1,99 @@ +# Placement rules. Every length is in feet. +# +# Each number is {value, source}; `placeholder: true` marks a demo value with no public source. +# The solver hardcodes no distance: everything it compares against comes from here. +# +# At startup the server deep-merges a git-ignored private/rules.yaml over this file when one exists +# (path: $HOUSESCAN_PRIVATE_RULES, else /private/rules.yaml). Mappings merge key by key; +# anything else in the private file replaces the public value. Never commit Base's values. + +policy: + id: public-demo + version: "2026-09-26" + # False because driveway, pool and reach values below are placeholders: a spot that passes every + # check still goes to manual review, and nothing is rejected automatically. + auto_approve: false + # Whether this policy may reject a site at all (some policies only pass or send to review). + allow_reject: true + +battery: + width_ft: {value: 2.583333333333, source: "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/01 C2)"} + depth_ft: {value: 1.833333333333, source: "Base Core spec: 22 in deep"} + height_ft: {value: 3.291666666667, source: "Base Core spec: 39.5 in tall"} + +# Default measurement error by how a position was measured, used when an item has no plus_minus_ft. +# These are the plan's estimates (docs/02 Lane C), not measured error bars; the D3 tape test checks them. +errors: + tap_ft: {value: 0.3, source: "docs/02 Lane C estimate for AR taps; untested"} + vlm_ft: {value: 1.5, source: "docs/02 Lane C estimate for photo detections; untested"} + mesh_ft: {value: 0.5, source: "docs/02 Lane C estimate for the LiDAR mesh; untested"} + tape_ft: {value: 0.05, source: "Tape measure read to the nearest half inch", placeholder: true} + wall_ft: {value: 0.3, source: "Walls come from AR taps: docs/02 Lane C estimate; untested"} + meter_ft: {value: 0.3, source: "The meter is an AR tap: docs/02 Lane C estimate; untested"} + +sweep: + step_ft: {value: 0.166666666667, source: "docs/01 C2: slide the footprint in 2 in steps"} + # Wall ends closer than this are one corner, not a stretch with no wall. Two taps of one corner can + # differ by twice the tap error. + wall_join_ft: {value: 0.6, source: "Twice the AR tap error estimate", placeholder: true} + # The meter must lie this close to its wall's line, or the scene is refused as malformed. + meter_to_wall_max_ft: {value: 3.0, source: "Input sanity bound", placeholder: true} + +clearances: + gas_ft: {value: 3.0, source: "Base help page: 3 ft from the gas meter; Austin Energy §1.9; Texas Gas Service"} + ac_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries"} + opening_ft: {value: 3.0, source: "IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling"} + drive_ft: {value: 5.0, source: "No public value (docs/01 rules table: TBD)", placeholder: true} + pool_ft: {value: 10.0, source: "No public value (docs/01 rules table: TBD)", placeholder: true} + # Wall-mounted equipment directly above the battery: minimum sideways gap along the wall. + wall_equipment_ft: {value: 0.0, source: "No public value; 0 means only overlap fails", placeholder: true} + +openings: + types: [door, window] + # When true, a window recorded as not operable does not count. Unknown operability always counts. + exempt_fixed_windows: false + # A window whose bottom edge is higher than this does not count (upper floors). null: all count. + exempt_bottom_above_ft: null + +wall_equipment: + types: [elec_box, vent] + +facing: + min_ft: {value: 3.0, source: "Base help page: 3 ft from fences"} + # battery_front: the gap is measured from the battery's front face (wall gap minus battery depth). + # wall: the gap is measured from the wall, and min_ft already includes the battery's depth. + measured_from: battery_front + +headroom: + min_ft: {value: 6.5, source: "NEC 110.26(A)(3) working-space headroom, applied to the battery as a demo choice", placeholder: true} + +meter_working_space: + width_ft: {value: 2.5, source: "NEC 110.26(A)(2): 30 in wide working space"} + depth_ft: {value: 3.0, source: "NEC 110.26(A)(1): 36 in deep for 0-150 V to ground"} + +ground: + # Surfaces a battery may stand on. Anything else under the footprint fails; unknown ground is UNSURE. + allowed: [concrete, gravel, lawn, mulch] + # Surfaces vehicles reach, measured by clearances.drive_ft. + drivable: [drive] + source: "docs/01 ground types; which surfaces are allowed is a demo choice" + placeholder: true + +route: + max_ft: {value: 20.0, source: "Base help page: within 20 ft of the meter"} + # Past this length the route is UNSURE; beyond max_ft it fails. + confident_reach_ft: {value: 15.0, source: "No public value", placeholder: true} + height_ft: {value: 1.0, source: "Cable run height above ground", placeholder: true} + corner_allowance_ft: {value: 0.5, source: "docs/02 Lane C: a little per corner", placeholder: true} + # What a wall object across the route does: fail, review (UNSURE for a person), detour (go over or + # under it at route height), allow (pass behind it). Types not listed are allowed. + crossing: + door: fail + garage_door: fail + window: detour + elec_box: detour + vent: detour + gas_meter: detour + ac: allow + downspout: allow + pool: allow diff --git a/server/scene.py b/server/scene.py new file mode 100644 index 000000000..ac8e85c38 --- /dev/null +++ b/server/scene.py @@ -0,0 +1,561 @@ +"""Turn an uploaded scene.json into plan geometry the solver can measure. + +The walls are chained left to right and unrolled into one coordinate s, the distance along the +chain from the meter (negative to the left). Every straight baseline segment knows its outward +direction from its point order (scene.schema.json: points run left to right as seen from outside, +so outward is the direction turned 90 degrees clockwise viewed from above). Clearances are measured +in plan (x, z) with shapely, so two things close together round an inside corner are close even +when they are far apart in s. + +What the capture observed is kept as geometry too. The unobserved part of the outdoor area near the +walls is precomputed once, so "was everything within 3 ft of this footprint seen?" is one distance. +""" + +import hashlib +import itertools +import json +import math +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator +from shapely import Geometry, LineString, Point, Polygon, unary_union +from shapely.validation import explain_validity + +from rules import Rules + +SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "scene.schema.json" +_VALIDATOR = Draft202012Validator(json.loads(SCHEMA_PATH.read_text())) + +# Tolerance for comparing lengths in feet. Far below any measurement error; it only keeps floating +# point noise (3.3 - 0.3 != 3.0) from deciding a check. +EPS = 1e-9 +# Arc resolution for the outdoor-area wedges at corners. +_ARC_STEPS = 8 + +Point2 = tuple[float, float] + + +class SceneError(ValueError): + """The upload is not a usable scene. `path` points at the offending field.""" + + def __init__(self, path: str, message: str) -> None: + super().__init__(f"{path}: {message}") + self.path = path + self.message = message + + +def _sub(a: Point2, b: Point2) -> Point2: + return (a[0] - b[0], a[1] - b[1]) + + +def _norm(v: Point2) -> float: + return math.hypot(v[0], v[1]) + + +@dataclass(frozen=True) +class Piece: + """A straight stretch of the chain in s: a wall segment, a gap with no wall, or an extension + that continues an end segment past the end of the walk.""" + + kind: str # "wall", "gap" or "extension" + wall_id: str | None + index: int # segment index within its wall; -1 for gaps and extensions + a: Point2 + b: Point2 + s0: float + s1: float + along: Point2 + outward: Point2 + plus_minus: float + + def point(self, s: float, out: float = 0.0) -> Point2: + t = s - self.s0 + return ( + self.a[0] + self.along[0] * t + self.outward[0] * out, + self.a[1] + self.along[1] * t + self.outward[1] * out, + ) + + def rect(self, s_lo: float, s_hi: float, out_lo: float, out_hi: float) -> Polygon: + return Polygon( + [ + self.point(s_lo, out_lo), + self.point(s_hi, out_lo), + self.point(s_hi, out_hi), + self.point(s_lo, out_hi), + ] + ) + + def local(self, p: Point2) -> Point2: + """(s, out) of a plan point relative to this piece's line.""" + d = _sub(p, self.a) + return ( + self.s0 + d[0] * self.along[0] + d[1] * self.along[1], + d[0] * self.outward[0] + d[1] * self.outward[1], + ) + + +@dataclass(frozen=True) +class SceneObject: + index: int + type: str + wall_id: str + span: tuple[float, float] + bottom: float | None + top: float | None + operable: bool | None + well: bool | None + source: str + plus_minus: float + geom: Geometry + points: tuple[Point2, ...] # vertices used to place candidate starts near the clearance edge + + @property + def label(self) -> str: + return f"objects[{self.index}] {self.type}" + + +@dataclass(frozen=True) +class GroundPatch: + index: int + type: str + polygon: Polygon + plus_minus: float + + +@dataclass(frozen=True) +class Measured: + """An overhead clearance or a facing gap over a stretch of wall.""" + + index: int + span: tuple[float, float] + value: float + plus_minus: float + + +@dataclass +class Scene: + raw: dict[str, Any] + input_sha256: str + pieces: list[Piece] # extension, walls and gaps in chain order, extension + meter_pos: tuple[float, float, float] + meter_plus_minus: float + meter_piece: Piece + objects: list[SceneObject] + ground: list[GroundPatch] + overheads: list[Measured] + facing: list[Measured] + observed: dict[str, list[tuple[float, float, float | None]]] + end_kinds: dict[str, str] + reach_ft: float # how far out from the walls the outdoor area is modelled + _cache: dict[str, Any] = field(default_factory=dict, repr=False) + + # --- chain ----------------------------------------------------------------------------------- + + @property + def walls(self) -> list[Piece]: + return [p for p in self.pieces if p.kind == "wall"] + + @property + def gaps(self) -> list[Piece]: + return [p for p in self.pieces if p.kind == "gap"] + + @property + def s_min(self) -> float: + return self.walls[0].s0 + + @property + def s_max(self) -> float: + return self.walls[-1].s1 + + @property + def meter_xz(self) -> Point2: + return (self.meter_pos[0], self.meter_pos[2]) + + def piece_at(self, s: float) -> Piece: + for p in self.pieces: + if p.s0 - EPS <= s <= p.s1 + EPS: + return p + return self.pieces[0] if s < self.pieces[0].s0 else self.pieces[-1] + + def point_at(self, s: float, out: float = 0.0) -> Point2: + return self.piece_at(s).point(s, out) + + def polyline(self, s_a: float, s_b: float) -> list[Point2]: + """Plan points along the chain from s_a to s_b (either order), corners included.""" + lo, hi = min(s_a, s_b), max(s_a, s_b) + pts = [self.point_at(lo)] + for p in self.pieces: + if lo + EPS < p.s1 < hi - EPS: + pts.append(p.b) + pts.append(self.point_at(hi)) + return pts if s_a <= s_b else pts[::-1] + + def band_polygon(self, s_lo: float, s_hi: float, out: float) -> Geometry: + """The ground strip in front of the chain from s_lo to s_hi, out to `out` from the wall, + with the wedges that fill the outside of convex corners.""" + parts: list[Geometry] = [] + if s_hi - s_lo <= EPS or out <= EPS: + return Polygon() + inside = [p for p in self.pieces if p.s1 > s_lo + EPS and p.s0 < s_hi - EPS] + for p in inside: + parts.append(p.rect(max(s_lo, p.s0), min(s_hi, p.s1), 0.0, out)) + for prev, nxt in itertools.pairwise(inside): + parts.append(_wedge(prev.b, prev.outward, nxt.outward, out)) + return unary_union(parts) + + def wall_line(self, s_lo: float, s_hi: float) -> Geometry: + if s_hi - s_lo <= EPS: + return Point(self.point_at(s_lo)) + return LineString(self.polyline(s_lo, s_hi)) + + # --- coverage -------------------------------------------------------------------------------- + + def observed_intervals(self, band: str) -> list[tuple[float, float]]: + return merge_intervals([(a, b) for a, b, _ in self.observed.get(band, [])]) + + def missing(self, band: str, s_lo: float, s_hi: float) -> list[tuple[float, float]]: + """Parts of [s_lo, s_hi] not observed in a 1D band.""" + return subtract_intervals((s_lo, s_hi), self.observed_intervals(band)) + + 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", []) + ] + ) + self._cache["ground"] = outdoor.difference(seen.buffer(1e-6)) + return self._cache["ground"] + + def unobserved_wall(self) -> Geometry: + if "wall" not in self._cache: + lo, hi = self.pieces[0].s0, self.pieces[-1].s1 + gaps = subtract_intervals((lo, hi), self.observed_intervals("wall")) + self._cache["wall"] = unary_union([self.wall_line(a, b) for a, b in gaps]) + return self._cache["wall"] + + +def merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]: + out: list[tuple[float, float]] = [] + for a, b in sorted(intervals): + if out and a <= out[-1][1] + EPS: + out[-1] = (out[-1][0], max(out[-1][1], b)) + else: + out.append((a, b)) + return out + + +def subtract_intervals( + target: tuple[float, float], covered: list[tuple[float, float]] +) -> list[tuple[float, float]]: + lo, hi = target + missing: list[tuple[float, float]] = [] + cursor = lo + for a, b in merge_intervals(covered): + if b <= cursor + EPS: + continue + if a >= hi - EPS: + break + if a > cursor + EPS: + missing.append((cursor, min(a, hi))) + cursor = max(cursor, b) + if cursor >= hi - EPS: + break + if cursor < hi - EPS: + missing.append((cursor, hi)) + return missing + + +def _wedge(v: Point2, n1: Point2, n2: Point2, radius: float) -> Polygon: + a1, a2 = math.atan2(n1[1], n1[0]), math.atan2(n2[1], n2[0]) + delta = (a2 - a1 + math.pi) % (2 * math.pi) - math.pi + pts = [v] + for k in range(_ARC_STEPS + 1): + ang = a1 + delta * k / _ARC_STEPS + # Scale up so the polygon's chords stay outside the arc. + r = radius / math.cos(abs(delta) / (2 * _ARC_STEPS)) + pts.append((v[0] + r * math.cos(ang), v[1] + r * math.sin(ang))) + poly = Polygon(pts) + return poly if poly.is_valid and poly.area > EPS else Polygon() + + +def _outward(along: Point2) -> Point2: + # Points run left to right seen from outside, so outward is `along` turned clockwise viewed + # from above: (ux, uz) -> (-uz, ux). + return (-along[1], along[0]) + + +def _xz(point: list[float], path: str) -> Point2: + return (float(point[0]), float(point[1])) + + +def _span(value: list[float], path: str) -> tuple[float, float]: + a, b = float(value[0]), float(value[1]) + if a > b: + raise SceneError(path, f"span_ft must be [start, end] with start <= end, got [{a}, {b}]") + return (a, b) + + +def _geometry(points: list[Point2], path: str) -> Geometry: + if len(points) == 1: + return Point(points[0]) + if len(points) == 2: + return LineString(points) + poly = Polygon(points) + if not poly.is_valid: + raise SceneError(path, f"polygon is not simple ({explain_validity(poly)})") + return poly + + +def _error(item: dict[str, Any], default: float) -> float: + return float(item["plus_minus_ft"]) if "plus_minus_ft" in item else default + + +def validate_schema(raw: Any) -> None: + errors = sorted(_VALIDATOR.iter_errors(raw), key=lambda e: list(e.absolute_path)) + if errors: + e = errors[0] + path = "/" + "/".join(str(p) for p in e.absolute_path) + raise SceneError(path, e.message) + + +def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = None) -> Scene: + """Validate against scene.schema.json, then build the unrolled chain and plan geometry.""" + validate_schema(raw) + if input_bytes is None: + input_bytes = json.dumps(raw, sort_keys=True, separators=(",", ":")).encode() + errors = rules.errors + source_error = { + "tap": errors.tap_ft.value, + "vlm": errors.vlm_ft.value, + "tape": errors.tape_ft.value, + } + + # 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] = [] + wall_ids: set[str] = set() + s = 0.0 + prev_end: Point2 | None = None + for wi, wall in enumerate(raw["walls"]): + wid = wall["id"] + if wid in wall_ids: + raise SceneError(f"/walls/{wi}/id", f"duplicate wall id {wid!r}") + wall_ids.add(wid) + pts = [_xz(p, f"/walls/{wi}/baseline") for p in wall["baseline"]] + wall_err = _error(wall, errors.wall_ft.value) + if prev_end is not None: + gap = _norm(_sub(pts[0], prev_end)) + if gap > join_tol: + along = ( + (pts[0][0] - prev_end[0]) / gap, + (pts[0][1] - prev_end[1]) / gap, + ) + pieces.append( + Piece("gap", None, -1, prev_end, pts[0], s, s + gap, along, _outward(along), 0) + ) + s += gap + for i in range(len(pts) - 1): + length = _norm(_sub(pts[i + 1], pts[i])) + if length < 1e-6: + raise SceneError(f"/walls/{wi}/baseline/{i + 1}", "repeats the previous point") + along = ((pts[i + 1][0] - pts[i][0]) / length, (pts[i + 1][1] - pts[i][1]) / length) + pieces.append( + Piece( + "wall", + wid, + i, + pts[i], + pts[i + 1], + s, + s + length, + along, + _outward(along), + wall_err, + ) + ) + s += length + prev_end = pts[-1] + + # Place the meter: s = 0 at its projection onto its own wall. + meter = raw["meter"] + if meter["wall_id"] not in wall_ids: + raise SceneError("/meter/wall_id", f"no wall with id {meter['wall_id']!r}") + mpos = tuple(float(v) for v in meter["pos"]) + mxz = (mpos[0], mpos[2]) + best: tuple[float, Piece, float] | None = None + for p in pieces: + if p.wall_id != meter["wall_id"]: + continue + s_local, _out = p.local(mxz) + s_clamped = min(max(s_local, p.s0), p.s1) + dist = _norm(_sub(mxz, p.point(s_clamped))) + if best is None or dist < best[0]: + best = (dist, p, s_clamped) + assert best is not None + max_off = rules.sweep.meter_to_wall_max_ft.value + if best[0] > max_off: + raise SceneError( + "/meter/pos", + f"the meter is {best[0]:.2f} ft from wall {meter['wall_id']!r}; " + f"more than {max_off} ft means the scene is misaligned", + ) + shift = best[2] + pieces = [ + Piece( + p.kind, + p.wall_id, + p.index, + p.a, + p.b, + p.s0 - shift, + p.s1 - shift, + p.along, + p.outward, + p.plus_minus, + ) + for p in pieces + ] + meter_piece = next( + p + for p in pieces + if p.kind == "wall" and p.wall_id == best[1].wall_id and p.index == best[1].index + ) + + # How far out from the walls the outdoor area matters: the largest clearance plus the battery. + c = rules.clearances + reach = max( + c.gas_ft.value, c.ac_ft.value, c.opening_ft.value, c.drive_ft.value, c.pool_ft.value + ) + reach += rules.battery.depth_ft.value + 1.0 + first, last = pieces[0], pieces[-1] + ext_len = reach + rules.battery.width_ft.value + left_ext = Piece( + "extension", + None, + -1, + first.point(first.s0 - ext_len), + first.a, + first.s0 - ext_len, + first.s0, + first.along, + first.outward, + first.plus_minus, + ) + right_ext = Piece( + "extension", + None, + -1, + last.b, + last.point(last.s1 + ext_len), + last.s1, + last.s1 + ext_len, + last.along, + last.outward, + last.plus_minus, + ) + pieces = [left_ext, *pieces, right_ext] + + scene = Scene( + raw=raw, + input_sha256=hashlib.sha256(input_bytes).hexdigest(), + pieces=pieces, + meter_pos=mpos, # type: ignore[arg-type] + meter_plus_minus=_error(meter, errors.meter_ft.value), + meter_piece=meter_piece, + objects=[], + ground=[], + overheads=[], + facing=[], + observed={}, + end_kinds={"left": "unexplored", "right": "unexplored"}, + reach_ft=reach, + ) + + for i, obj in enumerate(raw.get("objects", [])): + path = f"/objects/{i}" + if obj["wall_id"] not in wall_ids: + raise SceneError(f"{path}/wall_id", f"no wall with id {obj['wall_id']!r}") + span = _span(obj["span_ft"], f"{path}/span_ft") + bottom, top = obj.get("bottom_ft"), obj.get("top_ft") + if bottom is not None and top is not None and bottom > top: + raise SceneError(path, f"bottom_ft {bottom} is above top_ft {top}") + if "footprint" in obj: + pts = [_xz(p, f"{path}/footprint") for p in obj["footprint"]] + geom = _geometry(pts, f"{path}/footprint") + else: + geom = scene.wall_line(*span) + pts = [scene.point_at(span[0]), scene.point_at(span[1])] + attrs = obj.get("attrs", {}) + scene.objects.append( + SceneObject( + index=i, + type=obj["type"], + wall_id=obj["wall_id"], + span=span, + bottom=None if bottom is None else float(bottom), + top=None if top is None else float(top), + operable=attrs.get("operable"), + well=attrs.get("well"), + source=obj["source"], + plus_minus=_error(obj, source_error[obj["source"]]), + geom=geom, + points=tuple(pts), + ) + ) + + for i, patch in enumerate(raw.get("ground", [])): + poly = _geometry([_xz(p, "") for p in patch["polygon"]], f"/ground/{i}/polygon") + scene.ground.append(GroundPatch(i, patch["type"], poly, _error(patch, errors.tap_ft.value))) + + for key, target, value_key in ( + ("overheads", scene.overheads, "clearance_ft"), + ("facing", scene.facing, "depth_ft"), + ): + for i, item in enumerate(raw.get(key, [])): + if item["wall_id"] not in wall_ids: + raise SceneError(f"/{key}/{i}/wall_id", f"no wall with id {item['wall_id']!r}") + target.append( + Measured( + i, + _span(item["span_ft"], f"/{key}/{i}/span_ft"), + float(item[value_key]), + _error(item, errors.mesh_ft.value), + ) + ) + + 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"))) + for side, end in coverage.get("ends", {}).items(): + scene.end_kinds[side] = end["kind"] + + _check_orientation(scene) + return scene + + +def _check_orientation(scene: Scene) -> None: + """Cameras stand outside the house. If most keyframes sit on the inward side of the wall they + face, the baseline points were almost certainly ordered right to left.""" + walls = scene.walls + inward = total = 0 + for kf in scene.raw.get("keyframes", []): + pose = kf["pose"] + cam = (float(pose[12]), float(pose[14])) + nearest = min(walls, key=lambda p: Point(cam).distance(LineString([p.a, p.b]))) + s_local, out = nearest.local(cam) + if nearest.s0 <= s_local <= nearest.s1: + total += 1 + inward += out < 0 + if total >= 3 and inward * 2 > total: + raise SceneError( + "/walls", + f"{inward} of {total} keyframe cameras sit on the inward side of the wall they face; " + "baseline points must run left to right as seen from outside", + ) diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index f0477033e..e838f327c 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -140,7 +140,7 @@ "properties": { "band": { "enum": ["wall", "ground", "overhead", "facing"], - "description": "wall: the wall face from the ground to battery height, which also covers the cable route. 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." + "description": "wall: the wall face from the ground up to headroom height (boxes and vents above the battery, openings), which also covers the cable route. 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."}, "out_ft": {"type": "number", "minimum": 0, "description": "Required for the ground band: how far out from the wall the ground was seen."} diff --git a/server/solver.py b/server/solver.py new file mode 100644 index 000000000..6385b7b72 --- /dev/null +++ b/server/solver.py @@ -0,0 +1,1053 @@ +"""The placement solver: a pure function from a parsed scene and the rules to a result. + +It slides the battery footprint along every straight wall segment, runs every check at each start +position, and decides. Every check follows the strict rule (plan contract C5, docs/02 Lane C): +PASS only when the margin is larger than the error, FAIL only when the value is past the threshold +by more than the error, otherwise UNSURE. An area nobody observed is never a pass. + +Start positions are a 2 in grid plus every position where some check can change its outcome (an +opening's clearance edge, a coverage boundary, a ground patch edge), plus the midpoints between +those. A feasible stretch narrower than the grid step is therefore still found (golden test 12). +""" + +import itertools +import math +import time +from dataclasses import dataclass, field +from typing import Any + +from shapely import Geometry, Polygon, unary_union + +from rules import LoadedRules, Rules, Value +from scene import EPS, Piece, Scene, SceneObject, merge_intervals +from units import format_ft_in + +PASS, FAIL, UNSURE = "pass", "fail", "unsure" +_SEVERITY = {PASS: 0, UNSURE: 1, FAIL: 2} +# Areas and lengths below this are floating point slivers, not geometry. +_MEASURE_EPS = 1e-6 +SCHEMA_VERSION = "1.0" + + +@dataclass +class Check: + id: str + label: str + outcome: str + reason: str + rule_key: str + rule: Value | None + rule_source: str = "" + rule_placeholder: bool = False + measured: float | None = None + plus_minus: float | None = None + threshold: float | None = None + comparison: str | None = None + subject: str | None = None + unsure_cause: str | None = None + missing: list[tuple[str, float, float]] = field(default_factory=list) + + def to_json(self) -> dict[str, Any]: + out: dict[str, Any] = { + "id": self.id, + "label": self.label, + "outcome": self.outcome, + "reason": self.reason, + "measured_ft": _round(self.measured), + "plus_minus_ft": _round(self.plus_minus), + "threshold_ft": _round(self.threshold), + "comparison": self.comparison, + "subject": self.subject, + "rule": { + "key": self.rule_key, + "source": self.rule.source if self.rule else self.rule_source, + "placeholder": self.rule.placeholder if self.rule else self.rule_placeholder, + }, + } + if self.outcome == UNSURE: + out["unsure_cause"] = self.unsure_cause or "margin" + return out + + +def _round(v: float | None) -> float | None: + return None if v is None else round(v, 6) + + +def at_least(value: float, error: float, threshold: float) -> str: + """A clearance: PASS when value - error > threshold, FAIL when value + error < threshold.""" + if value - error - threshold > EPS: + return PASS + if threshold - (value + error) > EPS: + return FAIL + return UNSURE + + +def reach_outcome(length: float, error: float, confident: float, maximum: float) -> str: + """A route length: PASS when clearly under the confident reach, FAIL when clearly over the + maximum, UNSURE in between or on either line.""" + if (length - error) - maximum > EPS: + return FAIL + if confident - (length + error) > EPS: + return PASS + return UNSURE + + +def worst(outcomes: list[str]) -> str: + return max(outcomes, key=_SEVERITY.__getitem__, default=PASS) + + +def ft(v: float) -> str: + return format_ft_in(abs(v)) if v >= 0 else "-" + format_ft_in(abs(v)) + + +def where(s: float) -> str: + if abs(s) < 1 / 24: + return "at the meter" + return f"{format_ft_in(abs(s))} {'left' if s < 0 else 'right'} of the meter" + + +@dataclass +class Route: + outcome: str + length: float + plus_minus: float + near_s: float + polyline: list[tuple[float, float]] + detours: list[dict[str, Any]] + crossings: list[dict[str, Any]] + + +@dataclass +class Candidate: + piece: Piece + s0: float + s1: float + footprint: Polygon + checks: list[Check] + route: Route + outcome: str + + def failing(self) -> list[str]: + return [c.id for c in self.checks if c.outcome == FAIL] + + def unsure(self) -> list[str]: + return [c.id for c in self.checks if c.outcome == UNSURE] + + +class Solver: + def __init__(self, scene: Scene, loaded: LoadedRules) -> None: + self.scene = scene + self.loaded = loaded + r: Rules = loaded.rules + self.r = r + self.W = r.battery.width_ft.value + self.D = r.battery.depth_ft.value + self.H = r.battery.height_ft.value + 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.pool = [o for o in objs if o.type == "pool"] + self.openings = [o for o in objs if o.type in r.openings.types] + self.equipment = [o for o in objs if o.type in r.wall_equipment.types] + self.route_objects = [ + o + for o in objs + if r.route.crossing.get(o.type, "allow") != "allow" # type: ignore[call-overload] + ] + 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() + self.unobserved_wall = scene.unobserved_wall() + ws = r.meter_working_space + 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._ground_error = max((g.plus_minus for g in scene.ground), default=0.0) + + # --- geometry helpers ---------------------------------------------------------------------- + + def footprint(self, piece: Piece, s0: float) -> Polygon: + return piece.rect(s0, s0 + self.W, 0.0, self.D) + + def _covered(self, fp: Polygon, unobserved: Geometry, radius: float) -> bool: + """True when nothing unobserved lies within `radius` of the footprint.""" + if unobserved.is_empty: + return True + if fp.distance(unobserved) > radius + _MEASURE_EPS: + return True + near = unobserved.intersection(fp.buffer(radius) if radius > 0 else fp) + return ( + near.area <= _MEASURE_EPS + if near.geom_type.endswith("Polygon") + else (near.length <= _MEASURE_EPS) + ) + + # --- checks -------------------------------------------------------------------------------- + + def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: + c = Check( + "wall_backing", + "Flush against one straight wall", + PASS, + "", + "battery.width_ft", + self.r.battery.width_ft, + ) + if piece.kind != "wall" or s0 < piece.s0 - EPS or s1 > piece.s1 + EPS: + c.outcome = FAIL + c.reason = ( + "The footprint runs past the end of its straight wall segment (a corner, the end " + "of the wall or a stretch with no wall), so the battery can't sit flush." + ) + return c + missing = self.scene.missing("wall", s0, s1) + if missing: + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.missing = [("wall", a, b) for a, b in missing] + c.reason = "Part of the wall behind the battery was not seen." + return c + c.reason = "The whole footprint backs onto one straight, observed wall segment." + return c + + def check_ground(self, piece: Piece, fp: Polygon) -> Check: + c = Check( + "ground_surface", + "Ground under the battery", + PASS, + "", + "ground.allowed", + None, + rule_source=self.r.ground.source, + rule_placeholder=self.r.ground.placeholder, + ) + ew = piece.plus_minus + for g in self.bad: + core = g.polygon.buffer(-(g.plus_minus + ew)) + if not core.is_empty and fp.intersection(core).area > _MEASURE_EPS: + c.outcome, c.subject = FAIL, f"ground[{g.index}] {g.type}" + c.reason = f"The footprint stands on {g.type}, which is not an allowed surface." + return c + if not self._covered(fp, self.unobserved_ground, 0.0): + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.missing = [("ground", *self._s_range(piece, fp, 0.0))] + c.reason = "The ground under the footprint was not seen." + return c + # A patch edge's error matters only where an allowed surface meets a disallowed or + # unrecorded one; an edge that runs along the house wall is not a boundary at all. + # Containment is inclusive: touching a disallowed patch along an exact edge is not + # standing on it (golden test 01). + on_good = self._good_union.covers(fp) + near_bad = any( + fp.intersection(g.polygon.buffer(g.plus_minus + ew)).area > _MEASURE_EPS + for g in self.bad + ) + near_unknown = ( + not self._unclassified.is_empty + and fp.intersection(self._unclassified.buffer(self._ground_error + ew)).area + > _MEASURE_EPS + ) + if on_good and not near_bad and not near_unknown: + c.reason = "The whole footprint stands on an allowed surface." + return c + c.outcome = UNSURE + if not on_good and not near_bad: + c.unsure_cause = "unknown_attribute" + c.reason = "The ground under the footprint was seen but its surface was not recorded." + else: + c.unsure_cause = "margin" + c.reason = "The footprint sits within measurement error of a surface boundary." + return c + + def _s_range(self, piece: Piece, fp: Polygon, radius: float) -> tuple[float, float]: + s0 = piece.local(fp.exterior.coords[0])[0] + return (s0 - radius, s0 + self.W + radius) + + def check_clearance( + self, + check_id: str, + label: str, + rule_key: str, + rule: Value, + piece: Piece, + fp: Polygon, + items: list[tuple[str, Geometry, float, bool | None]], + band: str, + noun: str, + ) -> 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.""" + t = rule.value + c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") + worst_key: tuple[int, float] | None = None + for name, geom, err, counts in items: + if counts is False: + continue + d = fp.distance(geom) + e = err + piece.plus_minus + outcome = at_least(d, e, t) + cause = "margin" + if counts is None and outcome != PASS: + outcome, cause = UNSURE, "unknown_attribute" + key = (_SEVERITY[outcome], -(d - e)) + if worst_key is None or key > worst_key: + worst_key = key + c.outcome, c.measured, c.plus_minus, c.subject = outcome, d, e, name + c.unsure_cause = cause if outcome == UNSURE else None + if c.outcome == FAIL: + c.reason = ( + f"{c.subject} is {ft(c.measured or 0)} (± {ft(c.plus_minus or 0)}) from the " + f"battery; the rule needs more than {ft(t)}." + ) + return c + unobserved = self.unobserved_ground if band == "ground" else self.unobserved_wall + covered = self._covered(fp, unobserved, t) + if c.outcome == UNSURE: + if c.unsure_cause == "unknown_attribute": + c.reason = ( + 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." + ) + else: + c.reason = ( + f"{c.subject} is {ft(c.measured or 0)} (± {ft(c.plus_minus or 0)}) from the " + f"battery against a {ft(t)} rule: too close to call." + ) + if not covered: + c.missing = [(band, *self._s_range(piece, fp, t))] + return c + if not covered: + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.missing = [(band, *self._s_range(piece, fp, t))] + c.reason = ( + f"Not everything within {ft(t)} of the battery was seen, so a {noun} could " + "hide there." + ) + return c + if c.measured is None: + c.reason = f"No {noun} within {ft(t)} of the battery, and that whole area was seen." + else: + c.reason = ( + f"Nearest {noun} is {ft(c.measured)} (± {ft(c.plus_minus or 0)}) away, clear of " + f"the {ft(t)} rule, and the area around the battery was seen." + ) + return c + + def _opening_counts(self, o: SceneObject) -> bool | None: + cfg = self.r.openings + if o.type != "window" or o.well is True: + return True + exempt: bool | None = False + if cfg.exempt_fixed_windows: + exempt = True if o.operable is False else (None if o.operable is None else False) + if cfg.exempt_bottom_above_ft is not None and exempt is not True: + if o.bottom is None: + exempt = None + elif o.bottom > cfg.exempt_bottom_above_ft: + exempt = True + if exempt is True and o.well is 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.""" + rule = self.r.clearances.wall_equipment_ft + t = rule.value + c = Check( + "wall_equipment_above", + "No box or vent above the battery", + PASS, + "", + "clearances.wall_equipment_ft", + rule, + threshold=t, + comparison="at_least", + ) + 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 + outcome = at_least(gap, e, t) + key = (_SEVERITY[outcome], -(gap - e)) + if worst_key is None or key > worst_key: + worst_key = key + c.outcome, c.measured, c.plus_minus, c.subject = outcome, gap, e, o.label + if c.outcome == FAIL: + c.reason = f"{c.subject} is on the wall directly above the battery." + return c + missing = self.scene.missing("wall", s0 - t, s1 + t) + if c.outcome == UNSURE: + c.unsure_cause = "margin" + c.reason = f"{c.subject} ends within measurement error of the battery's edge." + elif missing: + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.reason = "The wall above the battery was not fully seen." + else: + c.reason = "Nothing is mounted on the wall above the battery." + if missing and c.outcome == UNSURE: + c.missing = [("wall", a, b) for a, b in missing] + return c + + def check_meter_space(self, piece: Piece, fp: Polygon, s0: float, s1: float) -> Check: + ws = self.r.meter_working_space + c = Check( + "meter_working_space", + "Clear of the meter's working space", + PASS, + "", + "meter_working_space.width_ft", + ws.width_ft, + threshold=0.0, + comparison="at_least", + subject="meter", + ) + d = fp.distance(self.ws_poly) + if d <= EPS: + d = min(0.0, max(self.ws_span[0] - s1, s0 - self.ws_span[1])) + e = self.scene.meter_plus_minus + piece.plus_minus + 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" + if c.outcome == FAIL: + c.reason = f"The battery would stand in the {box} working space in front of the meter." + elif c.outcome == UNSURE: + c.unsure_cause = "margin" + c.reason = ( + f"The battery is within measurement error of the meter's {box} working space." + ) + else: + c.reason = f"The battery is {ft(d)} clear of the meter's {box} working space." + return c + + def check_measured( + self, + check_id: str, + label: str, + band: str, + entries: list, + rule_key: str, + rule: Value, + s0: float, + s1: float, + subtract: float, + noun: str, + ) -> Check: + """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") + over = [m for m in entries if m.span[0] < s1 - EPS and m.span[1] > s0 + EPS] + missing = self.scene.missing(band, s0, s1) + if over: + m = min(over, key=lambda m: (m.value - m.plus_minus, m.index)) + c.measured, c.plus_minus = m.value - subtract, m.plus_minus + c.subject = f"{'overheads' if band == 'overhead' else 'facing'}[{m.index}]" + c.outcome = at_least(c.measured, c.plus_minus, t) + val = f"{ft(c.measured or 0)} (± {ft(c.plus_minus or 0)})" + if c.outcome == FAIL: + c.reason = f"The {noun} is {val}; the rule needs more than {ft(t)}." + return c + if c.outcome == UNSURE: + c.unsure_cause = "margin" + c.reason = f"The {noun} is {val} against a {ft(t)} rule: too close to call." + 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 c.measured is None: + c.reason = f"Nothing limits the {noun} over the battery's stretch of wall." + else: + c.reason = f"The {noun} is {val}, clear of the {ft(t)} rule." + if missing and c.outcome == UNSURE: + c.missing = [(band, a, b) for a, b in missing] + return c + + def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, Check]: + r = self.r.route + near = s0 if s0 > 0 else (s1 if s1 < 0 else 0.0) + lo, hi = min(0.0, near), max(0.0, near) + crossings: list[dict[str, Any]] = [] + detours: list[dict[str, Any]] = [] + effects: list[str] = [] + unknown: list[str] = [] + for gap in self.scene.gaps: + if min(hi, gap.s1) - max(lo, gap.s0) > EPS: + crossings.append( + { + "subject": "stretch with no wall", + "span_ft": [gap.s0, gap.s1], + "effect": "fail", + } + ) + effects.append("fail") + h = r.height_ft.value + for o in self.route_objects: + if min(hi, o.span[1]) - max(lo, o.span[0]) <= EPS: + continue + effect = r.crossing[o.type] # type: ignore[index] + crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": effect}) + effects.append(effect) + if effect == "detour": + bottom = o.bottom if o.bottom is not None else 0.0 + if o.top is None: + unknown.append(o.label) + continue + if bottom <= h + EPS and o.top >= h - EPS: + options = [2 * (o.top - h)] + if bottom > EPS: + options.append(2 * (h - bottom)) + extra = min(options) + if extra > EPS: + detours.append({"subject": o.label, "extra_ft": 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) + ) + e = self.scene.meter_plus_minus + piece.plus_minus + missing = self.scene.missing("wall", lo, hi) + + path = Check( + "route_path", + "Cable route along the wall", + PASS, + "", + "route.crossing", + None, + rule_source="docs/02 Lane C: the cable can't cross a door, a garage or a " + "stretch with no wall", + rule_placeholder=False, + ) + if "fail" in effects: + path.outcome = FAIL + 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 "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") + path.reason = ( + f"The cable would route past {path.subject}, which the policy sends to a person." + ) + elif unknown: + path.outcome, path.unsure_cause = UNSURE, "unknown_attribute" + path.subject = unknown[0] + path.reason = ( + f"The height of {unknown[0]} was not recorded, so the detour around it is unknown." + ) + elif missing: + path.outcome, path.unsure_cause = UNSURE, "unobserved" + path.missing = [("wall", a, b) for a, b in missing] + path.reason = "Part of the wall the cable would run along was not seen." + else: + path.reason = "The cable runs along continuous, observed wall with nothing blocking it." + + reach = Check( + "route_length", + "Cable run length", + PASS, + "", + "route.max_ft", + r.max_ft, + measured=length, + plus_minus=e, + threshold=r.max_ft.value, + comparison="at_most", + ) + reach.outcome = reach_outcome(length, e, r.confident_reach_ft.value, r.max_ft.value) + run = f"{ft(length)} (± {ft(e)})" + confident = r.confident_reach_ft.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: + reach.unsure_cause = "margin" + reach.reason = ( + f"The cable run is {run}: past the {ft(confident)} confident reach " + f"or within error of the {ft(r.max_ft.value)} maximum." + ) + else: + reach.reason = f"The cable run is {run}, within the {ft(confident)} confident reach." + route = Route( + outcome=worst([path.outcome, reach.outcome]), + length=length, + plus_minus=e, + near_s=near, + polyline=self.scene.polyline(0.0, near), + detours=detours, + crossings=crossings, + ) + return route, path, reach + + # --- candidates ---------------------------------------------------------------------------- + + def evaluate(self, piece: Piece, s0: float) -> Candidate: + r = self.r + s1 = s0 + self.W + fp = self.footprint(piece, s0) + 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_clearance( + "gas_clearance", + "Distance from gas equipment", + "clearances.gas_ft", + c.gas_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True) for o in self.gas], + "ground", + "gas meter or pipe", + ), + self.check_clearance( + "ac_clearance", + "Distance from AC units", + "clearances.ac_ft", + c.ac_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True) for o in self.ac], + "ground", + "AC unit", + ), + self.check_clearance( + "drive_clearance", + "Distance from the driveway", + "clearances.drive_ft", + c.drive_ft, + piece, + fp, + [ + (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True) + for g in self.drives + ], + "ground", + "drivable surface", + ), + self.check_clearance( + "pool_clearance", + "Distance from a pool", + "clearances.pool_ft", + c.pool_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True) for o in self.pool], + "ground", + "pool", + ), + self.check_clearance( + "opening_clearance", + "Distance from doors and windows", + "clearances.opening_ft", + c.opening_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, self._opening_counts(o)) for o in self.openings], + "wall", + "door or window", + ), + self.check_along_wall(piece, s0, s1), + self.check_measured( + "facing_gap", + "Open space in front", + "facing", + self.scene.facing, + "facing.min_ft", + r.facing.min_ft, + s0, + s1, + self.D if r.facing.measured_from == "battery_front" else 0.0, + "gap in front of the battery" + if r.facing.measured_from == "battery_front" + else "gap from the wall to whatever faces it", + ), + self.check_measured( + "headroom", + "Headroom above", + "overhead", + self.scene.overheads, + "headroom.min_ft", + r.headroom.min_ft, + s0, + s1, + 0.0, + "headroom", + ), + ] + route, path, reach = self.route_for(piece, s0, s1) + checks += [path, reach] + return Candidate(piece, s0, s1, fp, checks, route, worst([x.outcome for x in checks])) + + def starts(self, piece: Piece) -> list[float]: + lo, hi = piece.s0, piece.s1 - self.W + if hi < lo - EPS: + return [] + hi = max(hi, lo) + step = self.r.sweep.step_ft.value + points = [lo + k * step for k in range(int((hi - lo) / step + EPS) + 1)] + [hi] + W, D = self.W, self.D + # Along the wall: every place an interval can start or stop mattering. + boundaries = [0.0, *self.ws_span] + for o in self.scene.objects: + boundaries += list(o.span) + for m in self.scene.overheads + self.scene.facing: + boundaries += list(m.span) + for band in self.scene.observed.values(): + for a, b, _ in band: + boundaries += [a, b] + for g in self.scene.gaps: + boundaries += [g.s0, g.s1] + offsets = {0.0} + for o in self.equipment: + offsets |= {o.plus_minus, -o.plus_minus} + for b in boundaries: + for off in offsets: + points += [b + off, b - W - off] + rt = self.r.route + e_route = self.scene.meter_plus_minus + piece.plus_minus + for limit in (rt.confident_reach_ft.value, rt.max_ft.value): + for x in (limit - e_route, limit + e_route, limit): + points += [x, -x - W] + # Plan clearances: the start where the footprint is exactly the rule's distance from + # each vertex of each object (and within error of it). + c = self.r.clearances + rules_by_items = [ + (c.gas_ft.value, [(p, o.plus_minus) for o in self.gas for p in o.points]), + (c.ac_ft.value, [(p, o.plus_minus) for o in self.ac for p in o.points]), + (c.pool_ft.value, [(p, o.plus_minus) for o in self.pool for p in o.points]), + (c.opening_ft.value, [(p, o.plus_minus) for o in self.openings for p in o.points]), + ( + c.drive_ft.value, + [(p, g.plus_minus) for g in self.drives for p in g.polygon.exterior.coords], + ), + ] + for g in self.scene.ground: + for p in g.polygon.exterior.coords: + u, _ = piece.local(p) + for off in ( + 0.0, + g.plus_minus + piece.plus_minus, + -(g.plus_minus + piece.plus_minus), + ): + points += [u + off, u - W + off] + for t, items in rules_by_items: + for p, err in items: + u, v = piece.local(p) + dv = max(0.0, v - D) if v >= 0 else -v + for tt in (t, t + err + piece.plus_minus, t - err - piece.plus_minus): + if tt > dv: + du = math.sqrt(tt * tt - dv * dv) + points += [u + du, u - W - du] + pts = sorted({round(p, 9) for p in points if lo - EPS <= p <= hi + EPS}) + pts = [min(max(p, lo), hi) for p in pts] + mids = [(a + b) / 2 for a, b in itertools.pairwise(pts) if b - a > 1e-6] + return sorted(set(pts) | set(mids)) + + def candidates(self) -> list[Candidate]: + out = [] + for piece in self.scene.walls: + for s0 in self.starts(piece): + out.append(self.evaluate(piece, s0)) + return out + + +def evaluate_start( + scene: Scene, loaded: LoadedRules, s0: float, wall_id: str | None = None +) -> Candidate: + """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] + piece = next((p for p in walls if p.s0 - EPS <= s0 < p.s1 - EPS), walls[-1]) + return solver.evaluate(piece, s0) + + +# --- decision and result ------------------------------------------------------------------------- + + +def _rank_pass(c: Candidate) -> tuple: + return (c.route.length, abs((c.s0 + c.s1) / 2), c.s0) + + +def _rank_unsure(c: Candidate) -> tuple: + return (len(c.unsure()), c.route.length, c.s0) + + +def _spot_json(solver: Solver, c: Candidate) -> dict[str, Any]: + p = c.piece + corners = [p.point(c.s0), p.point(c.s1), p.point(c.s1, solver.D), p.point(c.s0, solver.D)] + center = p.point((c.s0 + c.s1) / 2, solver.D / 2) + mx, mz = solver.scene.meter_xz + return { + "outcome": c.outcome, + "wall_id": p.wall_id, + "segment": p.index, + "span_ft": [_round(c.s0), _round(c.s1)], + "width_ft": _round(solver.W), + "depth_ft": _round(solver.D), + "height_ft": _round(solver.H), + "footprint": [[_round(x), _round(z)] for x, z in corners], + "center": [_round(center[0]), _round(center[1])], + "along": [_round(p.along[0]), _round(p.along[1])], + "outward": [_round(p.outward[0]), _round(p.outward[1])], + "meter_offset_ft": [_round(center[0] - mx), _round(center[1] - mz)], + "route_length_ft": _round(c.route.length), + } + + +def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: + rt = c.route + return { + "outcome": rt.outcome, + "length_ft": _round(rt.length), + "plus_minus_ft": _round(rt.plus_minus), + "height_ft": _round(solver.r.route.height_ft.value), + "polyline": [[_round(x), _round(z)] for x, z in rt.polyline], + "detours": [ + {"subject": d["subject"], "extra_ft": _round(d["extra_ft"])} for d in rt.detours + ], + "crossings": [ + { + "subject": x["subject"], + "span_ft": [_round(v) for v in x["span_ft"]], + "effect": x["effect"], + } + for x in rt.crossings + ], + } + + +_BAND_TEXT = { + "wall": "wall", + "ground": "ground in front of the wall", + "overhead": "space overhead", + "facing": "gap in front of the wall", +} + + +def _missing_json(c: Candidate) -> list[dict[str, Any]]: + by_band: dict[str, list[tuple[float, float, str]]] = {} + for chk in c.checks: + for band, a, b in chk.missing: + by_band.setdefault(band, []).append((a, b, chk.id)) + out = [] + for band, items in by_band.items(): + for a, b in merge_intervals([(a, b) for a, b, _ in items]): + ids = sorted({i for x0, x1, i in items if x0 < b + EPS and x1 > a - EPS}) + out.append( + { + "kind": "band", + "band": band, + "span_ft": [_round(a), _round(b)], + "checks": ids, + "message": f"Show the {_BAND_TEXT[band]} from {where(a)} to {where(b)}.", + } + ) + return out + + +def _sweep_json(cands: list[Candidate]) -> list[dict[str, Any]]: + runs: list[dict[str, Any]] = [] + for c in sorted(cands, key=lambda c: c.s0): + key = (c.piece.wall_id, c.outcome, sorted(c.failing()), sorted(c.unsure())) + if runs and runs[-1]["_key"] == key: + runs[-1]["start_ft"][1] = _round(c.s0) + else: + runs.append( + { + "_key": key, + "wall_id": c.piece.wall_id, + "start_ft": [_round(c.s0), _round(c.s0)], + "outcome": c.outcome, + "failing": key[2], + "unsure": key[3], + } + ) + for run in runs: + del run["_key"] + return runs + + +def solve(scene: Scene, loaded: LoadedRules) -> dict[str, Any]: + """Decide where the battery goes. Pure apart from the elapsed time it reports.""" + started = time.perf_counter() + solver = Solver(scene, loaded) + r = loaded.rules + cands = solver.candidates() + passes = [c for c in cands if c.outcome == PASS] + unsures = [c for c in cands if c.outcome == UNSURE] + fails = [c for c in cands if c.outcome == FAIL] + auto = r.policy.auto_approve and r.policy.id is not None + + e_end = scene.meter_plus_minus + ends: dict[str, dict[str, Any]] = {} + for side, s_end, pt, piece in ( + ("left", scene.s_min, scene.walls[0].a, scene.walls[0]), + ("right", scene.s_max, scene.walls[-1].b, scene.walls[-1]), + ): + beyond = abs(s_end) - (e_end + piece.plus_minus) - r.route.max_ft.value > EPS + ends[side] = { + "kind": scene.end_kinds[side], + "s_ft": _round(s_end), + "point": [_round(pt[0]), _round(pt[1])], + "beyond_reach": beyond, + } + open_ends = [s for s, e in ends.items() if e["kind"] == "unexplored" and not e["beyond_reach"]] + + def past_end_requests() -> list[dict[str, Any]]: + return [ + { + "kind": "past_end", + "side": side, + "span_ft": [ends[side]["s_ft"], ends[side]["s_ft"]], + "message": ( + f"Keep walking past the {side} end of the scan ({where(ends[side]['s_ft'])}): " + "a spot within reach may be there." + ), + } + for side in open_ends + ] + + reasons: list[dict[str, Any]] = [] + missing: list[dict[str, Any]] = [] + spot = best = nearest = None + policy_reason = { + "code": "policy_not_approved", + "message": ( + "The rules in use are not approved for automatic decisions (no policy selected, or " + "placeholder values), so a person must confirm." + ), + } + if passes: + best = spot = min(passes, key=_rank_pass) + reasons.append( + {"code": "all_checks_pass", "message": "A fully observed spot passes every check."} + ) + decision = "pass" if auto else "manual_review" + if not auto: + reasons.append(policy_reason) + summary = ( + f"The battery fits {where((best.s0 + best.s1) / 2)} with a " + f"{format_ft_in(best.route.length)} cable run" + + (": every check passes." if auto else "; a person must confirm under these rules.") + ) + elif unsures: + best = spot = min(unsures, key=_rank_unsure) + decision = "manual_review" + ids = best.unsure() + labels = [c.label.lower() for c in best.checks if c.outcome == UNSURE] + reasons.append( + { + "code": "unsure_checks", + "checks": ids, + "message": "The best spot has checks nobody can settle from this scan: " + + "; ".join(labels) + + ".", + } + ) + missing = _missing_json(best) + if missing: + reasons.append( + { + "code": "unobserved_area", + "checks": sorted({i for m in missing for i in m["checks"]}), + "message": "Part of the area the checks need was not seen.", + } + ) + if open_ends: + reasons.append( + { + "code": "unexplored_end", + "message": "The wall continues past an end of the scan within cable reach.", + } + ) + missing += past_end_requests() + summary = ( + f"A person needs to check the best spot, {where((best.s0 + best.s1) / 2)}: " + + "; ".join(labels) + + "." + ) + else: + nearest = min(fails, key=lambda c: (c.route.length, c.s0)) if fails else None + best = nearest + fail_counts: dict[str, int] = {} + for c in fails: + for i in c.failing(): + fail_counts[i] = fail_counts.get(i, 0) + 1 + top = sorted(fail_counts, key=lambda i: -fail_counts[i]) + if cands: + reasons.append( + { + "code": "all_spots_fail", + "checks": top, + "message": "Every spot on the walls scanned fails at least one check by a " + "clear margin.", + } + ) + else: + reasons.append( + { + "code": "no_wall_segment_fits", + "message": "No straight stretch of scanned wall is as wide as the battery.", + } + ) + can_reject = auto and r.policy.allow_reject and not open_ends + if open_ends: + reasons.append( + { + "code": "unexplored_end", + "message": "The wall continues past an end of the scan within cable reach.", + } + ) + missing = past_end_requests() + if not auto: + reasons.append(policy_reason) + decision = "reject" if can_reject else "manual_review" + if decision == "reject": + summary = ( + "No spot within reach works: every spot fails " + + (", ".join(t.replace("_", " ") for t in top[:3]) or "the checks") + + "." + ) + elif open_ends: + summary = ( + "No spot on the scanned walls works; walk further to look for one within reach." + ) + else: + summary = "No spot on the scanned walls works; a person must confirm before rejecting." + + elapsed_ms = (time.perf_counter() - started) * 1000 + return { + "schema_version": SCHEMA_VERSION, + "decision": decision, + "summary": summary, + "reasons": reasons, + "policy": { + "id": r.policy.id, + "version": r.policy.version, + "auto_approve": auto, + "sources": list(loaded.sources), + "rules_sha256": loaded.sha256, + }, + "spot": _spot_json(solver, spot) if spot else None, + "route": _route_json(solver, spot) if spot else None, + "checks": [c.to_json() for c in best.checks] if best else [], + "nearest_considered": _spot_json(solver, nearest) if nearest else None, + "missing_evidence": missing, + "ends": ends, + "sweep": _sweep_json(cands), + "stats": { + "candidates": len(cands), + "pass": len(passes), + "unsure": len(unsures), + "fail": len(fails), + "elapsed_ms": round(elapsed_ms, 3), + "input_sha256": scene.input_sha256, + }, + } diff --git a/server/tests/helpers.py b/server/tests/helpers.py new file mode 100644 index 000000000..dcef74282 --- /dev/null +++ b/server/tests/helpers.py @@ -0,0 +1,94 @@ +"""Builders for synthetic test scenes and rules. Everything here is synthetic; no real home data. + +`shared_fixture()` is the public golden tests' shared fixture (docs/research/t3-lane-c-review.md on +t3/research): wall w1 is straight with s = x, the meter at 0 and outward +z; the only usable pad is +level ground at s = [6, 9]; both ends observed; facing gap and headroom 9; exact geometry. +""" + +import copy +from typing import Any + +from rules import LoadedRules, deep_merge, public_rules_dict, rules_from_dict +from scene import Scene, parse_scene +from solver import solve + +# Test settings, not Base policy. +GOLDEN_RULES: dict[str, Any] = { + "policy": {"id": "golden-test", "version": "1", "auto_approve": True, "allow_reject": True}, + "errors": { + k: {"value": 0.0, "source": "exact test geometry"} + for k in ("tap_ft", "vlm_ft", "mesh_ft", "tape_ft", "wall_ft", "meter_ft") + }, + "facing": {"min_ft": {"value": 5.0, "source": "test"}, "measured_from": "wall"}, + "route": { + "confident_reach_ft": {"value": 15.0, "source": "test"}, + "max_ft": {"value": 20.0, "source": "test"}, + "corner_allowance_ft": {"value": 0.0, "source": "test"}, + }, +} + + +def golden_rules(**overrides: Any) -> LoadedRules: + return rules_from_dict(deep_merge(deep_merge(public_rules_dict(), GOLDEN_RULES), overrides)) + + +def rect(x0: float, x1: float, z0: float, z1: float) -> list[list[float]]: + return [[x0, z0], [x1, z0], [x1, z1], [x0, z1]] + + +def everything_observed(lo: float = -60, hi: float = 60, out: float = 30) -> dict[str, Any]: + return { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [lo, hi]}, + {"band": "ground", "span_ft": [lo, hi], "out_ft": out}, + {"band": "overhead", "span_ft": [lo, hi]}, + {"band": "facing", "span_ft": [lo, hi]}, + ], + } + + +def pads_ground( + pads: list[tuple[float, float]], lo: float = -40, hi: float = 40, depth: float = 30 +) -> list[dict[str, Any]]: + """Lawn over each pad [x0, x1] on a straight wall along z = 0 (outward +z); deck elsewhere.""" + ground = [] + edges = [lo] + for x0, x1 in sorted(pads): + ground.append({"type": "lawn", "polygon": rect(x0, x1, 0, depth), "plus_minus_ft": 0}) + edges += [x0, x1] + edges.append(hi) + for a, b in zip(edges[::2], edges[1::2], strict=True): + if b > a: + ground.append({"type": "deck", "polygon": rect(a, b, 0, depth), "plus_minus_ft": 0}) + return ground + + +def shared_fixture() -> dict[str, Any]: + return { + "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": [], + "ground": pads_ground([(6, 9)]), + "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": everything_observed(-40, 40), + } + + +def run(raw: dict[str, Any], rules: LoadedRules | None = None) -> dict[str, Any]: + loaded = rules or golden_rules() + return solve(parse_scene(copy.deepcopy(raw), loaded.rules), loaded) + + +def parsed(raw: dict[str, Any], rules: LoadedRules | None = None) -> Scene: + loaded = rules or golden_rules() + return parse_scene(copy.deepcopy(raw), loaded.rules) + + +def check(result: dict[str, Any], check_id: str) -> dict[str, Any]: + return next(c for c in result["checks"] if c["id"] == check_id) From 5c466cb7dcd79441c8685eef6872bd04502a6840 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:16:06 -0500 Subject: [PATCH 04/52] server: encode the 14 public golden tests; route and evidence fixes The golden tests (docs/research/t3-lane-c-review.md) run against a shared synthetic fixture and validate every result against the result schema. Fixes they drove: an object only blocks the cable route when it touches the wall the cable runs along; missing-evidence requests name the exact unseen stretch; nearest_considered prefers the spot closest to passing. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 16 + server/schemas/result.schema.json | 4 +- server/solver.py | 49 ++- server/tests/test_golden_public.py | 514 +++++++++++++++++++++++++++++ 4 files changed, 569 insertions(+), 14 deletions(-) create mode 100644 server/tests/test_golden_public.py diff --git a/server/scene.py b/server/scene.py index ac8e85c38..1db5877d3 100644 --- a/server/scene.py +++ b/server/scene.py @@ -205,6 +205,22 @@ 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 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]))) + return min(max(best.local(p)[0], best.s0), best.s1) + + def s_extent(self, geom: Geometry) -> tuple[float, float] | None: + """The stretch of chain a region lies in front of.""" + if geom.is_empty: + return None + coords: list[Point2] = [] + for part in getattr(geom, "geoms", [geom]): + ring = part.exterior if hasattr(part, "exterior") else part + coords += [(x, z) for x, z in ring.coords] + values = [self.s_of(c) for c in coords] + return (min(values), max(values)) + def wall_line(self, s_lo: float, s_hi: float) -> Geometry: if s_hi - s_lo <= EPS: return Point(self.point_at(s_lo)) diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index db19e6070..ee47623c5 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -49,12 +49,12 @@ "oneOf": [{"type": "null"}, {"$ref": "#/$defs/route"}] }, "checks": { - "description": "Every check at the chosen spot, or, when `spot` is null, at the nearest spot considered (see `nearest_considered`).", + "description": "Every check at the chosen spot, or, when `spot` is null, at `nearest_considered`.", "type": "array", "items": {"$ref": "#/$defs/check"} }, "nearest_considered": { - "description": "When `spot` is null: the spot with the shortest route that was evaluated, and why it fails. Null otherwise.", + "description": "When `spot` is null: the evaluated spot closest to passing (fewest failing checks, then fewest unsure, then shortest route), whose checks explain why it fails. Null otherwise.", "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] }, "missing_evidence": { diff --git a/server/solver.py b/server/solver.py index 6385b7b72..8b2516da4 100644 --- a/server/solver.py +++ b/server/solver.py @@ -234,7 +234,7 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: return c if not self._covered(fp, self.unobserved_ground, 0.0): c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing = [("ground", *self._s_range(piece, fp, 0.0))] + c.missing = self._missing_ground(fp, 0.0) c.reason = "The ground under the footprint was not seen." return c # A patch edge's error matters only where an allowed surface meets a disallowed or @@ -263,9 +263,20 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: c.reason = "The footprint sits within measurement error of a surface boundary." return c - def _s_range(self, piece: Piece, fp: Polygon, radius: float) -> tuple[float, float]: - s0 = piece.local(fp.exterior.coords[0])[0] - return (s0 - radius, s0 + self.W + radius) + def _missing_ground(self, fp: Polygon, radius: float) -> list[tuple[str, float, float]]: + """The stretch of ground within `radius` of the footprint that nobody saw.""" + region = self.unobserved_ground.intersection(fp.buffer(radius) if radius > 0 else fp) + extent = self.scene.s_extent(region) + return [("ground", *extent)] if extent else [] + + def _missing_band( + self, band: str, fp: Polygon, radius: float + ) -> list[tuple[str, float, float]]: + if band == "ground": + return self._missing_ground(fp, radius) + region = self.unobserved_wall.intersection(fp.buffer(radius) if radius > 0 else fp) + extent = self.scene.s_extent(region) + return [("wall", *extent)] if extent else [] def check_clearance( self, @@ -318,11 +329,11 @@ def check_clearance( f"battery against a {ft(t)} rule: too close to call." ) if not covered: - c.missing = [(band, *self._s_range(piece, fp, t))] + c.missing = self._missing_band(band, fp, t) return c if not covered: c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing = [(band, *self._s_range(piece, fp, t))] + c.missing = self._missing_band(band, fp, t) c.reason = ( f"Not everything within {ft(t)} of the battery was seen, so a {noun} could " "hide there." @@ -483,9 +494,14 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C ) effects.append("fail") h = r.height_ft.value + path_line = self.scene.wall_line(lo, hi) if hi - lo > EPS else None for o in self.route_objects: if min(hi, o.span[1]) - max(lo, o.span[0]) <= EPS: continue + # Only something that touches the wall the cable runs along is in its way; a pipe + # or unit standing off the wall is not (the cable passes behind it). + if path_line is None or o.geom.distance(path_line) > o.plus_minus + _MEASURE_EPS: + continue effect = r.crossing[o.type] # type: ignore[index] crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": effect}) effects.append(effect) @@ -968,13 +984,22 @@ def past_end_requests() -> list[dict[str, Any]]: } ) missing += past_end_requests() - summary = ( - f"A person needs to check the best spot, {where((best.s0 + best.s1) / 2)}: " - + "; ".join(labels) - + "." - ) + spot_at = where((best.s0 + best.s1) / 2) + if any(c.unsure_cause == "unobserved" for c in best.checks if c.outcome == UNSURE): + summary = ( + f"More views are needed around the best spot, {spot_at}: " + f"{len(ids)} checks depend on areas the scan did not see." + ) + else: + summary = ( + f"A person needs to check the best spot, {spot_at}: " + "; ".join(labels) + "." + ) else: - nearest = min(fails, key=lambda c: (c.route.length, c.s0)) if fails else None + nearest = ( + min(fails, key=lambda c: (len(c.failing()), len(c.unsure()), c.route.length, c.s0)) + if fails + else None + ) best = nearest fail_counts: dict[str, int] = {} for c in fails: diff --git a/server/tests/test_golden_public.py b/server/tests/test_golden_public.py new file mode 100644 index 000000000..5112dbdb5 --- /dev/null +++ b/server/tests/test_golden_public.py @@ -0,0 +1,514 @@ +"""The 14 public golden tests from docs/research/t3-lane-c-review.md (branch t3/research). + +Thresholds are the test settings in tests/helpers.py (GOLDEN_RULES), not Base policy. Every scene +starts from the shared fixture: wall w1 along z = 0 with s = x, the meter at 0, outward +z, a lawn +pad at s = [6, 9] and deck everywhere else, both ends observed, facing gap and headroom 9, exact +geometry. Where the review writes a scene whose walls run right to left (test 04), the walls are +listed left to right as scene.schema.json requires; the expected plan positions are unchanged. +""" + +import copy +import json +import math +from pathlib import Path + +import pytest +from helpers import ( + check, + everything_observed, + golden_rules, + pads_ground, + parsed, + rect, + run, + shared_fixture, +) +from jsonschema import Draft202012Validator + +from solver import Solver, evaluate_start + +W = 31 / 12 # battery width (31 in) +D = 11 / 6 # battery depth (22 in) +RESULT = Draft202012Validator( + json.loads((Path(__file__).parents[1] / "schemas" / "result.schema.json").read_text()) +) + + +def solve_valid(raw, rules=None): + result = run(raw, rules) + RESULT.validate(result) + return result + + +def flat(values): + return [v for x in values for v in (flat(x) if isinstance(x, list) else [x])] + + +def approx(values, expected): + assert flat(values) == pytest.approx(flat(expected), abs=1e-6) + + +def at_start(raw, s0, check_id, rules=None): + candidate = evaluate_start(parsed(raw, rules), rules or golden_rules(), s0) + return next(c for c in candidate.checks if c.id == check_id) + + +def pad_runs_fail_only(result, check_id, pad=(6, 9)): + """Every sweep run of starts on the pad fails exactly this one check.""" + runs = [ + r + for r in result["sweep"] + if r["start_ft"][0] >= pad[0] - 1e-5 and r["start_ft"][1] <= pad[1] - W + 1e-5 + ] + return bool(runs) and all(r["failing"] == [check_id] for r in runs) + + +def with_gas(raw, geometry, span=(7, 7.5), plus_minus=0.0): + raw["objects"].append( + { + "type": "gas_meter", + "wall_id": raw["walls"][0]["id"], + "span_ft": list(span), + "source": "tap", + "plus_minus_ft": plus_minus, + "footprint": geometry, + } + ) + return raw + + +def test_01_fully_observed_spot_passes_without_unrelated_views() -> None: + raw = shared_fixture() + raw["objects"].append( + { + "type": "garage_door", + "wall_id": "w1", + "span_ft": [-12, -8], + "bottom_ft": 0, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0, + } + ) + # The wall past the garage, on the far side of the meter, was never seen. + raw["coverage"] = everything_observed(-8, 40) + raw["coverage"]["ends"]["left"] = {"kind": "unexplored"} + result = solve_valid(raw) + assert result["decision"] == "pass" + approx(result["spot"]["span_ft"], [6, 103 / 12]) + approx(result["spot"]["footprint"], [[6, 0], [103 / 12, 0], [103 / 12, D], [6, D]]) + approx([result["route"]["length_ft"]], [6]) + assert result["missing_evidence"] == [] + + +def test_02_no_selected_policy_means_no_automatic_approval() -> None: + rules = golden_rules(policy={"id": None, "auto_approve": False}) + result = solve_valid(shared_fixture(), rules) + assert result["decision"] == "manual_review" + assert "policy_not_approved" in [r["code"] for r in result["reasons"]] + assert result["spot"]["outcome"] == "pass" + + +def test_03_negative_s_uses_the_near_edge() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(-9, -6), (7, 10)]) + result = solve_valid(raw) + assert result["decision"] == "pass" + approx(result["spot"]["span_ft"], [-103 / 12, -6]) + approx([result["route"]["length_ft"]], [6]) + + +def corner_scene(): + # Review's test 04 with the walls listed left to right seen from outside: w2 comes down x = 4 + # from z = 12 to the corner, then w1 runs along z = 0 to x = -12 (outward -z). + return { + "meter": {"pos": [0, 5, 0], "wall_id": "w1", "plus_minus_ft": 0}, + "walls": [ + {"id": "w2", "baseline": [[4, 12], [4, 0]], "plus_minus_ft": 0}, + {"id": "w1", "baseline": [[4, 0], [-12, 0]], "plus_minus_ft": 0}, + ], + "objects": [], + "ground": [ + {"type": "lawn", "polygon": rect(4, 30, 2, 5), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(4, 30, 5, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(4, 30, -30, 2), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, 4, -30, 0), "plus_minus_ft": 0}, + ], + "overheads": [], + "facing": [], + "coverage": everything_observed(-40, 40), + } + + +def test_04_corner_allows_adjacent_wall_not_a_bent_battery() -> None: + raw = corner_scene() + result = solve_valid(raw) + assert result["decision"] == "pass" + spot = result["spot"] + assert spot["wall_id"] == "w2" + xs = [p[0] for p in spot["footprint"]] + zs = [p[1] for p in spot["footprint"]] + approx([min(xs), max(xs)], [4, 35 / 6]) + approx([min(zs), max(zs)], [2, 55 / 12]) + approx([result["route"]["length_ft"]], [6]) + + # The review's candidate s = [3, 67/12] runs 1 ft along w1 and 19/12 ft round the corner. + # Listed left to right that is s = [-67/12, -3]: it must fail backing, not bend. + corner = evaluate_start(parsed(raw), golden_rules(), -67 / 12) + backing = next(c for c in corner.checks if c.id == "wall_backing") + assert backing.outcome == "fail" + assert corner.outcome == "fail" + + +def test_05_inside_corner_gas_distance_is_euclidean() -> None: + raw = { + "meter": {"pos": [-6, 5, 0], "wall_id": "w1", "plus_minus_ft": 0}, + "walls": [ + {"id": "w1", "baseline": [[-6, 0], [4, 0]], "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[4, 0], [4, 20]], "plus_minus_ft": 0}, + ], + "objects": [], + "ground": [ + {"type": "lawn", "polygon": rect(0, 3, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, 0, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(3, 4, 0, 30), "plus_minus_ft": 0}, + ], + "overheads": [], + "facing": [], + "coverage": everything_observed(-40, 60), + } + with_gas(raw, [[4, 4]], span=(10, 10)) + result = solve_valid(raw) + assert result["decision"] == "reject" + assert result["spot"] is None + assert pad_runs_fail_only(result, "gas_clearance", pad=(6, 9)) + all_fail = next(r for r in result["reasons"] if r["code"] == "all_spots_fail") + assert "gas_clearance" in all_fail["checks"] + gas = at_start(raw, 6, "gas_clearance") + assert gas.outcome == "fail" + # Straight-line distance from the footprint x = [0, 31/12], z = [0, 11/6] to the gas at + # [4, 4], not the 65/12 ft measured along the unrolled wall. + assert gas.measured == pytest.approx(math.hypot(17 / 12, 13 / 6)) + assert gas.measured < 3 < 65 / 12 + + +def test_06_every_part_of_an_object_counts() -> None: + # Gas meter body 10 ft out, its regulator pipe reaching x = 7, z = 23/6. + raw = with_gas(shared_fixture(), [[7.5, 10], [7, 23 / 6]]) + result = solve_valid(raw) + assert result["decision"] == "reject" + gas = at_start(raw, 6, "gas_clearance") + assert gas.outcome == "fail" + assert gas.measured == pytest.approx(2) + assert pad_runs_fail_only(result, "gas_clearance") + + +@pytest.mark.parametrize( + ("underside", "plus_minus", "expected"), + [ + (6.5 - 1, 0.5, "reject"), + (6.5 + 0.7, 0.5, "pass"), + (6.5 + 0.3, 0.5, "manual_review"), + ], +) +def test_07_headroom_covers_the_whole_footprint(underside, plus_minus, expected) -> None: + raw = shared_fixture() + # The ray 1 ft out reads 9 (the base overhead entry); a landing spans the whole pad. + raw["overheads"].append( + {"wall_id": "w1", "span_ft": [6, 9], "clearance_ft": underside, "plus_minus_ft": plus_minus} + ) + assert solve_valid(raw)["decision"] == expected + + +def test_07_unobserved_headroom_is_manual_review() -> None: + raw = shared_fixture() + raw["coverage"]["observed"] = [ + o for o in raw["coverage"]["observed"] if o["band"] != "overhead" + ] + [ + {"band": "overhead", "span_ft": [-40, 5]}, + {"band": "overhead", "span_ft": [10, 40]}, + ] + result = solve_valid(raw) + assert result["decision"] == "manual_review" + head = check(result, "headroom") + assert head["outcome"] == "unsure" + assert head["unsure_cause"] == "unobserved" + assert any(m.get("band") == "overhead" for m in result["missing_evidence"]) + + +def test_08_route_across_a_garage_decides_the_result() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(10, 13)]) + raw["objects"].append( + { + "type": "garage_door", + "wall_id": "w1", + "span_ft": [3, 7], + "bottom_ft": 0, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0, + } + ) + result = solve_valid(raw) + assert result["decision"] == "reject" + assert pad_runs_fail_only(result, "route_path", pad=(10, 13)) + + +def test_08_no_route_interpolates_across_missing_wall() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(10, 13)]) + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [3, 0]], "plus_minus_ft": 0}, + {"id": "w1b", "baseline": [[4, 0], [40, 0]], "plus_minus_ft": 0}, + ] + raw["facing"][0]["span_ft"] = [-40, 3] + raw["overheads"][0]["span_ft"] = [-40, 3] + raw["facing"].append({"wall_id": "w1b", "span_ft": [4, 40], "depth_ft": 9, "plus_minus_ft": 0}) + raw["overheads"].append( + {"wall_id": "w1b", "span_ft": [4, 40], "clearance_ft": 9, "plus_minus_ft": 0} + ) + scene = parsed(raw) + assert [(g.s0, g.s1) for g in scene.gaps] == [(3, 4)] + result = solve_valid(raw) + assert result["decision"] == "reject" + assert pad_runs_fail_only(result, "route_path", pad=(10, 13)) + + +def reach_scene(start, meter_error=0.0): + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = meter_error + raw["ground"] = pads_ground([(start, start + 3)]) + return raw + + +@pytest.mark.parametrize( + ("start", "meter_error", "expected"), + [ + (15 - 1, 0.0, "pass"), # confident reach - 1 + (15, 0.0, "manual_review"), # on the confident-reach line + (15 + 1, 0.0, "manual_review"), + (20, 0.0, "manual_review"), # on the maximum + (20 + 1, 0.0, "reject"), + (20 + 0.2, 0.3, "manual_review"), # not clearly over the maximum + (20 + 0.4, 0.3, "reject"), + ], +) +def test_09_reach_uses_routed_length(start, meter_error, expected) -> None: + result = solve_valid(reach_scene(start, meter_error)) + assert result["decision"] == expected + + +def test_09_vertical_run_counts_toward_reach() -> None: + # Pad at confident reach - 4; a window reaching the ground forces the cable 2.5 ft up and back. + raw = reach_scene(15 - 4) + raw["objects"].append( + { + "type": "window", + "wall_id": "w1", + "span_ft": [2, 5], + "bottom_ft": 0, + "top_ft": 3.5, + "source": "tap", + "plus_minus_ft": 0, + } + ) + result = solve_valid(raw) + assert result["decision"] == "manual_review" + route = result["route"] + approx([route["length_ft"]], [15 + 1]) + approx([d["extra_ft"] for d in route["detours"]], [5]) + + +def gas_gap_scene(gap, gas_error, wall_error=0.0): + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = wall_error + # A gas pipe parallel to the wall: every start on the pad has the same gap. + return with_gas(raw, [[-40, D + gap], [40, D + gap]], span=(-40, 40), plus_minus=gas_error) + + +@pytest.mark.parametrize("e", [0.3, 0.5, 1.5]) +@pytest.mark.parametrize( + ("offset", "expected"), + [ + (lambda e: e + 0.01, "pass"), + (lambda e: e, "manual_review"), + (lambda e: -e, "manual_review"), + (lambda e: -e - 0.01, "reject"), + ], + ids=["T+e+0.01", "T+e", "T-e", "T-e-0.01"], +) +def test_10_margins_cover_equality(e, offset, expected) -> None: + result = solve_valid(gas_gap_scene(3 + offset(e), e)) + assert result["decision"] == expected + + +def test_10_compound_error_adds() -> None: + # Gap 3.5 derived from two positions each ±0.3 (the gas pipe and the wall): error ±0.6. + result = solve_valid(gas_gap_scene(3.5, 0.3, wall_error=0.3)) + assert result["decision"] == "manual_review" + gas = check(result, "gas_clearance") + assert gas["outcome"] == "unsure" + assert gas["plus_minus_ft"] == pytest.approx(0.6) + assert gas["unsure_cause"] == "margin" + + +def l_scene(variant): + """w1 along z = 0 to a corner at s = 12, then w2 down x = 12 (outward +x) to s = 32 (s = 12 - z + on w2). A gas pipe 1 ft in front of the battery's front face fails every spot it runs along.""" + raw = shared_fixture() + raw["facing"], raw["overheads"] = [], [] + walls = [{"id": "w1", "baseline": [[-40, 0], [12, 0]], "plus_minus_ft": 0}] + ground = [ + {"type": "lawn", "polygon": rect(-9, -6, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, -9, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-6, 40, 0, 30), "plus_minus_ft": 0}, + ] + front = D + 1 + # (d) adds a valid pad before the gate: the pipe along w1 stops 4 ft short of it. + with_gas(raw, [[-2 if variant == "d" else -40, front], [12 + front, front]], span=(-40, 12)) + if variant == "a": + raw["coverage"] = everything_observed(-40, 12) + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + elif variant == "b": + walls.append({"id": "w2", "baseline": [[12, 0], [12, -20]], "plus_minus_ft": 0}) + ground.append({"type": "deck", "polygon": rect(12, 40, -20, 0), "plus_minus_ft": 0}) + with_gas(raw, [[12 + front, front], [12 + front, -20]], span=(12, 32)) + raw["coverage"] = everything_observed(-40, 40) + else: + # A closed gate hides the ground along w2 from s = 14 to 20, within cable reach. Past the + # gate the wall is seen again to its real end. + walls.append({"id": "w2", "baseline": [[12, 0], [12, -20]], "plus_minus_ft": 0}) + ground.append({"type": "deck", "polygon": rect(12, 40, -2, 0), "plus_minus_ft": 0}) + ground.append({"type": "deck", "polygon": rect(12, 40, -40, -8), "plus_minus_ft": 0}) + with_gas(raw, [[12 + front, front], [12 + front, -1]], span=(12, 13)) + raw["coverage"] = everything_observed(-40, 40) + raw["coverage"]["observed"] = [ + o for o in raw["coverage"]["observed"] if o["band"] != "ground" + ] + [ + {"band": "ground", "span_ft": [-40, 14], "out_ft": 30}, + {"band": "ground", "span_ft": [20, 40], "out_ft": 30}, + ] + raw["walls"], raw["ground"] = walls, ground + return raw + + +def test_11a_unseen_wall_past_a_tapped_corner_is_manual_review() -> None: + result = solve_valid(l_scene("a")) + assert result["decision"] == "manual_review" + assert [m.get("side") for m in result["missing_evidence"] if m["kind"] == "past_end"] == [ + "right" + ] + + +def test_11b_reject_records_both_real_wall_ends() -> None: + result = solve_valid(l_scene("b")) + assert result["decision"] == "reject" + assert result["spot"] is None + assert result["missing_evidence"] == [] + assert result["ends"]["left"]["kind"] == "limit" + assert result["ends"]["right"]["kind"] == "limit" + approx([result["ends"]["left"]["s_ft"], result["ends"]["right"]["s_ft"]], [-40, 32]) + + +def test_11c_ground_hidden_behind_a_gate_is_manual_review() -> None: + result = solve_valid(l_scene("c")) + assert result["decision"] == "manual_review" + grounds = [m for m in result["missing_evidence"] if m.get("band") == "ground"] + assert grounds + assert all(m["span_ft"][1] > 14 and m["span_ft"][0] < 20 for m in grounds) + + +def test_11d_valid_pad_before_the_gate_passes_without_photo_request() -> None: + result = solve_valid(l_scene("d")) + assert result["decision"] == "pass" + approx(result["spot"]["span_ft"], [-103 / 12, -6]) + assert result["missing_evidence"] == [] + + +def two_ft_rules(): + return golden_rules(battery={"width_ft": {"value": 2.0, "source": "test battery"}}) + + +def test_12_sub_grid_interval_is_found() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(4, 12.1)]) + for span in ([3, 4], [12.1, 13.1]): + raw["objects"].append( + { + "type": "window", + "wall_id": "w1", + "span_ft": span, + "bottom_ft": 3, + "top_ft": 6, + "attrs": {"operable": True}, + "source": "tap", + "plus_minus_ft": 0, + } + ) + result = solve_valid(raw, two_ft_rules()) + assert result["decision"] == "pass" + s0, s1 = result["spot"]["span_ft"] + assert 7 < s0 < 7.1 + assert s1 == pytest.approx(s0 + 2) + + +def test_12_last_start_is_enumerated() -> None: + raw = shared_fixture() + raw["walls"] = [ + {"id": "w0", "baseline": [[-3, 0], [3, 0]], "plus_minus_ft": 0}, + {"id": "w1", "baseline": [[6, 0], [8, 0]], "plus_minus_ft": 0}, + ] + raw["meter"]["wall_id"] = "w0" + raw["facing"], raw["overheads"] = [], [] + loaded = two_ft_rules() + scene = parsed(raw, loaded) + piece = next(p for p in scene.walls if p.wall_id == "w1") + assert Solver(scene, loaded).starts(piece) == [6.0] + + +def f_and_s_scene(with_s=True): + raw = shared_fixture() + raw["ground"] = pads_ground([(6, 9), (10, 13)] if with_s else [(6, 9)]) + # Gas 3.2 ± 0.3 from every footprint on pad F; well clear of pad S. + return with_gas(raw, [[7.5, D + 3.2]], span=(7.5, 7.5), plus_minus=0.3) + + +def test_13_unsure_spot_never_outranks_a_pass() -> None: + result = solve_valid(f_and_s_scene()) + assert result["decision"] == "pass" + assert result["spot"]["span_ft"][0] == pytest.approx(10) + approx([result["route"]["length_ft"]], [10]) + + +def test_13_margin_only_review_asks_for_no_photos() -> None: + result = solve_valid(f_and_s_scene(with_s=False)) + assert result["decision"] == "manual_review" + assert check(result, "gas_clearance")["unsure_cause"] == "margin" + assert result["missing_evidence"] == [] + + +def test_14_result_records_its_measurements() -> None: + result = solve_valid(shared_fixture()) + assert result["decision"] == "pass" + assert result["policy"]["id"] == "golden-test" + assert result["policy"]["version"] == "1" + assert len(result["policy"]["rules_sha256"]) == 64 + spot = result["spot"] + approx(spot["meter_offset_ft"], [6 + W / 2, D / 2]) + approx([result["route"]["length_ft"], result["route"]["plus_minus_ft"]], [6, 0]) + facing = check(result, "facing_gap") + head = check(result, "headroom") + approx([facing["measured_ft"], head["measured_ft"]], [9, 9]) + for c in result["checks"]: + assert c["outcome"] == "pass" + assert c["rule"]["source"] + assert result["missing_evidence"] == [] + + +def test_result_is_stable_for_the_same_input() -> None: + a = run(shared_fixture()) + b = run(copy.deepcopy(shared_fixture())) + a["stats"].pop("elapsed_ms") + b["stats"].pop("elapsed_ms") + assert a == b From fcd457411a68171509d53b87b11c115f55a2a765 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:20:25 -0500 Subject: [PATCH 05/52] server: SVG site plan of a placement result (docs/01 D2) Drawn in the meter wall's frame with the house above and the yard below: ground patches, unseen ground hatched, the meter's working space in blue, the cable in purple, the battery and its clearance in red, and dimension lines for the clearances closest to their thresholds. One style block with a dark variant. python siteplan.py scene.json out.svg renders a scene from the command line. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/siteplan.py | 423 ++++++++++++++++++++++++++++++++++ server/tests/test_siteplan.py | 53 +++++ 2 files changed, 476 insertions(+) create mode 100644 server/siteplan.py create mode 100644 server/tests/test_siteplan.py diff --git a/server/siteplan.py b/server/siteplan.py new file mode 100644 index 000000000..843496803 --- /dev/null +++ b/server/siteplan.py @@ -0,0 +1,423 @@ +"""Draw a placement result as a top-down 2D site plan (docs/01 D2) in SVG. + +The plan is drawn in the meter wall's frame: the wall runs left to right across the page with the +house above it and the yard below, whatever the scene frame's axes are. Colours follow docs/01 D2 +(blue meter area, purple cable, red battery spot and its clearance); gas is utility-locate yellow. +Ground nobody saw is hatched, as in the app's coverage fog. The deciding clearances are drawn as +dimension lines with their measured values, so the drawing shows why the spot works. + +No dependency beyond shapely: the SVG is built as text. Colours live in one {DEFS}") + out.append(f'') + + # Ground, then what nobody saw. + for g in scene.ground: + d = frame.path(g.polygon) + if d: + out.append(f'') + fog = frame.path(scene.unobserved_ground()) + if fog: + out.append(f'') + for o in scene.objects: + if o.type == "pool" and isinstance(o.geom, Polygon): + out.append(f'') + + # The meter's working space (blue) and the house side of each wall (hatched poché). + ws = r.meter_working_space + ws_poly = scene.band_polygon(-ws.width_ft.value / 2, ws.width_ft.value / 2, ws.depth_ft.value) + out.append(f'') + for p in walls: + out.append(f'') + + # Where on the wall a battery could stand, on the house side of the wall line. + width = r.battery.width_ft.value + for run in result.get("sweep", []): + if run["outcome"] == "fail": + continue + a, b = run["start_ft"][0], run["start_ft"][1] + width + line = scene.wall_line(a, b) + piece = scene.piece_at((a + b) / 2) + shifted = _offset(line, piece, -0.45) + out.append(f'') + + for p in walls: + out.append(f'') + for g in scene.gaps: + out.append(f'') + + # Wall objects: openings break the wall line; equipment sits on or off it. + gas_ring = r.clearances.gas_ft.value + for o in scene.objects: + name = OBJECT_NAMES[o.type] + if o.type in ("window", "door", "garage_door"): + line = scene.wall_line(*o.span) + out.append(f'') + for off in (-0.25, 0.25): + piece = scene.piece_at(sum(o.span) / 2) + out.append( + f'' + ) + _label(out, frame, scene.point_at(sum(o.span) / 2, -1.6), name, "small") + elif o.type == "gas_meter": + shape = o.geom.buffer(0.35) if not isinstance(o.geom, Polygon) else o.geom + out.append(f'') + out.append(f'') + _label(out, frame, _label_point(scene, o.geom, 1.2), name, "label") + elif o.type != "pool": + shape = o.geom.buffer(0.3) if not isinstance(o.geom, Polygon) else o.geom + out.append(f'') + at = _label_point(scene, o.geom, 1.2) + if at[0] is None: + at = (shape.centroid.x, shape.centroid.y) + _label(out, frame, at, name, "small") + + # Cable, battery and its clearance. + route = result.get("route") + if route and len(route["polyline"]) >= 2: + line = LineString(route["polyline"]) + out.append(f'') + mid = line.interpolate(0.5, normalized=True) + piece = scene.piece_at(scene.s_of((mid.x, mid.y))) + _label( + out, + frame, + piece.point(scene.s_of((mid.x, mid.y)), -2.4), + f"Cable {short(route['length_ft'])}", + "cable-text", + ) + if spot: + fp = Polygon(spot["footprint"]) + ring = min(r.clearances.gas_ft.value, r.clearances.ac_ft.value) + out.append(f'') + cls = "battery" if spot["outcome"] == "pass" else "battery unsure" + if result.get("spot") is None: + cls = "battery unsure" + out.append(f'') + _dimensions(out, frame, scene, fp, result) + + # Meter on top. + mx, my = frame.px(scene.meter_piece.point(0.0)) + out.append(f'') + _label(out, frame, scene.point_at(0.0, -1.6), "Meter", "label") + + _legend(out, frame, k, h, result) + out.append("") + return "\n".join(out) + + +def _union_bounds(parts: list[Geometry]) -> Geometry: + from shapely import unary_union + + return unary_union(parts).envelope + + +def _offset(line: Geometry, piece: Any, distance: float) -> Geometry: + from shapely.affinity import translate + + return translate(line, piece.outward[0] * distance, piece.outward[1] * distance) + + +def _label_point(scene: Scene, geom: Geometry, out: float) -> tuple[float, float]: + """Things mounted on the wall are named on the house side, in the row with the openings, so + their names never collide with the dimension lines in the yard. Things out in the yard are + named just beyond themselves.""" + c = geom.centroid + s = scene.s_of((c.x, c.y)) + piece = scene.piece_at(s) + if geom.distance(scene.wall_line(s - 0.01, s + 0.01)) < 0.5: + return piece.point(s, -1.6) + _, v = piece.local((c.x, c.y)) + return piece.point(s, max(v, 0) + out) + + +def _label(out: list[str], frame: _Frame, at: tuple[float, float], text: str, cls: str) -> None: + x, y = frame.px(at) + out.append( + f'{_esc(text)}' + ) + + +def _dimensions( + out: list[str], frame: _Frame, scene: Scene, fp: Polygon, result: dict[str, Any] +) -> None: + """Dimension lines from the battery to the objects that decide its clearances.""" + subjects: dict[str, Geometry] = {o.label: o.geom for o in scene.objects} + subjects |= {f"ground[{g.index}] {g.type}": g.polygon for g in scene.ground} + rows = [] + for c in result.get("checks", []): + geom = subjects.get(c.get("subject") or "") + if geom is None or c["measured_ft"] is None or c["threshold_ft"] is None: + continue + margin = c["measured_ft"] - c["threshold_ft"] + rows.append((margin, c, geom)) + rows = [row for row in rows if row[0] <= DIMENSION_MARGIN_FT] + for _, c, geom in sorted(rows, key=lambda row: row[0])[:MAX_DIMENSIONS]: + a, b = nearest_points(fp, geom) + if a.distance(b) < 0.25: + continue + (x1, y1), (x2, y2) = frame.px((a.x, a.y)), frame.px((b.x, b.y)) + ang = math.atan2(y2 - y1, x2 - x1) + tx, ty = -math.sin(ang) * 5, math.cos(ang) * 5 + out.append(f'') + for x, y in ((x1, y1), (x2, y2)): + out.append( + f'' + ) + label = f"{short(c['measured_ft'])} (min {short(c['threshold_ft'])})" + # Label beside the line's midpoint. + lx, ly = (x1 + x2) / 2 + tx * 3.2, (y1 + y2) / 2 + ty * 3.2 + 4 + out.append( + f'{_esc(label)}' + ) + + +def _legend(out: list[str], frame: _Frame, k: float, h: float, result: dict[str, Any]) -> None: + y = h - LEGEND_PX + 26 + x = PAD_PX + # Scale bar: 5 ft. + bar = 5 * k + out.append( + f'' + ) + for t in (0, bar / 2, bar): + out.append( + f'' + ) + out.append(f'5 ft') + items = [ + ("battery", "Battery"), + ("cable-key", "Cable"), + ("meter-area", "Meter working space"), + ("gas", "Gas"), + ("fog", "Not seen"), + ] + cx = x + bar + 64 + for cls, text in items: + if cls == "cable-key": + out.append(f'') + else: + out.append(f'') + out.append(f'{_esc(text)}') + cx += 32 + 7.2 * len(text) + decision = {"pass": "Fits", "manual_review": "Needs review", "reject": "No spot"}.get( + result.get("decision", ""), "" + ) + out.append( + f'{_esc(decision)}' + ) + + +def main(argv: list[str]) -> int: + """python siteplan.py scene.json out.svg: solve a scene and write its site plan.""" + if len(argv) != 3: + print(main.__doc__, file=sys.stderr) + return 2 + loaded = load_rules() + raw = json.loads(Path(argv[1]).read_text()) + scene = parse_scene(raw, loaded.rules) + Path(argv[2]).write_text(render(scene, solve(scene, loaded), loaded)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/server/tests/test_siteplan.py b/server/tests/test_siteplan.py new file mode 100644 index 000000000..6e3a4390a --- /dev/null +++ b/server/tests/test_siteplan.py @@ -0,0 +1,53 @@ +import json +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest +from helpers import golden_rules, parsed, shared_fixture + +from siteplan import render, short +from solver import solve + +SVG = "{http://www.w3.org/2000/svg}" +EXAMPLE = json.loads((Path(__file__).parent / "fixtures" / "example-scene.json").read_text()) + + +def draw(raw): + loaded = golden_rules() + scene = parsed(raw, loaded) + result = solve(scene, loaded) + return result, render(scene, result, loaded) + + +@pytest.mark.parametrize("raw", [shared_fixture(), EXAMPLE], ids=["shared", "example"]) +def test_site_plan_is_well_formed_svg_with_the_key_elements(raw) -> None: + result, svg = draw(raw) + root = ET.fromstring(svg) + assert root.tag == f"{SVG}svg" + assert root.find(f"{SVG}title") is not None + assert result["summary"] in svg + classes = {el.get("class") for el in root.iter()} + assert {"wall", "meter", "cable"} <= classes + assert "battery" in classes or "battery unsure" in classes + assert not re.search(r"(? None: + assert draw(shared_fixture())[1] == draw(shared_fixture())[1] + + +def test_reject_draws_the_nearest_spot_as_unsure() -> None: + raw = shared_fixture() + for g in raw["ground"]: + g["type"] = "deck" + result, svg = draw(raw) + assert result["spot"] is None + assert 'class="battery unsure"' in svg + + +@pytest.mark.parametrize( + ("feet", "text"), [(3.0, "3 ft"), (7 / 12, "7 in"), (9 + 8 / 12, "9 ft 8 in")] +) +def test_short_lengths(feet, text) -> None: + assert short(feet) == text From 66d7ec8991dca650c833c54fa44e817ed483c8c6 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:23:27 -0500 Subject: [PATCH 06/52] server: defer unseen-stretch geometry to the reported spot; README Computing the exact unseen stretch for every candidate took most of a solve; only the chosen spot reports it. The example scene now solves in about 0.14 s. README documents the API, the contracts' conventions, how a decision is made, and where private rules load from. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++--- server/solver.py | 15 ++++++--- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/server/README.md b/server/README.md index b614c3225..6c24927ba 100644 --- a/server/README.md +++ b/server/README.md @@ -1,12 +1,12 @@ # server -The FastAPI server from `docs/02-implementation-plan.md` (lanes B and C). It will take a capture from the phone, build the wall geometry, run the recognition checks and pick a battery placement. Modules sit flat in this directory (`ingest.py`, `geometry.py`, `solver.py`, `rules.yaml` and so on) as the lanes add them. +The placement server (docs/02 lane C). It takes the phone's scan of the wall around the electric meter, decides whether a Base Power battery fits and where, and explains why. The app, the website and a future SDK all call the same HTTP API. -For now it holds `units.py`. ARKit reports meters, while `scene.json` and the rules use feet and inches; convert with `units.py` and format lengths for people with `format_ft_in`. +The decision is plain code, not AI. Every threshold lives in `rules.yaml` with its citation. ## Requirements -[uv](https://docs.astral.sh/uv/) 0.11. It installs Python 3.12 (`.python-version`) and the locked dev tools. +[uv](https://docs.astral.sh/uv/) 0.11. It installs Python 3.12 (`.python-version`) and the locked dependencies. ## Commands @@ -15,10 +15,87 @@ Run from `server/`: | Command | What it does | | --- | --- | | `uv sync --locked` | Create `.venv` from `uv.lock` | +| `uv run uvicorn api:app --host 0.0.0.0 --port 8000` | Serve the API; `--reload` while developing | +| `uv run python siteplan.py scene.json plan.svg` | Solve one scene and write its site plan | | `uv run ruff check .` | Lint | | `uv run ruff format .` | Format; CI runs `ruff format --check .` | | `uv run pytest -q` | Tests in `tests/` | -`make server` from the repository root runs what CI (`.github/workflows/server.yml`, job "Server checks") runs. Add dependencies with `uv add ` and commit `uv.lock`. +`make server` from the repository root runs what CI (`.github/workflows/server.yml`, job "Server checks") runs. + +## API + +| Endpoint | Body | Returns | +| --- | --- | --- | +| `GET /health` | | `{"status": "ok", "schema_version", "policy"}`: which rules are loaded | +| `POST /v1/placements` | a scene | The result JSON (`schemas/result.schema.json`) | +| `POST /v1/placements/site-plan.svg` | a scene | The site plan as `image/svg+xml` | + +A scene is either bare `scene.json` (`Content-Type: application/json`) or a zip bundle (`application/zip`) holding `scene.json` plus the keyframe and close-up JPEGs it names. A browser form can also send either one as a multipart file field named `bundle`. The server reads a bundle in memory and refuses it if any image named in `scene.json` is missing. + +```sh +curl -s localhost:8000/v1/placements -H 'Content-Type: application/json' \ + --data-binary @tests/fixtures/example-scene.json | jq .decision +``` + +Every refusal is JSON with the field at fault: + +```json +{"error": {"code": "invalid_scene", "message": "Additional properties are not allowed ('plusminus_ft' was unexpected)", "path": "/objects/0"}} +``` + +| Status | When | +| --- | --- | +| 400 | The body or zip can't be read, or a zip entry has an absolute or `..` path | +| 413 | The body or unzipped bundle is over the limit (`HOUSESCAN_MAX_UPLOAD_MB`, default 256; `HOUSESCAN_MAX_UNZIPPED_MB`, default 512) | +| 415 | The content type is neither JSON, zip nor multipart | +| 422 | The scene breaks `schemas/scene.schema.json` or can't be placed (for example the meter is far from its wall) | + +## Contracts + +- `schemas/scene.schema.json` (C1): what the app uploads. It is docs/01's `scene.json` in feet with optional additions (`schema_version`, `coverage`, `plus_minus_ft`, `tape` as a source, a plan `footprint` per object). Unknown fields are refused, so a misspelled field fails loudly instead of being ignored. +- `schemas/result.schema.json` (C2): what the server answers. Both files change only by adding optional fields; `schema_version` goes up when they do. + +Three conventions matter to anyone producing a scene: + +1. Wall `baseline` points run **left to right as seen from outside**, and walls are listed in that order. The outward side of each wall segment follows from its point order. The server refuses a scene whose keyframe cameras sit mostly on the inward side. +2. `s` is feet along the unrolled walls from the meter, negative to the left. Spans (`span_ft`) and coverage use it. +3. `coverage` says what the capture actually saw. A check that depends on something unseen is UNSURE, never PASS. A scene without `coverage` can't pass. + +## How a decision is made + +`solve(parse_scene(raw, rules), loaded_rules)` is a pure function; `api.py` only adds HTTP. + +1. **Unroll the walls** (`scene.py`). The walls are chained left to right. Every straight segment keeps its outward direction, and s is measured from the meter's projection onto its wall. Stretches between walls that don't meet are gaps: nothing backs onto them, and no cable crosses them. +2. **Sweep the footprint** (`solver.py`). The battery (31 × 22 in from `rules.yaml`) slides along every straight segment. Start positions are a 2 in grid plus every position where a check can change outcome: an opening's clearance edge, a ground patch edge, a coverage boundary, the start at which the footprint comes exactly the clearance distance from a gas meter's corner. Midpoints between those positions are added too, so a legal stretch narrower than 2 in is still found. A footprint never bends round a corner. +3. **Check each position.** Distances are measured in plan (x, z) with shapely, so a gas meter just round an inside corner is as close as it really is. Each check is strict (contract C5): with a measured gap d ± e and a minimum T, PASS needs d − e > T, FAIL needs d + e < T, and anything else, including exact equality, is UNSURE. Errors add up: a gap between two tapped positions that are each ±0.3 ft is ±0.6 ft. A recognition confidence is never used as a distance error. + + | Check | Measures | + | --- | --- | + | `wall_backing` | Footprint flush on one straight, observed segment | + | `ground_surface` | Ground under the footprint is an allowed surface; patch edges count only where they meet a disallowed or unrecorded surface | + | `meter_working_space` | Clear of the NEC 110.26 space in front of the meter | + | `gas_clearance`, `ac_clearance`, `pool_clearance`, `drive_clearance` | Distance to every part of each object, and everything within the clearance was seen | + | `opening_clearance` | Distance to doors and windows; an unknown attribute that could exempt a window gives UNSURE, never a pass | + | `wall_equipment_above` | No box or vent on the wall above the battery | + | `facing_gap`, `headroom` | The smallest measurement over the battery's stretch of wall | + | `route_path` | The cable runs along continuous, observed wall; doors, garages and gaps block it; windows it detours round | + | `route_length` | Routed length, including detours and corners: PASS under the confident reach, FAIL clearly over the maximum | + +4. **Decide.** A spot passes when every check passes. With at least one passing spot the result is **pass**, choosing the shortest cable run. With none, the best spot without a failing check gives **manual_review**, with its reasons and the exact stretches still unseen. **reject** needs every spot to fail by a clear margin *and* both ends of the walk known: each end must be a real limit, or so far away that no spot beyond it could pass the route-length check. Otherwise the result asks for the walk to continue. A policy with `auto_approve: false` turns every pass or reject into manual review. + +## Rules + +`rules.yaml` holds every number, each with a `source`. `placeholder: true` marks a demo value that has no public source (driveway, pool, confident reach). The public file sets `auto_approve: false` for that reason. + +At startup the server deep-merges a git-ignored `private/rules.yaml` over it when one exists. The path comes from `HOUSESCAN_PRIVATE_RULES`, or else `/private/rules.yaml`. `GET /health` shows which files are loaded and the hash of the merged rules. Every result records the same hash. Never commit Base's values: `private/` is git-ignored. + +## Tests + +- `tests/test_golden_public.py`: the 14 golden tests from `docs/research/t3-lane-c-review.md` (branch `t3/research`). Every result is validated against the result schema. +- `tests/test_properties.py`: property tests. Missing coverage never passes, equality is unsure, left and right mirror, and a larger clearance never turns a pass into a fail. +- `tests/test_api.py`, `tests/test_schemas.py`, `tests/test_siteplan.py`: the HTTP layer, the published schemas, the SVG. + +Base-derived tests live in `private/tests/` (git-ignored). Run them with `uv run pytest ../private/tests -q`. Recognition prompts go in `server/prompts/`, which git ignores; they are never committed. diff --git a/server/solver.py b/server/solver.py index 8b2516da4..c0aa513ab 100644 --- a/server/solver.py +++ b/server/solver.py @@ -13,7 +13,9 @@ import itertools import math import time +from collections.abc import Callable from dataclasses import dataclass, field +from functools import partial from typing import Any from shapely import Geometry, Polygon, unary_union @@ -46,6 +48,11 @@ class Check: subject: str | None = None unsure_cause: str | None = None missing: list[tuple[str, float, float]] = field(default_factory=list) + # Computing the exact unseen stretch is slow, so it is deferred until a result reports it. + missing_later: Callable[[], list[tuple[str, float, float]]] | None = None + + def all_missing(self) -> list[tuple[str, float, float]]: + return self.missing + (self.missing_later() if self.missing_later else []) def to_json(self) -> dict[str, Any]: out: dict[str, Any] = { @@ -234,7 +241,7 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: return c if not self._covered(fp, self.unobserved_ground, 0.0): c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing = self._missing_ground(fp, 0.0) + c.missing_later = partial(self._missing_ground, fp, 0.0) c.reason = "The ground under the footprint was not seen." return c # A patch edge's error matters only where an allowed surface meets a disallowed or @@ -329,11 +336,11 @@ def check_clearance( f"battery against a {ft(t)} rule: too close to call." ) if not covered: - c.missing = self._missing_band(band, fp, t) + c.missing_later = partial(self._missing_band, band, fp, t) return c if not covered: c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing = self._missing_band(band, fp, t) + c.missing_later = partial(self._missing_band, band, fp, t) c.reason = ( f"Not everything within {ft(t)} of the battery was seen, so a {noun} could " "hide there." @@ -850,7 +857,7 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: def _missing_json(c: Candidate) -> list[dict[str, Any]]: by_band: dict[str, list[tuple[float, float, str]]] = {} for chk in c.checks: - for band, a, b in chk.missing: + for band, a, b in chk.all_missing(): by_band.setdefault(band, []).append((a, b, chk.id)) out = [] for band, items in by_band.items(): From 2c9348fc2d504404446dc5a572e71494b72370dc Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:28:01 -0500 Subject: [PATCH 07/52] server: FastAPI layer, property tests, mirror-symmetric sweep POST /v1/placements takes bare scene.json, a zip bundle or a multipart bundle field and returns the result JSON; /v1/placements/site-plan.svg returns the site plan; GET /health reports the loaded rules. Refusals share one JSON error envelope with the JSON pointer at fault. Bundles are read in memory; unsafe entry names and oversized bodies are refused. Property tests: missing coverage never passes, equality is unsure, left and right mirror exactly, a larger clearance never turns a pass into a fail and a stricter rule never turns a fail into a pass. The mirror property found that the 2 in grid was anchored at each segment's left end, so a scene and its mirror picked spots up to 1 in apart; the grid is now anchored at the meter in left- and right-edge form. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/api.py | 345 ++++++++++++++++++++++ server/solver.py | 8 +- server/tests/test_api.py | 363 +++++++++++++++++++++++ server/tests/test_properties.py | 506 ++++++++++++++++++++++++++++++++ 4 files changed, 1221 insertions(+), 1 deletion(-) create mode 100644 server/api.py create mode 100644 server/tests/test_api.py create mode 100644 server/tests/test_properties.py diff --git a/server/api.py b/server/api.py new file mode 100644 index 000000000..ea746f8b4 --- /dev/null +++ b/server/api.py @@ -0,0 +1,345 @@ +"""HTTP front of the placement server. + +One upload in, one solver result out. The body is a bare scene.json, a zip bundle (scene.json plus +the keyframe and still images it names), or a multipart form whose single `bundle` field holds +either. Every refusal is the same JSON envelope, {"error": {"code", "message", "path"}}, where +`path` is a JSON pointer into scene.json when one field is to blame; a client never sees a stack +trace. + +The zip is read in memory and never extracted, so entry names only matter as lookups; entries with +absolute paths or `..` are still refused because a bundle carrying them was not made by our app. + +Run: uv run uvicorn api:app --host 0.0.0.0 --port 8000 +""" + +import io +import json +import logging +import os +import re +import zipfile +import zlib +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, Response +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import UploadFile +from starlette.exceptions import HTTPException + +import siteplan +from rules import LoadedRules, load_rules +from scene import Scene, SceneError, parse_scene +from solver import SCHEMA_VERSION, solve + +log = logging.getLogger("housescan.api") + +_MB = 1024 * 1024 + + +def _limit_bytes(env: str, default_mb: float) -> int: + raw = os.environ.get(env) + if raw is None: + return int(default_mb * _MB) + try: + mb = float(raw) + except ValueError: + mb = -1.0 + if not mb > 0 or mb == float("inf"): + raise ValueError(f"{env}={raw!r} must be a positive number of megabytes") + return int(mb * _MB) + + +# 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) + +# Loaded at import: invalid rules must stop the server before it answers anything. +LOADED: LoadedRules = load_rules() + +_ZIP_MAGIC = b"PK\x03\x04" +_JSON_TYPES = {"application/json"} +_ZIP_TYPES = {"application/zip", "application/x-zip-compressed"} +# No declared type, or a generic binary one: the body's first bytes decide. +_SNIFF_TYPES = {"", "application/octet-stream"} +_MULTIPART = "multipart/form-data" +_BUNDLE_FIELD = "bundle" +# macOS Finder adds this folder of resource forks to every zip it makes. +_MACOS_JUNK = "__MACOSX/" +_DRIVE = re.compile(r"^[A-Za-z]:") + + +class ApiError(Exception): + def __init__(self, status: int, code: str, message: str, path: str | None = None) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + self.path = path + + +def _error_response(status: int, code: str, message: str, path: str | None) -> JSONResponse: + return JSONResponse( + {"error": {"code": code, "message": message, "path": path}}, status_code=status + ) + + +app = FastAPI( + title="House scanning placement server", + version=SCHEMA_VERSION, + description="Turns a phone capture (scene.json or a zip bundle) into a battery placement.", +) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + + +@app.exception_handler(ApiError) +async def _api_error(_request: Request, exc: ApiError) -> JSONResponse: + return _error_response(exc.status, exc.code, exc.message, exc.path) + + +_HTTP_CODES = { + 400: "bad_request", + 404: "not_found", + 405: "method_not_allowed", + 413: "body_too_large", + 415: "unsupported_media_type", +} + + +@app.exception_handler(HTTPException) +async def _http_error(_request: Request, exc: HTTPException) -> JSONResponse: + code = _HTTP_CODES.get(exc.status_code, f"http_{exc.status_code}") + return _error_response(exc.status_code, code, str(exc.detail), None) + + +@app.exception_handler(Exception) +async def _internal_error(_request: Request, exc: Exception) -> JSONResponse: + log.exception("unhandled error", exc_info=exc) + return _error_response( + 500, "internal_error", "The server failed while handling this upload.", None + ) + + +def _policy() -> dict[str, Any]: + p = LOADED.rules.policy + return { + "id": p.id, + "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, + "sources": list(LOADED.sources), + "rules_sha256": LOADED.sha256, + } + + +@app.get("/health") +def health() -> dict[str, Any]: + return {"status": "ok", "schema_version": SCHEMA_VERSION, "policy": _policy()} + + +# --- reading the upload --------------------------------------------------------------------------- + + +def _media_type(request: Request) -> str: + return request.headers.get("content-type", "").split(";")[0].strip().lower() + + +async def _read_body(request: Request) -> bytes: + """The whole body, refused with 413 as soon as it passes MAX_UPLOAD_BYTES.""" + limit = MAX_UPLOAD_BYTES + too_large = ApiError( + 413, "body_too_large", f"The upload is larger than the {limit // _MB} MB limit." + ) + declared = request.headers.get("content-length") + if declared is not None and declared.isdigit() and int(declared) > limit: + raise too_large + chunks: list[bytes] = [] + size = 0 + async for chunk in request.stream(): + size += len(chunk) + if size > limit: + raise too_large + chunks.append(chunk) + return b"".join(chunks) + + +async def _bundle_field(request: Request, body: bytes) -> tuple[bytes, str]: + """The `bundle` field of a multipart form and its declared media type.""" + + async def replay() -> dict[str, Any]: + return {"type": "http.request", "body": body, "more_body": False} + + form_request = Request(request.scope, replay) + try: + form = await form_request.form(max_files=1, max_fields=1) + except HTTPException as exc: + raise ApiError(400, "unreadable_multipart", str(exc.detail)) from None + try: + extra = sorted(k for k in form if k != _BUNDLE_FIELD) + if extra: + raise ApiError( + 400, + "unexpected_form_field", + f"The form may only carry one field named {_BUNDLE_FIELD!r}; got {extra}.", + ) + field = form.get(_BUNDLE_FIELD) + if field is None: + raise ApiError(400, "missing_bundle", f"The form has no field named {_BUNDLE_FIELD!r}.") + if isinstance(field, UploadFile): + return await field.read(), (field.content_type or "").split(";")[0].strip().lower() + return field.encode(), "" + finally: + await form.close() + + +def _reject_constant(name: str) -> float: + raise ValueError(f"{name} is not a number JSON allows") + + +def _load_json(data: bytes, what: str) -> Any: + try: + return json.loads(data, parse_constant=_reject_constant) + except json.JSONDecodeError as exc: + raise ApiError( + 422, "invalid_json", f"{what} is not valid JSON: {exc.msg} at line {exc.lineno}" + ) from None + except (ValueError, RecursionError) as exc: + raise ApiError(422, "invalid_json", f"{what} is not valid JSON: {exc}") from None + + +def _unsafe(name: str) -> bool: + n = name.replace("\\", "/") + return n.startswith("/") or bool(_DRIVE.match(n)) or ".." in n.split("/") + + +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.""" + try: + zf = zipfile.ZipFile(io.BytesIO(data)) + except (zipfile.BadZipFile, zlib.error, ValueError, EOFError) as exc: + raise ApiError(400, "unreadable_zip", f"The bundle is not a readable zip: {exc}") from None + with zf: + infos = zf.infolist() + for info in infos: + if _unsafe(info.filename): + raise ApiError( + 400, + "unsafe_zip_entry", + f"The bundle entry {info.filename!r} has an absolute path or '..'.", + ) + total = sum(info.file_size for info in infos) + if total > MAX_UNZIPPED_BYTES: + raise ApiError( + 413, + "bundle_too_large", + f"The bundle unpacks to {total} bytes, over the " + f"{MAX_UNZIPPED_BYTES // _MB} MB limit.", + ) + names = {i.filename for i in infos if not i.is_dir()} + prefix = _scene_folder(names) + try: + scene_bytes = zf.read(prefix + "scene.json") + except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError, EOFError) as exc: + raise ApiError( + 400, "unreadable_zip", f"scene.json in the bundle can't be read: {exc}" + ) from None + return scene_bytes, names, prefix + + +def _scene_folder(names: set[str]) -> str: + if "scene.json" in names: + return "" + tops = {n.split("/", 1)[0] for n in names if not n.startswith(_MACOS_JUNK)} + if len(tops) == 1: + (top,) = tops + if f"{top}/scene.json" in names: + return f"{top}/" + raise ApiError( + 400, + "missing_scene_json", + "The bundle has no scene.json at its root or inside a single top-level folder.", + ) + + +def _pointer_token(key: str) -> str: + return key.replace("~", "~0").replace("/", "~1") + + +def _check_bundle_files(raw: dict[str, Any], names: set[str], prefix: str) -> None: + """Every image scene.json names must be in the bundle, next to scene.json.""" + wanted = [ + (f"/keyframes/{i}/img", kf["img"]) for i, kf in enumerate(raw.get("keyframes", [])) + ] + [(f"/stills/{_pointer_token(k)}", v) for k, v in raw.get("stills", {}).items()] + missing = [(path, name) for path, name in wanted if prefix + name not in names] + if missing: + listed = ", ".join(repr(name) for _, name in missing) + raise ApiError( + 422, + "missing_bundle_file", + f"scene.json names files the bundle doesn't contain: {listed}.", + missing[0][0], + ) + + +async def _upload(request: Request) -> tuple[bytes, bool]: + """The scene payload and whether it is a zip bundle.""" + media = _media_type(request) + if media not in _JSON_TYPES | _ZIP_TYPES | _SNIFF_TYPES | {_MULTIPART}: + raise ApiError( + 415, + "unsupported_media_type", + f"Content-Type {media!r} is not supported; send application/json, application/zip " + f"or multipart/form-data with a {_BUNDLE_FIELD!r} file.", + ) + body = await _read_body(request) + if media == _MULTIPART: + body, media = await _bundle_field(request, body) + if not body: + raise ApiError(400, "empty_body", "The upload is empty.") + return body, media in _ZIP_TYPES or body.startswith(_ZIP_MAGIC) + + +def _parse(payload: bytes, is_zip: bool) -> Scene: + if is_zip: + scene_bytes, names, prefix = _open_bundle(payload) + raw = _load_json(scene_bytes, "scene.json") + else: + scene_bytes, names, prefix = payload, None, "" + raw = _load_json(payload, "The body") + try: + scene = parse_scene(raw, LOADED.rules, scene_bytes) + except SceneError as exc: + raise ApiError(422, "invalid_scene", exc.message, exc.path) from None + if names is not None: + _check_bundle_files(raw, names, prefix) + return scene + + +def _solve(payload: bytes, is_zip: bool) -> tuple[Scene, dict[str, Any]]: + scene = _parse(payload, is_zip) + return scene, solve(scene, LOADED) + + +@app.post("/v1/placements") +async def placements(request: Request) -> JSONResponse: + payload, is_zip = await _upload(request) + _scene, result = await run_in_threadpool(_solve, payload, is_zip) + return JSONResponse(result) + + +@app.post("/v1/placements/site-plan.svg") +async def site_plan(request: Request) -> Response: + payload, is_zip = await _upload(request) + + def render() -> str: + scene, result = _solve(payload, is_zip) + return siteplan.render(scene, result, LOADED) + + svg = await run_in_threadpool(render) + return Response(content=svg, media_type="image/svg+xml") diff --git a/server/solver.py b/server/solver.py index c0aa513ab..23cb8d72d 100644 --- a/server/solver.py +++ b/server/solver.py @@ -713,8 +713,14 @@ def starts(self, piece: Piece) -> list[float]: return [] hi = max(hi, lo) step = self.r.sweep.step_ft.value - points = [lo + k * step for k in range(int((hi - lo) / step + EPS) + 1)] + [hi] W, D = self.W, self.D + # The grid is anchored at the meter, in left-edge and right-edge form (starts k * step + # and k * step - W), so the start positions of a scene and of its mirror image map onto + # each other and left and right get the same treatment. + k_lo, k_hi = math.floor(lo / step) - 1, math.ceil((hi + W) / step) + 1 + points = [lo, hi] + for k in range(k_lo, k_hi + 1): + points += [k * step, k * step - W] # Along the wall: every place an interval can start or stop mattering. boundaries = [0.0, *self.ws_span] for o in self.scene.objects: diff --git a/server/tests/test_api.py b/server/tests/test_api.py new file mode 100644 index 000000000..6ffe0c9d5 --- /dev/null +++ b/server/tests/test_api.py @@ -0,0 +1,363 @@ +import copy +import io +import json +import time +import zipfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from jsonschema import Draft202012Validator + +import api +from scene import parse_scene +from solver import solve + +SERVER = Path(__file__).resolve().parents[1] +RESULT_VALIDATOR = Draft202012Validator( + json.loads((SERVER / "schemas" / "result.schema.json").read_text()) +) +EXAMPLE_BYTES = (SERVER / "tests" / "fixtures" / "example-scene.json").read_bytes() +EXAMPLE = json.loads(EXAMPLE_BYTES) +# A short wall solves in milliseconds; tests about transport, not placement, upload this one. It +# names one keyframe image and one still, like a real bundle. +SMALL = { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w1"}, + "walls": [{"id": "w1", "baseline": [[-8.0, 0.0], [8.0, 0.0]], "height_ft": 9}], + "ground": [{"type": "lawn", "polygon": [[-8, 0], [8, 0], [8, 20], [-8, 20]]}], + "coverage": { + "observed": [ + {"band": "wall", "span_ft": [-8, 8]}, + {"band": "ground", "span_ft": [-8, 8], "out_ft": 20}, + ] + }, + "keyframes": [ + { + "id": "k1", + "pose": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2.0, 4.5, 9.0, 1], + "intrinsics": [1450.0, 1450.0, 960.0, 720.0], + "w": 1920, + "h": 1440, + "img": "k1.jpg", + } + ], + "stills": {"meter_close": "meter_close.jpg"}, +} +SMALL_BYTES = json.dumps(SMALL).encode() +JPEG = b"\xff\xd8\xff\xe0 synthetic test image \xff\xd9" +PLACEMENTS = "/v1/placements" +SITE_PLAN = "/v1/placements/site-plan.svg" +_JSON = {"content-type": "application/json"} + + +@pytest.fixture(scope="module") +def client() -> TestClient: + return TestClient(api.app) + + +def bundle(files: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for name, data in files.items(): + zf.writestr(name, data) + return buf.getvalue() + + +def example_bundle( + prefix: str = "", drop: str | None = None, scene: bytes = EXAMPLE_BYTES +) -> bytes: + files = {"scene.json": scene, "k1.jpg": JPEG, "meter_close.jpg": JPEG} + return bundle({prefix + k: v for k, v in files.items() if k != drop}) + + +def post_zip(client: TestClient, data: bytes, media: str = "application/zip"): + return client.post(PLACEMENTS, content=data, headers={"content-type": media}) + + +def error(resp) -> dict: + body = resp.json() + assert set(body) == {"error"}, body + assert set(body["error"]) == {"code", "message", "path"}, body + return body["error"] + + +def same_decision(a: dict, b: dict) -> None: + assert a["decision"] == b["decision"] + assert a["spot"] == b["spot"] + assert [(c["id"], c["outcome"]) for c in a["checks"]] == [ + (c["id"], c["outcome"]) for c in b["checks"] + ] + + +# --- health --------------------------------------------------------------------------------------- + + +def test_health_reports_the_loaded_policy(client: TestClient) -> None: + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["schema_version"] == "1.0" + policy = api.LOADED.rules.policy + assert body["policy"] == { + "id": policy.id, + "version": policy.version, + "auto_approve": policy.auto_approve and policy.id is not None, + "sources": list(api.LOADED.sources), + "rules_sha256": api.LOADED.sha256, + } + + +def test_health_policy_matches_what_a_placement_reports(client: TestClient) -> None: + placed = client.post(PLACEMENTS, content=SMALL_BYTES, headers=_JSON).json() + assert client.get("/health").json()["policy"] == placed["policy"] + + +# --- accepted uploads ----------------------------------------------------------------------------- + + +def test_bare_json_returns_a_schema_valid_result(client: TestClient) -> None: + resp = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers=_JSON) + assert resp.status_code == 200, resp.text + result = resp.json() + RESULT_VALIDATOR.validate(result) + direct = solve(parse_scene(copy.deepcopy(EXAMPLE), api.LOADED.rules), api.LOADED) + same_decision(result, direct) + + +def test_zip_bundle_gives_the_same_decision_as_bare_json(client: TestClient) -> None: + bare = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers=_JSON).json() + resp = post_zip(client, example_bundle()) + assert resp.status_code == 200, resp.text + zipped = resp.json() + RESULT_VALIDATOR.validate(zipped) + same_decision(zipped, bare) + # The input hash is over scene.json's bytes, so both routes name the same input. + assert zipped["stats"]["input_sha256"] == bare["stats"]["input_sha256"] + + +@pytest.mark.parametrize("media", ["application/octet-stream", "application/json"]) +def test_zip_is_recognised_by_its_magic_bytes(client: TestClient, media: str) -> None: + resp = post_zip(client, example_bundle(scene=SMALL_BYTES), media) + assert resp.status_code == 200, resp.text + + +def test_zip_with_scene_inside_one_top_level_folder(client: TestClient) -> None: + data = example_bundle("capture-2026-09-26/", scene=SMALL_BYTES) + # A macOS Finder zip also carries __MACOSX/, which must not count as a second folder. + with zipfile.ZipFile(io.BytesIO(data), "a") as zf: + zf.writestr("__MACOSX/capture-2026-09-26/._scene.json", b"resource fork") + assert post_zip(client, data).status_code == 200 + + +def test_multipart_bundle_field_accepts_zip_and_json(client: TestClient) -> None: + as_zip = client.post( + PLACEMENTS, + files={"bundle": ("capture.zip", example_bundle(scene=SMALL_BYTES), "application/zip")}, + ) + as_json = client.post( + PLACEMENTS, files={"bundle": ("scene.json", SMALL_BYTES, "application/json")} + ) + assert as_zip.status_code == 200, as_zip.text + assert as_json.status_code == 200, as_json.text + same_decision(as_zip.json(), as_json.json()) + + +# --- refusals ------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("missing", "path"), + [("k1.jpg", "/keyframes/0/img"), ("meter_close.jpg", "/stills/meter_close")], +) +def test_zip_missing_a_referenced_image_is_422_naming_it( + client: TestClient, missing: str, path: str +) -> None: + resp = post_zip(client, example_bundle(drop=missing)) + assert resp.status_code == 422 + err = error(resp) + assert err["code"] == "missing_bundle_file" + assert missing in err["message"] + assert err["path"] == path + + +def test_bare_json_skips_the_image_check(client: TestClient) -> None: + # SMALL names k1.jpg and meter_close.jpg; a bare upload carries no files to check. + assert client.post(PLACEMENTS, content=SMALL_BYTES, headers=_JSON).status_code == 200 + + +@pytest.mark.parametrize("name", ["../x", "a/../../x", "/etc/x", "C:/x", "..\\x"]) +def test_zip_entry_escaping_the_bundle_is_400(client: TestClient, name: str) -> None: + data = bundle({"scene.json": EXAMPLE_BYTES, "k1.jpg": JPEG, "meter_close.jpg": JPEG, name: b""}) + resp = post_zip(client, data) + assert resp.status_code == 400 + err = error(resp) + assert err["code"] == "unsafe_zip_entry" + assert repr(name) in err["message"] + + +def test_zip_without_scene_json_is_400(client: TestClient) -> None: + resp = post_zip(client, bundle({"a/scene.json": b"{}", "b/k1.jpg": JPEG})) + assert resp.status_code == 400 + assert error(resp)["code"] == "missing_scene_json" + + +def test_corrupt_zip_is_400(client: TestClient) -> None: + resp = post_zip(client, b"PK\x03\x04 not really a zip") + assert resp.status_code == 400 + assert error(resp)["code"] == "unreadable_zip" + + +def test_malformed_json_is_422(client: TestClient) -> None: + resp = client.post(PLACEMENTS, content=b'{"meter": ', headers=_JSON) + assert resp.status_code == 422 + err = error(resp) + assert err["code"] == "invalid_json" + assert err["path"] is None + + +def test_nan_is_not_json(client: TestClient) -> None: + text = EXAMPLE_BYTES.decode().replace('"plus_minus_ft": 0.3}', '"plus_minus_ft": NaN}', 1) + resp = client.post(PLACEMENTS, content=text.encode(), headers=_JSON) + assert resp.status_code == 422 + assert error(resp)["code"] == "invalid_json" + + +def test_unknown_scene_field_is_422_with_its_path(client: TestClient) -> None: + scene = copy.deepcopy(EXAMPLE) + scene["objects"][1]["plusminus_ft"] = 0.3 + resp = client.post(PLACEMENTS, json=scene) + assert resp.status_code == 422 + err = error(resp) + assert err["code"] == "invalid_scene" + assert err["path"] == "/objects/1" + assert "plusminus_ft" in err["message"] + + +def test_scene_error_after_the_schema_carries_its_path(client: TestClient) -> None: + scene = copy.deepcopy(EXAMPLE) + scene["meter"]["wall_id"] = "nowhere" + resp = client.post(PLACEMENTS, json=scene) + assert resp.status_code == 422 + assert error(resp)["path"] == "/meter/wall_id" + + +@pytest.mark.parametrize("media", ["text/plain", "application/xml", "image/jpeg"]) +def test_unsupported_content_type_is_415(client: TestClient, media: str) -> None: + resp = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers={"content-type": media}) + assert resp.status_code == 415 + assert error(resp)["code"] == "unsupported_media_type" + + +def test_oversized_body_is_413(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(api, "MAX_UPLOAD_BYTES", len(EXAMPLE_BYTES) - 1) + resp = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers=_JSON) + assert resp.status_code == 413 + assert error(resp)["code"] == "body_too_large" + + +def test_oversized_body_without_content_length_is_413( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(api, "MAX_UPLOAD_BYTES", 1000) + + def chunks(): + for _ in range(10): + yield b" " * 200 + + resp = client.post(PLACEMENTS, content=chunks(), headers=_JSON) + assert resp.status_code == 413 + + +def test_bundle_that_unpacks_too_large_is_413( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(api, "MAX_UNZIPPED_BYTES", len(EXAMPLE_BYTES) + 10) + resp = post_zip(client, example_bundle()) + assert resp.status_code == 413 + assert error(resp)["code"] == "bundle_too_large" + + +def test_empty_body_is_400(client: TestClient) -> None: + resp = client.post(PLACEMENTS, content=b"", headers=_JSON) + assert resp.status_code == 400 + assert error(resp)["code"] == "empty_body" + + +def test_multipart_without_bundle_field_is_400(client: TestClient) -> None: + resp = client.post(PLACEMENTS, files={"scene": ("scene.json", EXAMPLE_BYTES)}) + assert resp.status_code == 400 + assert error(resp)["code"] == "unexpected_form_field" + + +def test_multipart_with_two_files_is_400(client: TestClient) -> None: + resp = client.post( + PLACEMENTS, + files=[("bundle", ("a.json", EXAMPLE_BYTES)), ("bundle", ("b.json", EXAMPLE_BYTES))], + ) + assert resp.status_code == 400 + assert error(resp)["code"] == "unreadable_multipart" + + +def test_unknown_route_uses_the_error_envelope(client: TestClient) -> None: + resp = client.get("/v1/nothing-here") + assert resp.status_code == 404 + assert error(resp)["code"] == "not_found" + + +@pytest.mark.parametrize("value", ["abc", "0", "-5", "inf", "nan"]) +def test_bad_size_limit_env_fails_loudly(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv("HOUSESCAN_MAX_UPLOAD_MB", value) + with pytest.raises(ValueError, match="HOUSESCAN_MAX_UPLOAD_MB"): + api._limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256) + + +def test_size_limit_env_is_megabytes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_MAX_UPLOAD_MB", "1.5") + assert api._limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256) == 3 * 512 * 1024 + + +# --- CORS ----------------------------------------------------------------------------------------- + + +def test_cors_preflight_allows_any_origin(client: TestClient) -> None: + resp = client.options( + PLACEMENTS, + headers={ + "origin": "https://example.org", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + ) + assert resp.status_code == 200 + assert resp.headers["access-control-allow-origin"] == "*" + assert "POST" in resp.headers["access-control-allow-methods"] + + +# --- site plan ------------------------------------------------------------------------------------ + + +def test_site_plan_is_svg_for_the_same_upload(client: TestClient) -> None: + data = example_bundle(scene=SMALL_BYTES) + placed = post_zip(client, data).json() + svg = client.post(SITE_PLAN, content=data, headers={"content-type": "application/zip"}) + assert svg.status_code == 200 + assert svg.headers["content-type"].startswith("image/svg+xml") + assert svg.text.startswith(" None: + resp = client.post(SITE_PLAN, content=b"{", headers=_JSON) + assert resp.status_code == 422 + assert error(resp)["code"] == "invalid_json" + + +# --- speed ---------------------------------------------------------------------------------------- + + +def test_solving_the_example_takes_under_a_second() -> None: + scene = parse_scene(copy.deepcopy(EXAMPLE), api.LOADED.rules) + started = time.perf_counter() + solve(scene, api.LOADED) + assert time.perf_counter() - started < 1.0 diff --git a/server/tests/test_properties.py b/server/tests/test_properties.py new file mode 100644 index 000000000..353d57421 --- /dev/null +++ b/server/tests/test_properties.py @@ -0,0 +1,506 @@ +"""Properties the placement solver must keep for every scene, not only the golden ones. + +All scenes are synthetic: one straight wall w1 along z = 0 from x = -16 to 16 (s = x, outward +z), +the meter at s = 0, lawn over one pad and deck (not an allowed surface) everywhere else, so every +spot that can pass lies on the pad. + +The rules are the golden test rules with a 0.5 ft filler grid instead of 2 in. That keeps each +solve in the tens of milliseconds, and it thins only the filler: the solver also starts a candidate +at every position where some check can change outcome, which is what these properties exercise. +""" + +import copy +from typing import Any + +import pytest +from helpers import ( + everything_observed, + golden_rules, + pads_ground, + parsed, + rect, + run, + shared_fixture, +) +from hypothesis import given, settings +from hypothesis import strategies as st + +from solver import FAIL, PASS, UNSURE, at_least, evaluate_start, reach_outcome + +LO, HI = -16.0, 16.0 +GRID = 0.5 +STEP = {"sweep": {"step_ft": {"value": GRID, "source": "test: coarse filler grid"}}} +FAST = golden_rules(**STEP) +W = FAST.rules.battery.width_ft.value +D = FAST.rules.battery.depth_ft.value +_C = FAST.rules.clearances +OPENING = _C.opening_ft.value +# The widest plan clearance measured over the ground: how far out the ground must have been seen. +GROUND_REACH = max(_C.gas_ft.value, _C.ac_ft.value, _C.drive_ft.value, _C.pool_ft.value) +SEVERITY = {PASS: 0, UNSURE: 1, FAIL: 2} +# A cut in coverage must overlap the stretch it is meant to hide by at least this much, so the +# unseen area is real geometry and not a floating point sliver. +OVERLAP = 0.05 + +# No example database: a failure is reproduced from the example pytest prints, so every run and CI +# behave the same whatever an earlier local run found. +PROPERTY = settings(max_examples=40, deadline=None, database=None) + + +def ft(lo: float, hi: float) -> st.SearchStrategy[float]: + return st.floats(lo, hi, allow_nan=False).map(lambda x: round(x, 3)) + + +# --- scene builders + + +def gas(g: float, err: float = 0.0) -> dict[str, Any]: + # top_ft is below the cable height, so the cable passes over it without a detour. + return { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [g, g + 1], + "bottom_ft": 0, + "top_ft": 0.8, + "source": "tap", + "plus_minus_ft": err, + "footprint": rect(g, g + 1, 0, 1), + } + + +def window(w: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "window", + "wall_id": "w1", + "span_ft": [w, w + 3], + "bottom_ft": 3, + "top_ft": 7, + "attrs": {"operable": True, "well": False}, + "source": "tap", + "plus_minus_ft": err, + } + + +def ac(a: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "ac", + "wall_id": "w1", + "span_ft": [a, a + 3], + "source": "tap", + "plus_minus_ft": err, + "footprint": rect(a, a + 3, 0.5, 3.5), + } + + +def elec_box(e: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [e, e + 1], + "bottom_ft": 4, + "top_ft": 5, + "source": "tap", + "plus_minus_ft": err, + } + + +def pool(q: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "pool", + "wall_id": "w1", + "span_ft": [q, q + 8], + "source": "tap", + "plus_minus_ft": err, + "footprint": rect(q, q + 8, 6, 14), + } + + +def scene( + pad: tuple[float, float], + objects: list[dict[str, Any]] | None = None, + facing: float = 9.0, + headroom: float = 9.0, + measured_err: float = 0.0, + wall_err: float = 0.0, + drive: float | None = None, +) -> dict[str, Any]: + ground = pads_ground([pad], LO, HI) + if drive is not None: + ground.append({"type": "drive", "polygon": rect(drive, drive + 4, 0, 30)}) + return { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0}, + "walls": [ + {"id": "w1", "baseline": [[LO, 0], [HI, 0]], "height_ft": 9, "plus_minus_ft": wall_err} + ], + "objects": objects or [], + "ground": ground, + "overheads": [ + { + "wall_id": "w1", + "span_ft": [LO, HI], + "clearance_ft": headroom, + "plus_minus_ft": measured_err, + } + ], + "facing": [ + { + "wall_id": "w1", + "span_ft": [LO, HI], + "depth_ft": facing, + "plus_minus_ft": measured_err, + } + ], + # Seen well past both wall ends, so no spot on the pad is unsure for lack of coverage. + "coverage": everything_observed(-60, 60, 30), + } + + +def _mirror_span(span: list[float]) -> list[float]: + return [-span[1], -span[0]] + + +def _mirror_points(points: list[list[float]]) -> list[list[float]]: + return [[-x, z] for x, z in reversed(points)] + + +def mirror(raw: dict[str, Any]) -> dict[str, Any]: + """The same scene reflected left for right (x -> -x, so s -> -s). Baselines are reversed so + they still run left to right as seen from outside, which keeps outward pointing out.""" + assert "keyframes" not in raw, "mirroring camera poses is not implemented" + m = copy.deepcopy(raw) + x, y, z = m["meter"]["pos"] + m["meter"]["pos"] = [-x, y, z] + m["walls"] = [{**w, "baseline": _mirror_points(w["baseline"])} for w in reversed(m["walls"])] + for o in m.get("objects", []): + o["span_ft"] = _mirror_span(o["span_ft"]) + if "footprint" in o: + o["footprint"] = _mirror_points(o["footprint"]) + for g in m.get("ground", []): + g["polygon"] = _mirror_points(g["polygon"]) + for item in m.get("overheads", []) + m.get("facing", []): + item["span_ft"] = _mirror_span(item["span_ft"]) + coverage = m.get("coverage", {}) + for obs in coverage.get("observed", []): + obs["span_ft"] = _mirror_span(obs["span_ft"]) + ends = coverage.get("ends", {}) + if ends: + coverage["ends"] = { + side: ends[other] + for side, other in (("left", "right"), ("right", "left")) + if other in ends + } + return m + + +def outcomes(result: dict[str, Any]) -> dict[str, str]: + return {c["id"]: c["outcome"] for c in result["checks"]} + + +# --- (b) equality is unsure: truth tables + + +@pytest.mark.parametrize( + ("value", "error", "threshold", "expected"), + [ + (4.0, 0.0, 3.0, PASS), + (2.0, 0.0, 3.0, FAIL), + (3.0, 0.0, 3.0, UNSURE), # exactly on the line + (3.6, 0.5, 3.0, PASS), + (3.5, 0.5, 3.0, UNSURE), # value - error on the line + (2.5, 0.5, 3.0, UNSURE), # value + error on the line + (2.4, 0.5, 3.0, FAIL), + (3.0, 1.0, 3.0, UNSURE), + (0.1 + 0.2, 0.0, 0.3, UNSURE), # 0.30000000000000004: float noise must not pass it + (0.0, 0.0, 0.0, UNSURE), # touching: the meter working space check + (-0.1, 0.0, 0.0, FAIL), + ], +) +def test_at_least_truth_table(value: float, error: float, threshold: float, expected: str) -> None: + assert at_least(value, error, threshold) == expected + + +@pytest.mark.parametrize( + ("length", "error", "expected"), + [ + (10.0, 0.0, PASS), + (15.0, 0.0, UNSURE), # exactly the confident reach + (17.0, 0.0, UNSURE), # between confident reach and maximum + (20.0, 0.0, UNSURE), # exactly the maximum + (21.0, 0.0, FAIL), + (14.0, 0.5, PASS), + (14.5, 0.5, UNSURE), # length + error on the confident line + (20.5, 0.5, UNSURE), # length - error on the maximum + (21.0, 0.5, FAIL), + (19.9, 0.5, UNSURE), + ], +) +def test_reach_outcome_truth_table(length: float, error: float, expected: str) -> None: + assert reach_outcome(length, error, 15.0, 20.0) == expected + + +@PROPERTY +@given(t=ft(0, 100), e=ft(0, 10)) +def test_at_least_on_either_error_edge_is_unsure(t: float, e: float) -> None: + assert at_least(t, 0.0, t) == UNSURE + assert at_least(t + e, e, t) == UNSURE + assert at_least(t - e, e, t) == UNSURE + + +@PROPERTY +@given(confident=ft(0, 50), extra=ft(0, 50), e=ft(0, 5)) +def test_reach_on_either_line_is_unsure(confident: float, extra: float, e: float) -> None: + maximum = confident + extra + assert reach_outcome(confident, 0.0, confident, maximum) == UNSURE + assert reach_outcome(maximum, 0.0, confident, maximum) == UNSURE + assert reach_outcome(confident - e, e, confident, maximum) == UNSURE + assert reach_outcome(maximum + e, e, confident, maximum) == UNSURE + + +# --- (a) missing coverage never passes + +BANDS = ["wall", "ground", "overhead", "facing"] + + +def needed(band: str, p0: float, p1: float) -> tuple[float, float]: + """The stretch of `band` every candidate on the pad [p0, p1] (right of the meter) depends on. + + A candidate starts at s0 in [p0, p1 - W]. The wall band carries its cable route (0 to s0), its + backing (s0 to s0 + W) and the opening clearance around it (to s0 + W + opening); the ground + band everything within the widest ground clearance of the footprint; overhead and facing only + the footprint's own stretch. The intersection over all candidates is what is returned. + """ + if band == "wall": + return (0.0, p0 + W + OPENING) + if band == "ground": + return (p1 - W - GROUND_REACH, p0 + W + GROUND_REACH) + return (p1 - W, p0 + W) + + +def _observed(raw: dict[str, Any], band: str) -> dict[str, Any]: + return next(o for o in raw["coverage"]["observed"] if o["band"] == band) + + +@st.composite +def pads(draw: st.DrawFn) -> tuple[float, float]: + p0 = draw(ft(2.0, 9.0)) + return (p0, round(p0 + draw(ft(W + 0.2, W + 2.0)), 3)) + + +@st.composite +def coverage_cuts(draw: st.DrawFn) -> tuple[tuple[float, float], str, str, float, float]: + pad = draw(pads()) + band = draw(st.sampled_from(BANDS)) + modes = ["remove", "hole", "cut_left", "cut_right"] + (["shallow"] * (band == "ground")) + mode = draw(st.sampled_from(modes)) + lo, hi = needed(band, *pad) + assert hi - lo > 2 * OVERLAP + at = draw(ft(lo + OVERLAP, hi - OVERLAP)) + size = draw(ft(2 * OVERLAP, 4.0)) + return pad, band, mode, at, size + + +def cut(raw: dict[str, Any], band: str, mode: str, at: float, size: float) -> dict[str, Any]: + raw = copy.deepcopy(raw) + observed = raw["coverage"]["observed"] + obs = _observed(raw, band) + a, b = obs["span_ft"] + if mode == "remove": + observed.remove(obs) + elif mode == "hole": + observed.remove(obs) + for span in ([a, at - size / 2], [at + size / 2, b]): + observed.append({**obs, "span_ft": span}) + elif mode == "cut_left": + obs["span_ft"] = [at, b] + elif mode == "cut_right": + obs["span_ft"] = [a, at] + else: # shallow: the ground was seen, but not as far out as the clearances reach + obs["out_ft"] = round(max(0.0, min(at, D + GROUND_REACH - OVERLAP)), 3) + return raw + + +def test_the_uncut_pad_scene_passes() -> None: + # The coverage property below means something only because the same scene, fully observed, + # passes. Checked at the generator's extremes. + for pad in [(2.0, 2.0 + W + 0.2), (9.0, 9.0 + W + 2.0), (6.0, 9.0)]: + for raw in (scene(pad), mirror(scene(pad))): + result = run(raw, FAST) + assert result["decision"] == PASS, (pad, result["summary"]) + + +@PROPERTY +@given(case=coverage_cuts(), flip=st.booleans()) +def test_missing_coverage_never_passes(case, flip: bool) -> None: + pad, band, mode, at, size = case + base = scene(pad) + raw = cut(base, band, mode, at, size) + if flip: + raw = mirror(raw) + result = run(raw, FAST) + assert result["stats"]["pass"] == 0, result["summary"] + assert result["decision"] != PASS + assert result["spot"] is None or result["spot"]["outcome"] != PASS + + +# --- (c) left and right mirror + + +@st.composite +def layouts(draw: st.DrawFn) -> dict[str, Any]: + p0, p1 = draw(pads()) + if draw(st.booleans()): + p0, p1 = -p1, -p0 + objects = [] + # Nonzero errors put some best spots inside an error band, so manual_review is reached too. + g = draw(st.one_of(st.none(), ft(-14.0, 13.0))) + if g is not None: + objects.append(gas(g, draw(ft(0.0, 0.5)))) + w = draw(st.one_of(st.none(), ft(-14.0, 11.0))) + if w is not None: + objects.append(window(w, draw(ft(0.0, 0.5)))) + return scene((p0, p1), objects) + + +def assert_mirrored(a: dict[str, Any], b: dict[str, Any], tol: float) -> None: + """`b` is the answer for the mirror of `a`'s scene, positions equal within `tol`.""" + assert a["decision"] == b["decision"] + assert (a["spot"] is None) == (b["spot"] is None) + if a["spot"] is None: + near_a, near_b = a["nearest_considered"], b["nearest_considered"] + assert near_a["route_length_ft"] == pytest.approx(near_b["route_length_ft"], abs=tol) + return + assert outcomes(a) == outcomes(b) + assert a["route"]["length_ft"] == pytest.approx(b["route"]["length_ft"], abs=tol) + s0, s1 = a["spot"]["span_ft"] + assert b["spot"]["span_ft"] == pytest.approx([-s1, -s0], abs=tol) + + +# Start positions are anchored at the meter in left-edge and right-edge form, so a scene and its +# mirror try mirrored starts and must choose mirrored spots, to rounding. +EXACT = 1e-6 + + +@PROPERTY +@given(raw=layouts()) +def test_mirror_gives_the_mirrored_answer(raw: dict[str, Any]) -> None: + assert_mirrored(run(raw, FAST), run(mirror(raw), FAST), EXACT) + + +def test_mirror_of_a_scene_whose_pad_ends_at_its_own_edges() -> None: + # The pad's own edges bound the passing stretch here (standing next to deck is allowed), so + # the best spot is exact and the mirror must match to rounding. + raw = scene((6.0, 9.0), [gas(-5.0), window(12.0)]) + a, b = run(raw, FAST), run(mirror(raw), FAST) + assert a["decision"] == PASS + assert_mirrored(a, b, 1e-6) + + +# Regression: the grid used to be anchored at the wall's left end, so with a clearance line +# bounding the pad the chosen start was 6.316667 and the mirrored one ended at -6.358333. +def test_mirror_is_exact_when_a_clearance_bounds_the_pad() -> None: + raw = shared_fixture() + raw["objects"] = [gas(2.3)] + a, b = run(raw), run(mirror(raw)) + assert a["decision"] == PASS + assert_mirrored(a, b, 1e-6) + + +# --- (d) monotonicity: more room never hurts, a stricter rule never helps + +MOVERS = {"gas": gas, "ac": ac, "pool": pool, "window": window, "elec_box": elec_box} + + +@st.composite +def cluttered(draw: st.DrawFn) -> dict[str, Any]: + """Every kind of clearance subject at a random place, with random errors.""" + err = ft(0.0, 0.5) + positions = {name: draw(ft(-14.0, 12.0)) for name in MOVERS} + errors = {name: draw(err) for name in MOVERS} + return { + "pad": draw(pads()), + "positions": positions, + "errors": errors, + "facing": draw(ft(3.0, 9.0)), + "headroom": draw(ft(5.0, 9.0)), + "measured_err": draw(err), + "wall_err": draw(ft(0.0, 0.3)), + "drive": draw(st.one_of(st.none(), ft(-15.0, 11.0))), + "s0": draw(ft(-16.0, 16.0)), + } + + +def build(c: dict[str, Any], **changes: Any) -> dict[str, Any]: + c = {**c, **changes} + objects = [MOVERS[n](c["positions"][n], c["errors"][n]) for n in MOVERS] + return scene( + c["pad"], + objects, + facing=c["facing"], + headroom=c["headroom"], + measured_err=c["measured_err"], + wall_err=c["wall_err"], + drive=c["drive"], + ) + + +def evaluate(raw: dict[str, Any], s0: float, loaded=FAST) -> dict[str, str]: + return {c.id: c.outcome for c in evaluate_start(parsed(raw, loaded), loaded, s0).checks} + + +def never_more_severe(a: dict[str, str], b: dict[str, str]) -> None: + """No check is more severe in `a` than in `b` (so no pass in b is a fail in a).""" + worse = {k: (b[k], a[k]) for k in b if SEVERITY[a[k]] > SEVERITY[b[k]]} + assert not worse, worse + + +@PROPERTY +@given(c=cluttered(), mover=st.sampled_from(sorted(MOVERS)), delta=ft(0.01, 10.0)) +def test_moving_an_object_away_never_hurts(c: dict[str, Any], mover: str, delta: float) -> None: + s0 = c["s0"] + width = {"gas": 1.0, "ac": 3.0, "pool": 8.0, "window": 3.0, "elec_box": 1.0}[mover] + at = c["positions"][mover] + away = 1.0 if at + width / 2 >= s0 + W / 2 else -1.0 + moved = {**c["positions"], mover: round(at + away * delta, 3)} + never_more_severe(evaluate(build(c, positions=moved), s0), evaluate(build(c), s0)) + + +@PROPERTY +@given( + c=cluttered(), + which=st.sampled_from(["facing", "headroom"]), + delta=ft(0.01, 5.0), +) +def test_more_facing_depth_or_headroom_never_hurts( + c: dict[str, Any], which: str, delta: float +) -> None: + s0 = c["s0"] + more = build(c, **{which: round(c[which] + delta, 3)}) + never_more_severe(evaluate(more, s0), evaluate(build(c), s0)) + + +RULE_KEYS = [ + ("clearances", "gas_ft"), + ("clearances", "ac_ft"), + ("clearances", "opening_ft"), + ("clearances", "drive_ft"), + ("clearances", "pool_ft"), + ("clearances", "wall_equipment_ft"), + ("facing", "min_ft"), + ("headroom", "min_ft"), + ("meter_working_space", "width_ft"), + ("meter_working_space", "depth_ft"), +] + + +@PROPERTY +@given(c=cluttered(), key=st.sampled_from(RULE_KEYS), delta=ft(0.01, 5.0)) +def test_a_stricter_rule_never_helps(c: dict[str, Any], key: tuple[str, str], delta: float) -> None: + section, name = key + base = getattr(getattr(FAST.rules, section), name).value + stricter = golden_rules( + **STEP, **{section: {name: {"value": base + delta, "source": "test: stricter"}}} + ) + raw, s0 = build(c), c["s0"] + # Same candidate, same scene; the scene is parsed again because its modelled outdoor area + # grows with the widest clearance. + never_more_severe(evaluate(raw, s0, FAST), evaluate(raw, s0, stricter)) From 44607e04e4f851944c47cb535c9c4b13bbad1c5b Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:36:53 -0500 Subject: [PATCH 08/52] server: publish the scene request body in /openapi.json The routes read the raw body so one endpoint can take JSON, a zip or a form, which left FastAPI's OpenAPI document without a request body. The verification harness discovers the upload formats from that document and refused to send anything. Both POST routes now declare application/json, application/zip and multipart/form-data (field `bundle`). Co-Authored-By: Claude Opus 5.5 (1M context) --- server/api.py | 36 ++++++++++++++++++++++++++++++++++-- server/tests/test_api.py | 9 +++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/server/api.py b/server/api.py index ea746f8b4..05a8b609f 100644 --- a/server/api.py +++ b/server/api.py @@ -326,14 +326,46 @@ def _solve(payload: bytes, is_zip: bool) -> tuple[Scene, dict[str, Any]]: return scene, solve(scene, LOADED) -@app.post("/v1/placements") +# The routes read the raw body so one endpoint can take JSON, a zip or a form, so FastAPI can't +# infer the request body; publish it explicitly for clients that read /openapi.json. +SCENE_BODY: dict[str, Any] = { + "requestBody": { + "required": True, + "description": "A scene: bare scene.json, a zip bundle (scene.json plus the images it " + "names), or a multipart form whose one field `bundle` holds either. See " + "schemas/scene.schema.json.", + "content": { + "application/json": {"schema": {"type": "object", "title": "scene.json"}}, + "application/zip": {"schema": {"type": "string", "format": "binary"}}, + "multipart/form-data": { + "schema": { + "type": "object", + "required": [_BUNDLE_FIELD], + "properties": {_BUNDLE_FIELD: {"type": "string", "format": "binary"}}, + } + }, + }, + } +} + + +@app.post( + "/v1/placements", + openapi_extra=SCENE_BODY, + responses={200: {"description": "The result (schemas/result.schema.json)"}}, +) async def placements(request: Request) -> JSONResponse: payload, is_zip = await _upload(request) _scene, result = await run_in_threadpool(_solve, payload, is_zip) return JSONResponse(result) -@app.post("/v1/placements/site-plan.svg") +@app.post( + "/v1/placements/site-plan.svg", + openapi_extra=SCENE_BODY, + response_class=Response, + responses={200: {"content": {"image/svg+xml": {}}, "description": "The site plan"}}, +) async def site_plan(request: Request) -> Response: payload, is_zip = await _upload(request) diff --git a/server/tests/test_api.py b/server/tests/test_api.py index 6ffe0c9d5..301cd0430 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -361,3 +361,12 @@ def test_solving_the_example_takes_under_a_second() -> None: started = time.perf_counter() solve(scene, api.LOADED) assert time.perf_counter() - started < 1.0 + + +def test_openapi_publishes_the_scene_request_body(client: TestClient) -> None: + # Clients (the verification harness, a future SDK) discover the upload formats here. + paths = client.get("/openapi.json").json()["paths"] + for path in ("/v1/placements", "/v1/placements/site-plan.svg"): + content = paths[path]["post"]["requestBody"]["content"] + assert set(content) == {"application/json", "application/zip", "multipart/form-data"} + assert content["multipart/form-data"]["schema"]["required"] == ["bundle"] From 58632a1156110c809fcd3da81e156e99bdc51b99 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:51:53 -0500 Subject: [PATCH 09/52] server: fix the independent review's findings Wrong passes: - An unexplored end counted the ground and wall past it as seen along a straight extension; everything within reach of that end now counts as unseen, so a corner just past the walk can't hide a door or gas. - A door or garage door drawn with a footprint off the wall dropped out of the route; only things the cable can go round or behind are exempt when they stand off the wall. - Facing and headroom measurements that reach the battery only within their edge error no longer give a clean pass. - Coverage radius now includes the footprint's own position error, and gas equipment needs the wall seen as well as the ground. Wrong rejects or crashes: - A gap between walls no longer than their combined error is UNSURE, not a route failure. - Collinear baseline points merge instead of becoming corners. - Start positions now include where each footprint corner crosses an object's clearance outline, including mid-edge on slanted pipes. - A meter tapped past its wall's end, NaN/infinite numbers and coordinates beyond 1e5 ft are refused with a SceneError. Also: detour heights add to the route error; route_length cites the line that decided it; sweep runs carry their segment and never cross a corner (additive schema field); starts past cable reach are one failing run instead of being evaluated; garage doors count as openings. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/rules.yaml | 5 +- server/scene.py | 93 +++++- server/schemas/result.schema.json | 5 +- server/solver.py | 258 +++++++++++----- server/tests/test_review_regressions.py | 371 ++++++++++++++++++++++++ 5 files changed, 648 insertions(+), 84 deletions(-) create mode 100644 server/tests/test_review_regressions.py diff --git a/server/rules.yaml b/server/rules.yaml index 4655e8298..e5e363cb3 100644 --- a/server/rules.yaml +++ b/server/rules.yaml @@ -44,12 +44,13 @@ clearances: ac_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries"} opening_ft: {value: 3.0, source: "IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling"} drive_ft: {value: 5.0, source: "No public value (docs/01 rules table: TBD)", placeholder: true} - pool_ft: {value: 10.0, source: "No public value (docs/01 rules table: TBD)", placeholder: true} + pool_ft: {value: 10.0, source: "No public value (docs/01 rules table: TBD); the demo ring drawn in docs/eli5.html", placeholder: true} # Wall-mounted equipment directly above the battery: minimum sideways gap along the wall. wall_equipment_ft: {value: 0.0, source: "No public value; 0 means only overlap fails", placeholder: true} openings: - types: [door, window] + # A garage door counts: a battery must not stand in front of one. + types: [door, garage_door, window] # When true, a window recorded as not operable does not count. Unknown operability always counts. exempt_fixed_windows: false # A window whose bottom edge is higher than this does not count (upper floors). null: all count. diff --git a/server/scene.py b/server/scene.py index 1db5877d3..43d4dac66 100644 --- a/server/scene.py +++ b/server/scene.py @@ -245,14 +245,26 @@ def unobserved_ground(self) -> Geometry: for a, b, out in self.observed.get("ground", []) ] ) - self._cache["ground"] = outdoor.difference(seen.buffer(1e-6)) + unseen = outdoor.difference(seen.buffer(1e-6)) + self._cache["ground"] = unary_union([unseen, *self._unexplored_discs()]) return self._cache["ground"] + def _unexplored_discs(self) -> list[Geometry]: + """Round an unexplored end the walls may turn any way, so the extension line past it + proves nothing: everything within reach of that end counts as unseen.""" + ends = {"left": self.walls[0].a, "right": self.walls[-1].b} + return [ + Point(ends[side]).buffer(self.reach_ft) + for side, kind in self.end_kinds.items() + if kind == "unexplored" + ] + def unobserved_wall(self) -> Geometry: if "wall" not in self._cache: lo, hi = self.pieces[0].s0, self.pieces[-1].s1 gaps = subtract_intervals((lo, hi), self.observed_intervals("wall")) - self._cache["wall"] = unary_union([self.wall_line(a, b) for a, b in gaps]) + lines = [self.wall_line(a, b) for a, b in gaps] + self._cache["wall"] = unary_union([*lines, *self._unexplored_discs()]) return self._cache["wall"] @@ -300,6 +312,29 @@ def _wedge(v: Point2, n1: Point2, n2: Point2, radius: float) -> Polygon: return poly if poly.is_valid and poly.area > EPS else Polygon() +# Baseline points closer than this to the line through their neighbours are one straight wall. +COLLINEAR_FT = 0.05 + + +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 + straddle.""" + out = [pts[0]] + for i in range(1, len(pts) - 1): + a, p, b = out[-1], pts[i], pts[i + 1] + ab = _sub(b, a) + length = _norm(ab) + if length < 1e-6: + raise SceneError(f"{path}/{i + 1}", "repeats an earlier point") + t_along = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / (length * length) + off = abs((p[0] - a[0]) * ab[1] - (p[1] - a[1]) * ab[0]) / length + if not (0 < t_along < 1 and off <= tol): + out.append(p) + out.append(pts[-1]) + return out + + def _outward(along: Point2) -> Point2: # Points run left to right seen from outside, so outward is `along` turned clockwise viewed # from above: (ux, uz) -> (-uz, ux). @@ -332,7 +367,29 @@ def _error(item: dict[str, Any], default: float) -> float: return float(item["plus_minus_ft"]) if "plus_minus_ft" in item else default +# Coordinates beyond this are not a house scan; they would only exhaust memory in the sweep. +MAX_COORDINATE_FT = 1e5 + + +def _check_numbers(value: Any, path: str) -> None: + """JSON Schema accepts NaN and infinities as numbers; nothing in a scene may be either.""" + if isinstance(value, dict): + for k, v in value.items(): + _check_numbers(v, f"{path}/{k}") + elif isinstance(value, list): + for i, v in enumerate(value): + _check_numbers(v, f"{path}/{i}") + elif isinstance(value, float | int) and not isinstance(value, bool): + if not math.isfinite(value): + raise SceneError(path or "/", f"{value!r} is not a finite number") + if abs(value) > MAX_COORDINATE_FT: + raise SceneError( + path or "/", f"{value} ft is beyond the {MAX_COORDINATE_FT:g} ft bound" + ) + + def validate_schema(raw: Any) -> None: + _check_numbers(raw, "") errors = sorted(_VALIDATOR.iter_errors(raw), key=lambda e: list(e.absolute_path)) if errors: e = errors[0] @@ -358,6 +415,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N wall_ids: set[str] = set() s = 0.0 prev_end: Point2 | None = None + prev_err = 0.0 for wi, wall in enumerate(raw["walls"]): wid = wall["id"] if wid in wall_ids: @@ -365,6 +423,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N wall_ids.add(wid) pts = [_xz(p, f"/walls/{wi}/baseline") for p in wall["baseline"]] wall_err = _error(wall, errors.wall_ft.value) + pts = _merge_collinear(pts, max(wall_err, COLLINEAR_FT), f"/walls/{wi}/baseline") if prev_end is not None: gap = _norm(_sub(pts[0], prev_end)) if gap > join_tol: @@ -372,8 +431,21 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N (pts[0][0] - prev_end[0]) / gap, (pts[0][1] - prev_end[1]) / gap, ) + # The gap's length is only known to within both walls' errors. + gap_err = prev_err + wall_err pieces.append( - Piece("gap", None, -1, prev_end, pts[0], s, s + gap, along, _outward(along), 0) + Piece( + "gap", + None, + -1, + prev_end, + pts[0], + s, + s + gap, + along, + _outward(along), + gap_err, + ) ) s += gap for i in range(len(pts) - 1): @@ -396,7 +468,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N ) ) s += length - prev_end = pts[-1] + prev_end, prev_err = pts[-1], wall_err # Place the meter: s = 0 at its projection onto its own wall. meter = raw["meter"] @@ -404,7 +476,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N raise SceneError("/meter/wall_id", f"no wall with id {meter['wall_id']!r}") mpos = tuple(float(v) for v in meter["pos"]) mxz = (mpos[0], mpos[2]) - best: tuple[float, Piece, float] | None = None + best: tuple[float, Piece, float, float] | None = None for p in pieces: if p.wall_id != meter["wall_id"]: continue @@ -412,8 +484,17 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N s_clamped = min(max(s_local, p.s0), p.s1) dist = _norm(_sub(mxz, p.point(s_clamped))) if best is None or dist < best[0]: - best = (dist, p, s_clamped) + best = (dist, p, s_clamped, abs(s_local - s_clamped)) assert best is not None + meter_err = _error(meter, errors.meter_ft.value) + overshoot = best[3] + if overshoot > meter_err + best[1].plus_minus + EPS: + # Clamping would silently shorten every cable route by the overshoot. + raise SceneError( + "/meter/pos", + f"the meter is {overshoot:.2f} ft past the end of wall {meter['wall_id']!r}, more " + "than the meter's and the wall's errors allow; extend the wall to the meter", + ) max_off = rules.sweep.meter_to_wall_max_ft.value if best[0] > max_off: raise SceneError( diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index ee47623c5..c1b9dceff 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -35,7 +35,7 @@ "properties": { "id": {"type": ["string", "null"]}, "version": {"type": ["string", "null"]}, - "auto_approve": {"type": "boolean", "description": "False while the rules hold placeholder values or no policy is selected; every would-be pass or reject then becomes manual_review."}, + "auto_approve": {"type": "boolean", "description": "Whether the rules allow automatic decisions: false when no policy is selected or the rules file sets auto_approve false (the public rules.yaml does, because it holds placeholder values). When false, every would-be pass or reject becomes manual_review. Each check's rule.placeholder says which values decided it."}, "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."} } @@ -92,7 +92,8 @@ "required": ["wall_id", "start_ft", "outcome", "failing", "unsure"], "properties": { "wall_id": {"type": "string"}, - "start_ft": {"$ref": "#/$defs/span", "description": "Range of battery start positions (left edge, in s) in this run."}, + "segment": {"type": "integer", "minimum": 0, "description": "Straight baseline segment of `wall_id`; runs never cross a corner."}, + "start_ft": {"$ref": "#/$defs/span", "description": "Range of battery start positions (left edge, in s) in this run. Stretches too far along the wall for any cable route are one failing run, not evaluated start by start."}, "outcome": {"$ref": "#/$defs/outcome"}, "failing": {"type": "array", "items": {"type": "string"}}, "unsure": {"type": "array", "items": {"type": "string"}} diff --git a/server/solver.py b/server/solver.py index 23cb8d72d..576b84e3a 100644 --- a/server/solver.py +++ b/server/solver.py @@ -18,7 +18,7 @@ from functools import partial from typing import Any -from shapely import Geometry, Polygon, unary_union +from shapely import Geometry, LineString, Polygon, unary_union from rules import LoadedRules, Rules, Value from scene import EPS, Piece, Scene, SceneObject, merge_intervals @@ -182,17 +182,19 @@ def footprint(self, piece: Piece, s0: float) -> Polygon: return piece.rect(s0, s0 + self.W, 0.0, self.D) def _covered(self, fp: Polygon, unobserved: Geometry, radius: float) -> bool: - """True when nothing unobserved lies within `radius` of the footprint.""" + """True when nothing unobserved lies within `radius` of the footprint. Unseen areas count + by area (touching along an edge is not overlap) and unseen wall lines by length.""" if unobserved.is_empty: return True if fp.distance(unobserved) > radius + _MEASURE_EPS: return True near = unobserved.intersection(fp.buffer(radius) if radius > 0 else fp) - return ( - near.area <= _MEASURE_EPS - if near.geom_type.endswith("Polygon") - else (near.length <= _MEASURE_EPS) - ) + for part in getattr(near, "geoms", [near]): + if part.geom_type.endswith("Polygon") and part.area > _MEASURE_EPS: + return False + if part.geom_type.endswith("LineString") and part.length > _MEASURE_EPS: + return False + return True # --- checks -------------------------------------------------------------------------------- @@ -239,9 +241,9 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: c.outcome, c.subject = FAIL, f"ground[{g.index}] {g.type}" c.reason = f"The footprint stands on {g.type}, which is not an allowed surface." return c - if not self._covered(fp, self.unobserved_ground, 0.0): + if not self._covered(fp, self.unobserved_ground, ew): c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing_later = partial(self._missing_ground, fp, 0.0) + c.missing_later = partial(self._missing_ground, fp, ew) c.reason = "The ground under the footprint was not seen." return c # A patch edge's error matters only where an allowed surface meets a disallowed or @@ -276,6 +278,9 @@ def _missing_ground(self, fp: Polygon, radius: float) -> list[tuple[str, float, extent = self.scene.s_extent(region) return [("ground", *extent)] if extent else [] + def _unobserved(self, band: str) -> Geometry: + return self.unobserved_ground if band == "ground" else self.unobserved_wall + def _missing_band( self, band: str, fp: Polygon, radius: float ) -> list[tuple[str, float, float]]: @@ -322,8 +327,13 @@ def check_clearance( f"battery; the rule needs more than {ft(t)}." ) return c - unobserved = self.unobserved_ground if band == "ground" else self.unobserved_wall - covered = self._covered(fp, unobserved, t) + # The footprint itself is only placed to within the wall's error, so the area that must + # have been seen reaches that much further. + radius = t + piece.plus_minus + bands = ["ground", "wall"] if band == "ground+wall" else [band] + unseen = [b for b in bands if not self._covered(fp, self._unobserved(b), radius)] + covered = not unseen + band = unseen[0] if unseen else bands[0] if c.outcome == UNSURE: if c.unsure_cause == "unknown_attribute": c.reason = ( @@ -336,11 +346,11 @@ def check_clearance( f"battery against a {ft(t)} rule: too close to call." ) if not covered: - c.missing_later = partial(self._missing_band, band, fp, t) + c.missing_later = partial(self._missing_band, band, fp, radius) return c if not covered: c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing_later = partial(self._missing_band, band, fp, t) + c.missing_later = partial(self._missing_band, band, fp, radius) c.reason = ( f"Not everything within {ft(t)} of the battery was seen, so a {noun} could " "hide there." @@ -453,17 +463,29 @@ def check_measured( s1: float, subtract: float, noun: str, + wall_error: float, ) -> Check: """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") - over = [m for m in entries if m.span[0] < s1 - EPS and m.span[1] > s0 + EPS] missing = self.scene.missing(band, s0, s1) - if over: - m = min(over, key=lambda m: (m.value - m.plus_minus, m.index)) - c.measured, c.plus_minus = m.value - subtract, m.plus_minus - c.subject = f"{'overheads' if band == 'overhead' else 'facing'}[{m.index}]" - c.outcome = at_least(c.measured, c.plus_minus, t) + worst_key: tuple[int, float] | None = None + for m in entries: + # A measurement's stretch is itself only known to within its and the wall's error: + # one that reaches the battery only inside that band can't give a clean pass, but + # isn't a clear failure either. + edge = m.plus_minus + wall_error + overlap = min(s1, m.span[1]) - max(s0, m.span[0]) + if overlap <= -edge + EPS or (edge == 0 and overlap <= EPS): + continue + outcome = at_least(m.value - subtract, m.plus_minus, t) + if overlap <= edge + EPS and outcome == FAIL: + outcome = UNSURE + key = (_SEVERITY[outcome], -(m.value - m.plus_minus)) + if worst_key is None or key > worst_key: + worst_key = key + c.outcome, c.measured, c.plus_minus = outcome, m.value - subtract, m.plus_minus + c.subject = f"{'overheads' if band == 'overhead' else 'facing'}[{m.index}]" val = f"{ft(c.measured or 0)} (± {ft(c.plus_minus or 0)})" if c.outcome == FAIL: c.reason = f"The {noun} is {val}; the rule needs more than {ft(t)}." @@ -490,26 +512,34 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C detours: list[dict[str, Any]] = [] effects: list[str] = [] unknown: list[str] = [] + small_gaps: list[str] = [] for gap in self.scene.gaps: if min(hi, gap.s1) - max(lo, gap.s0) > EPS: + # A gap no longer than the two walls' errors may not be a gap at all. + clear = (gap.s1 - gap.s0) - gap.plus_minus > EPS crossings.append( { "subject": "stretch with no wall", "span_ft": [gap.s0, gap.s1], - "effect": "fail", + "effect": "fail" if clear else "review", } ) - effects.append("fail") + if clear: + effects.append("fail") + else: + 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 for o in self.route_objects: - if min(hi, o.span[1]) - max(lo, o.span[0]) <= EPS: - continue - # Only something that touches the wall the cable runs along is in its way; a pipe - # or unit standing off the wall is not (the cable passes behind it). - if path_line is None or o.geom.distance(path_line) > o.plus_minus + _MEASURE_EPS: + if path_line is None or min(hi, o.span[1]) - max(lo, o.span[0]) <= EPS: continue effect = r.crossing[o.type] # type: ignore[index] + # Something the cable can go round or behind is only in its way when it touches the + # wall the cable runs along; a door or garage blocks it wherever it is drawn. + standoff = o.geom.distance(path_line) - o.plus_minus - piece.plus_minus + if effect in ("detour", "allow") and standoff > _MEASURE_EPS: + continue crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": effect}) effects.append(effect) if effect == "detour": @@ -517,6 +547,10 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C if o.top is None: unknown.append(o.label) continue + # Heights carry the object's error; a detour that may or may not be needed, or + # whose size is uncertain, widens the route's error by the round trip. + if bottom - o.plus_minus <= h + EPS and o.top + o.plus_minus >= h - EPS: + e += 2 * o.plus_minus if bottom <= h + EPS and o.top >= h - EPS: options = [2 * (o.top - h)] if bottom > EPS: @@ -532,7 +566,6 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C length = ( (hi - lo) + corners * r.corner_allowance_ft.value + sum(d["extra_ft"] for d in detours) ) - e = self.scene.meter_plus_minus + piece.plus_minus missing = self.scene.missing("wall", lo, hi) path = Check( @@ -557,6 +590,13 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C path.reason = ( f"The cable would route past {path.subject}, which the policy sends to a person." ) + elif small_gaps: + path.outcome, path.unsure_cause = UNSURE, "margin" + path.subject = "stretch with no wall" + path.reason = ( + f"The cable would cross {small_gaps[0]}, no longer than the walls' own " + "measurement error: it may be one continuous wall." + ) elif unknown: path.outcome, path.unsure_cause = UNSURE, "unknown_attribute" path.subject = unknown[0] @@ -570,19 +610,27 @@ 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) + # Cite the line that decided: the maximum when the run fails or is within error of it, + # otherwise the confident reach. + by_max = outcome == FAIL or length + e >= r.max_ft.value - EPS + rule_key, rule = ( + ("route.max_ft", r.max_ft) + if by_max + else ("route.confident_reach_ft", r.confident_reach_ft) + ) reach = Check( "route_length", "Cable run length", - PASS, + outcome, "", - "route.max_ft", - r.max_ft, + rule_key, + rule, measured=length, plus_minus=e, - threshold=r.max_ft.value, + threshold=rule.value, comparison="at_most", ) - reach.outcome = reach_outcome(length, e, r.confident_reach_ft.value, r.max_ft.value) run = f"{ft(length)} (± {ft(e)})" confident = r.confident_reach_ft.value if reach.outcome == FAIL: @@ -625,7 +673,8 @@ def evaluate(self, piece: Piece, s0: float) -> Candidate: piece, fp, [(o.label, o.geom, o.plus_minus, True) for o in self.gas], - "ground", + # Gas meters hang on the wall as well as standing on the ground. + "ground+wall", "gas meter or pipe", ), self.check_clearance( @@ -689,6 +738,7 @@ def evaluate(self, 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, ), self.check_measured( "headroom", @@ -701,19 +751,30 @@ def evaluate(self, piece: Piece, s0: float) -> Candidate: s1, 0.0, "headroom", + piece.plus_minus, ), ] route, path, reach = self.route_for(piece, s0, s1) checks += [path, reach] return Candidate(piece, s0, s1, fp, checks, route, worst([x.outcome for x in checks])) + 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_max = self.scene.meter_plus_minus + piece.plus_minus + detour_err + return self.r.route.max_ft.value + e_max + 2 * EPS + def starts(self, piece: Piece) -> list[float]: - lo, hi = piece.s0, piece.s1 - self.W + """Start positions (left edge, in s) to evaluate on one straight segment.""" + W, D = self.W, self.D + limit = self.reach_limit(piece) + lo, hi = max(piece.s0, -limit - W), min(piece.s1 - W, limit) if hi < lo - EPS: return [] hi = max(hi, lo) step = self.r.sweep.step_ft.value - W, D = self.W, self.D # The grid is anchored at the meter, in left-edge and right-edge form (starts k * step # and k * step - W), so the start positions of a scene and of its mirror image map onto # each other and left and right get the same treatment. @@ -733,51 +794,62 @@ def starts(self, piece: Piece) -> list[float]: for g in self.scene.gaps: boundaries += [g.s0, g.s1] offsets = {0.0} - for o in self.equipment: - offsets |= {o.plus_minus, -o.plus_minus} + for m in [*self.equipment, *self.scene.overheads, *self.scene.facing]: + for e in (m.plus_minus, m.plus_minus + piece.plus_minus): + offsets |= {e, -e} for b in boundaries: for off in offsets: points += [b + off, b - W - off] rt = self.r.route e_route = self.scene.meter_plus_minus + piece.plus_minus - for limit in (rt.confident_reach_ft.value, rt.max_ft.value): - for x in (limit - e_route, limit + e_route, limit): + for line in (rt.confident_reach_ft.value, rt.max_ft.value): + for x in (line - e_route, line + e_route, line): points += [x, -x - W] - # Plan clearances: the start where the footprint is exactly the rule's distance from - # each vertex of each object (and within error of it). - c = self.r.clearances - rules_by_items = [ - (c.gas_ft.value, [(p, o.plus_minus) for o in self.gas for p in o.points]), - (c.ac_ft.value, [(p, o.plus_minus) for o in self.ac for p in o.points]), - (c.pool_ft.value, [(p, o.plus_minus) for o in self.pool for p in o.points]), - (c.opening_ft.value, [(p, o.plus_minus) for o in self.openings for p in o.points]), - ( - c.drive_ft.value, - [(p, g.plus_minus) for g in self.drives for p in g.polygon.exterior.coords], - ), - ] - for g in self.scene.ground: - for p in g.polygon.exterior.coords: - u, _ = piece.local(p) - for off in ( - 0.0, - g.plus_minus + piece.plus_minus, - -(g.plus_minus + piece.plus_minus), - ): - points += [u + off, u - W + off] - for t, items in rules_by_items: - for p, err in items: - u, v = piece.local(p) - dv = max(0.0, v - D) if v >= 0 else -v - for tt in (t, t + err + piece.plus_minus, t - err - piece.plus_minus): - if tt > dv: - du = math.sqrt(tt * tt - dv * dv) - points += [u + du, u - W - du] + # 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)] + for geom, dist in self._clearance_edges(piece): + boundary = geom.buffer(dist).boundary if dist != 0 else geom.boundary + for track in tracks: + if track.distance(boundary) > EPS: + continue + for x, z in _coords_of(track.intersection(boundary)): + u = piece.local((x, z))[0] + points += [u, u - W] pts = sorted({round(p, 9) for p in points if lo - EPS <= p <= hi + EPS}) pts = [min(max(p, lo), hi) for p in pts] 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.""" + c = self.r.clearances + ew = piece.plus_minus + out: list[tuple[Geometry, float]] = [] + for t, objs in ( + (c.gas_ft.value, self.gas), + (c.ac_ft.value, self.ac), + (c.pool_ft.value, self.pool), + (c.opening_ft.value, self.openings), + ): + for o in objs: + for d in (t, t + o.plus_minus + ew, t - o.plus_minus - ew): + if d > 0: + out.append((o.geom, d)) + for g in self.drives: + for d in ( + c.drive_ft.value, + c.drive_ft.value + g.plus_minus + ew, + c.drive_ft.value - g.plus_minus - ew, + ): + if d > 0: + out.append((g.polygon, d)) + for g in self.scene.ground: + for d in (0.0, g.plus_minus + ew, -(g.plus_minus + ew)): + out.append((g.polygon, d)) + return out + def candidates(self) -> list[Candidate]: out = [] for piece in self.scene.walls: @@ -785,6 +857,37 @@ def candidates(self) -> list[Candidate]: out.append(self.evaluate(piece, s0)) return out + def out_of_reach(self) -> list[dict[str, Any]]: + """Sweep runs for stretches of wall too far along for any route to pass, which are not + evaluated start by start.""" + runs = [] + for piece in self.scene.walls: + limit = self.reach_limit(piece) + lo, hi = piece.s0, piece.s1 - self.W + if hi < lo - EPS: + continue + for a, b in ((lo, min(hi, -limit - self.W)), (max(lo, limit), hi)): + if b - a > EPS: + runs.append( + { + "wall_id": piece.wall_id, + "segment": piece.index, + "start_ft": [_round(a), _round(b)], + "outcome": FAIL, + "failing": ["route_length"], + "unsure": [], + } + ) + return runs + + +def _coords_of(geom: Geometry) -> list[tuple[float, float]]: + out: list[tuple[float, float]] = [] + for part in getattr(geom, "geoms", [geom]): + if not part.is_empty and hasattr(part, "coords"): + out += [(x, z) for x, z in part.coords] + return out + def evaluate_start( scene: Scene, loaded: LoadedRules, s0: float, wall_id: str | None = None @@ -884,7 +987,7 @@ def _missing_json(c: Candidate) -> list[dict[str, Any]]: def _sweep_json(cands: list[Candidate]) -> list[dict[str, Any]]: runs: list[dict[str, Any]] = [] for c in sorted(cands, key=lambda c: c.s0): - key = (c.piece.wall_id, c.outcome, sorted(c.failing()), sorted(c.unsure())) + key = (c.piece.wall_id, c.piece.index, c.outcome, sorted(c.failing()), sorted(c.unsure())) if runs and runs[-1]["_key"] == key: runs[-1]["start_ft"][1] = _round(c.s0) else: @@ -892,10 +995,11 @@ def _sweep_json(cands: list[Candidate]) -> list[dict[str, Any]]: { "_key": key, "wall_id": c.piece.wall_id, + "segment": c.piece.index, "start_ft": [_round(c.s0), _round(c.s0)], "outcome": c.outcome, - "failing": key[2], - "unsure": key[3], + "failing": key[3], + "unsure": key[4], } ) for run in runs: @@ -909,6 +1013,7 @@ def solve(scene: Scene, loaded: LoadedRules) -> dict[str, Any]: solver = Solver(scene, loaded) r = loaded.rules cands = solver.candidates() + far = solver.out_of_reach() passes = [c for c in cands if c.outcome == PASS] unsures = [c for c in cands if c.outcome == UNSURE] fails = [c for c in cands if c.outcome == FAIL] @@ -1019,7 +1124,10 @@ def past_end_requests() -> list[dict[str, Any]]: for i in c.failing(): fail_counts[i] = fail_counts.get(i, 0) + 1 top = sorted(fail_counts, key=lambda i: -fail_counts[i]) - if cands: + if cands or far: + if far: + fail_counts["route_length"] = fail_counts.get("route_length", 0) + len(far) + top = sorted(fail_counts, key=lambda i: -fail_counts[i]) reasons.append( { "code": "all_spots_fail", @@ -1079,7 +1187,9 @@ def past_end_requests() -> list[dict[str, Any]]: "nearest_considered": _spot_json(solver, nearest) if nearest else None, "missing_evidence": missing, "ends": ends, - "sweep": _sweep_json(cands), + "sweep": sorted( + _sweep_json(cands) + far, key=lambda run: (run["start_ft"][0], run["segment"]) + ), "stats": { "candidates": len(cands), "pass": len(passes), diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py new file mode 100644 index 000000000..3fe8ea446 --- /dev/null +++ b/server/tests/test_review_regressions.py @@ -0,0 +1,371 @@ +"""Regressions from the solver review. Synthetic scenes built on the shared fixture in helpers.py. + +Thresholds are the golden test settings (GOLDEN_RULES), not Base policy: facing 5 ft from the +wall, headroom 6.5, clearances 3, confident reach 15, maximum 20, every default error 0. +""" + +import math +import time + +import pytest +from helpers import ( + check, + everything_observed, + golden_rules, + pads_ground, + parsed, + rect, + run, + shared_fixture, +) + +from scene import SceneError +from solver import evaluate_start + +W = 31 / 12 # battery width (31 in) +D = 11 / 6 # battery depth (22 in) + + +def at_start(raw, s0, check_id, rules=None): + candidate = evaluate_start(parsed(raw, rules), rules or golden_rules(), s0) + return next(c for c in candidate.checks if c.id == check_id) + + +def observed_band(raw, band, spans, out=30): + """Replace one coverage band with the given observed spans.""" + extra = {"out_ft": out} if band == "ground" else {} + raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != band] + [ + {"band": band, "span_ft": list(s), **extra} for s in spans + ] + + +# --- 1. unexplored end ---------------------------------------------------------------------------- + + +def test_unexplored_end_next_to_spot_is_not_a_pass() -> None: + # Before: the area past an unexplored end counted as seen, so a spot 0.02 ft from it passed. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [10, 0]] + raw["ground"] = pads_ground([(7.4, 30)], hi=30) + raw["coverage"] = everything_observed(-40, 30) + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = run(raw) + assert result["decision"] != "pass" + assert any( + c["outcome"] == "unsure" and c.get("unsure_cause") == "unobserved" for c in result["checks"] + ) + + +def test_limit_end_with_wall_beyond_spot_still_passes() -> None: + # Control for the test above: the same spot on a wall that continues and ends at a real limit. + raw = shared_fixture() + raw["ground"] = pads_ground([(7.4, 10)]) + assert run(raw)["decision"] == "pass" + + +# --- 2. doors with a footprint ------------------------------------------------------------------ + + +def test_garage_door_with_footprint_still_blocks_route() -> None: + # Before: a door given a footprint 0.35 ft off the wall was treated as standing off it. + raw = shared_fixture() + raw["ground"] = pads_ground([(10, 13)]) + raw["objects"].append( + { + "type": "garage_door", + "wall_id": "w1", + "span_ft": [3, 7], + "bottom_ft": 0, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0.3, + "footprint": [[3, 0.35], [7, 0.35]], + } + ) + assert run(raw)["decision"] == "reject" + assert at_start(raw, 10.0, "route_path").outcome == "fail" + + +# --- 3. measurement span edges carry their error ------------------------------------------------ + + +def test_headroom_span_edge_error_counts() -> None: + # Before: a 5 ft overhead ending at s = 9 (± 0.5) was ignored for a battery starting at 9. + raw = shared_fixture() + raw["ground"] = pads_ground([(9, 12)]) + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [-40, 9], "clearance_ft": 5, "plus_minus_ft": 0.5}, + {"wall_id": "w1", "span_ft": [9, 40], "clearance_ft": 9, "plus_minus_ft": 0.5}, + ] + assert run(raw)["decision"] != "pass" + assert at_start(raw, 9.0, "headroom").outcome in {"unsure", "fail"} + assert at_start(raw, 9.6, "headroom").outcome == "pass" + + +def test_facing_span_edge_error_counts() -> None: + # Before: a 1 ft facing gap ending at s = 9 (± 0.5) was ignored for a battery starting at 9. + raw = shared_fixture() + raw["ground"] = pads_ground([(9, 12)]) + raw["facing"] = [ + {"wall_id": "w1", "span_ft": [-40, 9], "depth_ft": 1, "plus_minus_ft": 0.5}, + {"wall_id": "w1", "span_ft": [9, 40], "depth_ft": 9, "plus_minus_ft": 0.5}, + ] + assert at_start(raw, 9.0, "facing_gap").outcome != "pass" + assert at_start(raw, 9.6, "facing_gap").outcome == "pass" + + +# --- 4. meter past its wall's end ---------------------------------------------------------------- + + +def test_meter_past_wall_end_is_refused() -> None: + # Before: the meter was clamped to the wall end, silently shortening every route by 2.5 ft. + raw = shared_fixture() + raw["meter"]["pos"] = [42.5, 5, 0] + with pytest.raises(SceneError, match="past"): + parsed(raw) + + +def test_meter_past_wall_end_within_error_is_accepted() -> None: + raw = shared_fixture() + raw["meter"]["pos"] = [40.2, 5, 0] + raw["meter"]["plus_minus_ft"] = 0.3 + parsed(raw) + + +# --- 5. coverage radius ------------------------------------------------------------------------- + + +def test_coverage_radius_includes_wall_error() -> None: + # Before: coverage was checked to exactly 3 ft, though the wall itself is only known to ± 0.3. + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.3 + edge = 6 + W + 3.05 + observed_band(raw, "wall", [(-40, edge)]) + observed_band(raw, "ground", [(-40, edge)]) + for check_id in ("gas_clearance", "ac_clearance", "opening_clearance"): + c = at_start(raw, 6.0, check_id) + assert (c.outcome, c.unsure_cause) == ("unsure", "unobserved"), check_id + + +# --- 6. detour error ---------------------------------------------------------------------------- + + +def test_detour_error_flows_into_route_error() -> None: + # Before: a detour around a ± 1.5 ft photo detection added length but no error to the route. + rules = golden_rules(errors={"vlm_ft": {"value": 1.5, "source": "t"}}) + raw = shared_fixture() + raw["ground"] = pads_ground([(13, 16)]) + raw["objects"].append( + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [2, 3], + "bottom_ft": 0.5, + "top_ft": 2, + "source": "vlm", + } + ) + result = run(raw, rules) + assert result["route"]["plus_minus_ft"] >= 2 * 1.5 + assert result["decision"] == "manual_review" + + +# --- 7. gaps between walls ---------------------------------------------------------------------- + + +def two_wall_scene(w2_start, w2_end, pad): + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [3, 0]], "plus_minus_ft": 0.5}, + {"id": "w2", "baseline": [[w2_start, 0], [w2_end, 0]], "plus_minus_ft": 0.5}, + ] + raw["ground"] = pads_ground([pad], hi=w2_end) + raw["facing"] = [ + {"wall_id": "w1", "span_ft": [-40, 3], "depth_ft": 9, "plus_minus_ft": 0}, + {"wall_id": "w2", "span_ft": [w2_start, w2_end], "depth_ft": 9, "plus_minus_ft": 0}, + ] + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [-40, 3], "clearance_ft": 9, "plus_minus_ft": 0}, + {"wall_id": "w2", "span_ft": [w2_start, w2_end], "clearance_ft": 9, "plus_minus_ft": 0}, + ] + raw["coverage"] = everything_observed(-40, w2_end) + return raw + + +def test_gap_within_wall_error_is_manual_review() -> None: + # Before: a 0.7 ft gap between walls each ± 0.5 rejected, though the walls may meet. + raw = two_wall_scene(3.7, 40.7, (9.5, 13.5)) + result = run(raw) + assert result["decision"] == "manual_review" + assert check(result, "route_path")["outcome"] == "unsure" + + +def test_gap_beyond_wall_error_rejects() -> None: + raw = two_wall_scene(5, 40, (11, 15)) + assert run(raw)["decision"] == "reject" + assert at_start(raw, 11.0, "route_path").outcome == "fail" + + +# --- 8. collinear baseline points --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "baseline", + [ + [[-40, 0], [7.5, 0], [40, 0]], + [[x, 0] for x in range(-40, 41, 2)], + ], + ids=["one-mid-point", "every-2-ft"], +) +def test_collinear_baseline_points_are_not_corners(baseline) -> None: + # Before: a point in the middle of a straight wall split it, failing backing across the point. + raw = shared_fixture() + raw["walls"][0]["baseline"] = baseline + result = run(raw) + assert result["decision"] == "pass" + assert result["spot"]["span_ft"] == pytest.approx([6, 6 + W], abs=1e-6) + + +# --- 9. starts from object edges ---------------------------------------------------------------- + + +def test_start_window_between_slanted_pipes_is_found() -> None: + # Before: only grid starts were tried, so a 0.025 ft pass window between two pipes was missed. + rules = golden_rules( + route={ + "confident_reach_ft": {"value": 30, "source": "t"}, + "max_ft": {"value": 40, "source": "t"}, + } + ) + lo, hi = 14.005, 14.03 + c_right = hi + W + 3 * math.sqrt(2) + c_left = lo - 3 * math.sqrt(2) + raw = shared_fixture() + raw["ground"] = pads_ground([(8, 17)]) + for c, pipe in ( + (c_right, [[c_right - 10, -10], [c_right + 30, 30]]), + (c_left, [[c_left + 10, -10], [c_left - 30, 30]]), + ): + raw["objects"].append( + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [c, c], + "bottom_ft": 3, + "top_ft": 4, + "source": "tap", + "plus_minus_ft": 0, + "footprint": pipe, + } + ) + assert evaluate_start(parsed(raw, rules), rules, (lo + hi) / 2).outcome == "pass" + result = run(raw, rules) + assert result["decision"] == "pass" + assert lo < result["spot"]["span_ft"][0] < hi + + +# --- 10. sweep runs at corners ------------------------------------------------------------------ + + +def test_sweep_runs_split_at_corners() -> None: + # Before: pass runs on both segments of one wall merged into a run spanning the corner. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [8, 0], [8, -32]] + raw["ground"] = [ + {"type": "lawn", "polygon": rect(-40, 8, 0, 30), "plus_minus_ft": 0}, + {"type": "lawn", "polygon": rect(8, 40, -40, 30), "plus_minus_ft": 0}, + ] + result = run(raw) + passing = [r for r in result["sweep"] if r["outcome"] == "pass"] + assert passing + assert all("segment" in r for r in passing) + assert not [r for r in passing if r["start_ft"][0] < 8 - W and r["start_ft"][1] > 8 - W + 1e-6] + assert at_start(raw, 7.0, "wall_backing").outcome == "fail" + + +# --- 11. non-finite and absurd numbers ---------------------------------------------------------- + + +def _set_meter_nan(raw): + raw["meter"]["pos"][0] = float("nan") + + +def _set_baseline_inf(raw): + raw["walls"][0]["baseline"][1] = [float("inf"), 0] + + +def _set_coverage_nan(raw): + raw["coverage"]["observed"][0]["span_ft"] = [-40, float("nan")] + + +def _set_baseline_huge(raw): + raw["walls"][0]["baseline"][1] = [2e5, 0] + + +@pytest.mark.parametrize( + "mutate", + [_set_meter_nan, _set_baseline_inf, _set_coverage_nan, _set_baseline_huge], + ids=["nan-meter", "inf-baseline", "nan-coverage", "huge-baseline"], +) +def test_non_finite_or_huge_numbers_are_refused(mutate) -> None: + # Before: NaN and infinity passed the schema and crashed or poisoned the geometry. + raw = shared_fixture() + mutate(raw) + with pytest.raises(SceneError): + parsed(raw) + + +# --- 12. reach bound ---------------------------------------------------------------------------- + + +def test_long_wall_is_bounded_by_reach() -> None: + # Before: every start along a 2000 ft wall was fully evaluated, far past the cable's reach. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-1000, 0], [1000, 0]] + raw["ground"] = [{"type": "lawn", "polygon": rect(-1000, 1000, 0, 30), "plus_minus_ft": 0}] + raw["overheads"][0]["span_ft"] = [-1000, 1000] + raw["facing"][0]["span_ft"] = [-1000, 1000] + raw["coverage"] = everything_observed(-1000, 1000) + started = time.perf_counter() + result = run(raw) + assert time.perf_counter() - started < 1.5 + assert result["decision"] == "pass" + far = [ + r for r in result["sweep"] if r["outcome"] == "fail" and r["failing"] == ["route_length"] + ] + assert any(r["start_ft"][0] >= 20 for r in far) + assert any(r["start_ft"][1] <= -20 for r in far) + + +# --- 13. route length rule citation ------------------------------------------------------------- + + +def test_route_length_cites_confident_reach_on_pass() -> None: + # Before: a pass inside the confident reach cited the maximum as its rule. + result = run(shared_fixture()) + assert result["decision"] == "pass" + assert check(result, "route_length")["rule"]["key"] == "route.confident_reach_ft" + + +def test_route_length_cites_maximum_on_reject() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(21, 24)]) + assert run(raw)["decision"] == "reject" + # The result's nearest considered spot is a deck spot by the meter (it also fails only one + # check, with a shorter route), so check the length-rejected start on the pad directly. + reach = at_start(raw, 21.0, "route_length") + assert (reach.outcome, reach.rule_key) == ("fail", "route.max_ft") + + +# --- 14. gas clearance needs the wall ------------------------------------------------------------ + + +def test_gas_clearance_needs_the_wall_observed() -> None: + # Before: gas clearance checked only the ground band, missing an unseen wall-mounted meter. + rules = golden_rules(clearances={"gas_ft": {"value": 5, "source": "t"}}) + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 5), (12, 40)]) + result = run(raw, rules) + assert result["decision"] == "manual_review" + gas = check(result, "gas_clearance") + assert (gas["outcome"], gas.get("unsure_cause")) == ("unsure", "unobserved") From a3f20029f5a76066bad640598907d5b64d893971 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:55:12 -0500 Subject: [PATCH 10/52] server: route and measurement semantics found by the verification harness - route_length's threshold_ft is again the maximum, the at_most line past which the run fails, so the check's numbers agree with its outcome under the strict rule; the cited rule names both lines and is a placeholder if either is. - A facing or overhead measurement's plus_minus_ft is the error of the measured height or depth (as the schema says); where its stretch sits along the wall carries the wall's error. A stretch that lies under the battery wherever that error puts it counts in full; one that only might gives UNSURE at worst. The earlier fix used the value's error for the span, so a narrow landing could never clearly fail. - Wall segments shorter than the battery appear in the sweep as a failing wall_backing run, so a curved wall of short chords explains why no spot is there. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 51 ++++++++++++++++--------- server/tests/test_review_regressions.py | 47 ++++++++++++++++------- 2 files changed, 67 insertions(+), 31 deletions(-) diff --git a/server/solver.py b/server/solver.py index 576b84e3a..dcfd8c3eb 100644 --- a/server/solver.py +++ b/server/solver.py @@ -471,15 +471,18 @@ def check_measured( missing = self.scene.missing(band, s0, s1) worst_key: tuple[int, float] | None = None for m in entries: - # A measurement's stretch is itself only known to within its and the wall's error: - # one that reaches the battery only inside that band can't give a clean pass, but - # isn't a clear failure either. - edge = m.plus_minus + wall_error - overlap = min(s1, m.span[1]) - max(s0, m.span[0]) - if overlap <= -edge + EPS or (edge == 0 and overlap <= EPS): + # plus_minus is the error of the measured height or depth; where the stretch sits + # along the wall is known to the wall's error. A measurement that lies under the + # battery wherever that error puts it counts in full; one that only might (it + # reaches the battery's edge within the error) can't give a clean pass, but isn't a + # clear failure either. With exact geometry, touching end to end is not overlap. + a, b = m.span + possible = a - wall_error < s1 - EPS and b + wall_error > s0 + EPS + if not possible: continue + definite = a + wall_error < s1 - EPS and b - wall_error > s0 + EPS outcome = at_least(m.value - subtract, m.plus_minus, t) - if overlap <= edge + EPS and outcome == FAIL: + if not definite and outcome == FAIL: outcome = UNSURE key = (_SEVERITY[outcome], -(m.value - m.plus_minus)) if worst_key is None or key > worst_key: @@ -611,24 +614,25 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C 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) - # Cite the line that decided: the maximum when the run fails or is within error of it, - # otherwise the confident reach. - by_max = outcome == FAIL or length + e >= r.max_ft.value - EPS - rule_key, rule = ( - ("route.max_ft", r.max_ft) - if by_max - else ("route.confident_reach_ft", r.confident_reach_ft) + # 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. + cr = r.confident_reach_ft + rule = Value( + value=r.max_ft.value, + source=f"{r.max_ft.source}. Review past {ft(cr.value)}: {cr.source}", + placeholder=r.max_ft.placeholder or cr.placeholder, ) reach = Check( "route_length", "Cable run length", outcome, "", - rule_key, + "route.max_ft", rule, measured=length, plus_minus=e, - threshold=rule.value, + threshold=r.max_ft.value, comparison="at_most", ) run = f"{ft(length)} (± {ft(e)})" @@ -858,13 +862,24 @@ def candidates(self) -> list[Candidate]: return out def out_of_reach(self) -> list[dict[str, Any]]: - """Sweep runs for stretches of wall too far along for any route to pass, which are not - evaluated start by start.""" + """Sweep runs for wall that is not evaluated start by start: segments too short for the + battery, and stretches too far along for any route to pass.""" runs = [] for piece in self.scene.walls: limit = self.reach_limit(piece) lo, hi = piece.s0, piece.s1 - self.W if hi < lo - EPS: + # Too short for the battery: any start runs off the end of the segment. + runs.append( + { + "wall_id": piece.wall_id, + "segment": piece.index, + "start_ft": [_round(lo), _round(lo)], + "outcome": FAIL, + "failing": ["wall_backing"], + "unsure": [], + } + ) continue for a, b in ((lo, min(hi, -limit - self.W)), (max(lo, limit), hi)): if b - a > EPS: diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py index 3fe8ea446..d0c832cf4 100644 --- a/server/tests/test_review_regressions.py +++ b/server/tests/test_review_regressions.py @@ -90,28 +90,44 @@ def test_garage_door_with_footprint_still_blocks_route() -> None: def test_headroom_span_edge_error_counts() -> None: - # Before: a 5 ft overhead ending at s = 9 (± 0.5) was ignored for a battery starting at 9. + # Before: a 5 ft overhead ending at s = 9 was ignored for a battery starting at 9, though + # where a stretch sits along the wall is only known to the wall's error (± 0.3 here). + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.3 + raw["ground"] = pads_ground([(8.5, 12.5)]) + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [-40, 9], "clearance_ft": 5, "plus_minus_ft": 0.5}, + {"wall_id": "w1", "span_ft": [9, 40], "clearance_ft": 9, "plus_minus_ft": 0.5}, + ] + assert at_start(raw, 9.0, "headroom").outcome == "unsure" + assert at_start(raw, 9.4, "headroom").outcome == "pass" + # A stretch that lies under the battery wherever the error puts it still fails outright. + assert at_start(raw, 6.0, "headroom").outcome == "fail" + + +def test_exact_touching_measurement_does_not_count() -> None: + # With exact geometry a stretch ending where the battery starts is beside it, not over it. raw = shared_fixture() raw["ground"] = pads_ground([(9, 12)]) raw["overheads"] = [ {"wall_id": "w1", "span_ft": [-40, 9], "clearance_ft": 5, "plus_minus_ft": 0.5}, {"wall_id": "w1", "span_ft": [9, 40], "clearance_ft": 9, "plus_minus_ft": 0.5}, ] - assert run(raw)["decision"] != "pass" - assert at_start(raw, 9.0, "headroom").outcome in {"unsure", "fail"} - assert at_start(raw, 9.6, "headroom").outcome == "pass" + assert at_start(raw, 9.0, "headroom").outcome == "pass" def test_facing_span_edge_error_counts() -> None: - # Before: a 1 ft facing gap ending at s = 9 (± 0.5) was ignored for a battery starting at 9. + # Before: a 1 ft facing gap ending at s = 9 was ignored for a battery starting at 9 on a wall + # known to ± 0.3. raw = shared_fixture() - raw["ground"] = pads_ground([(9, 12)]) + raw["walls"][0]["plus_minus_ft"] = 0.3 + raw["ground"] = pads_ground([(8.5, 12.5)]) raw["facing"] = [ {"wall_id": "w1", "span_ft": [-40, 9], "depth_ft": 1, "plus_minus_ft": 0.5}, {"wall_id": "w1", "span_ft": [9, 40], "depth_ft": 9, "plus_minus_ft": 0.5}, ] - assert at_start(raw, 9.0, "facing_gap").outcome != "pass" - assert at_start(raw, 9.6, "facing_gap").outcome == "pass" + assert at_start(raw, 9.0, "facing_gap").outcome == "unsure" + assert at_start(raw, 9.4, "facing_gap").outcome == "pass" # --- 4. meter past its wall's end ---------------------------------------------------------------- @@ -340,11 +356,16 @@ def test_long_wall_is_bounded_by_reach() -> None: # --- 13. route length rule citation ------------------------------------------------------------- -def test_route_length_cites_confident_reach_on_pass() -> None: - # Before: a pass inside the confident reach cited the maximum as its rule. - result = run(shared_fixture()) - assert result["decision"] == "pass" - assert check(result, "route_length")["rule"]["key"] == "route.confident_reach_ft" +def test_route_length_cites_both_lines() -> None: + # Before: the rule named only the maximum, hiding that a pass is decided by the confident + # reach. threshold_ft stays the maximum, the at_most line past which the run fails. + rules = golden_rules( + route={"confident_reach_ft": {"value": 15, "source": "reach test", "placeholder": True}} + ) + reach = check(run(shared_fixture(), rules), "route_length") + assert reach["threshold_ft"] == 20 + assert "reach test" in reach["rule"]["source"] + assert reach["rule"]["placeholder"] is True def test_route_length_cites_maximum_on_reject() -> None: From eaabef97616d25932f84144d60dfc11f379f79ef Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:56:05 -0500 Subject: [PATCH 11/52] server: checks with a review band publish review_threshold_ft route_length has two lines: past the confident reach it is at best unsure, past the maximum it fails. threshold_ft carries only the maximum, so a checker applying the strict rule to it alone expects a 16 ft run to pass. The optional field (additive to result.schema.json) names the review line, as the scoring harness's review_threshold does. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/schemas/result.schema.json | 1 + server/solver.py | 4 ++++ server/tests/test_review_regressions.py | 1 + 3 files changed, 6 insertions(+) diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index c1b9dceff..64c1cccdd 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -145,6 +145,7 @@ "measured_ft": {"type": ["number", "null"], "description": "The deciding measurement, for example the gap to the nearest gas meter. Null when nothing relevant was found or it was not observed."}, "plus_minus_ft": {"type": ["number", "null"]}, "threshold_ft": {"type": ["number", "null"]}, + "review_threshold_ft": {"type": "number", "description": "Present on checks with a review band (route_length): a value that doesn't clear this line (at_most: measured + error < review_threshold_ft) is at best unsure, even when it clears threshold_ft. Same convention as the scoring harness's review_threshold."}, "comparison": {"enum": ["at_least", "at_most", null], "description": "at_least: pass needs measured - error > threshold. at_most: pass needs measured + error < threshold."}, "subject": {"type": ["string", "null"], "description": "What the measurement is to, for example \"objects[2] gas_meter\"."}, "rule": { diff --git a/server/solver.py b/server/solver.py index dcfd8c3eb..cbb402f05 100644 --- a/server/solver.py +++ b/server/solver.py @@ -44,6 +44,7 @@ class Check: measured: float | None = None plus_minus: float | None = None threshold: float | None = None + review_threshold: float | None = None comparison: str | None = None subject: str | None = None unsure_cause: str | None = None @@ -71,6 +72,8 @@ def to_json(self) -> dict[str, Any]: "placeholder": self.rule.placeholder if self.rule else self.rule_placeholder, }, } + if self.review_threshold is not None: + out["review_threshold_ft"] = _round(self.review_threshold) if self.outcome == UNSURE: out["unsure_cause"] = self.unsure_cause or "margin" return out @@ -633,6 +636,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C measured=length, plus_minus=e, threshold=r.max_ft.value, + review_threshold=cr.value, comparison="at_most", ) run = f"{ft(length)} (± {ft(e)})" diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py index d0c832cf4..7c85177d1 100644 --- a/server/tests/test_review_regressions.py +++ b/server/tests/test_review_regressions.py @@ -364,6 +364,7 @@ def test_route_length_cites_both_lines() -> None: ) reach = check(run(shared_fixture(), rules), "route_length") assert reach["threshold_ft"] == 20 + assert reach["review_threshold_ft"] == 15 assert "reach test" in reach["rule"]["source"] assert reach["rule"]["placeholder"] is True From a67596ce826a1bef122ac0f2832981eafa136a8c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 04:01:23 -0500 Subject: [PATCH 12/52] server: second review round - Start positions also come from where each clearance region meets the footprint's strip along the wall, so an object standing a little off the wall (reached by a side edge, not a corner) can't hide a pass window; the first fix regressed that case into a reject. - Collinear-point merging rechecks every dropped point against the new chord, with a 0.6 in tolerance, so a gentle curve of taps can't become one straight wall far off the real one. - Walls with different ids that meet in a straight line join into one stretch; results still name the uploaded wall under the spot. - Round an unexplored end, ground observed in front of the scanned walls and the house behind them no longer count as unseen; only the unknown corner does. - scene.schema.json bounds collection sizes (50 walls, 500 points per outline, 500 objects), so one upload can't tie up the server. - The opening clearance's citation says applying it to garage doors is a demo choice. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/rules.yaml | 2 +- server/scene.py | 99 ++++++++++++++++++++----- server/schemas/scene.schema.json | 11 ++- server/solver.py | 24 ++++-- server/tests/test_review_regressions.py | 76 +++++++++++++++++++ 5 files changed, 184 insertions(+), 28 deletions(-) diff --git a/server/rules.yaml b/server/rules.yaml index e5e363cb3..9cfec66ba 100644 --- a/server/rules.yaml +++ b/server/rules.yaml @@ -42,7 +42,7 @@ sweep: clearances: gas_ft: {value: 3.0, source: "Base help page: 3 ft from the gas meter; Austin Energy §1.9; Texas Gas Service"} ac_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries"} - opening_ft: {value: 3.0, source: "IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling"} + opening_ft: {value: 3.0, 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"} drive_ft: {value: 5.0, source: "No public value (docs/01 rules table: TBD)", placeholder: true} pool_ft: {value: 10.0, source: "No public value (docs/01 rules table: TBD); the demo ring drawn in docs/eli5.html", placeholder: true} # Wall-mounted equipment directly above the battery: minimum sideways gap along the wall. diff --git a/server/scene.py b/server/scene.py index 43d4dac66..a53c29917 100644 --- a/server/scene.py +++ b/server/scene.py @@ -142,6 +142,7 @@ 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 objects: list[SceneObject] ground: list[GroundPatch] overheads: list[Measured] @@ -173,6 +174,11 @@ def s_max(self) -> float: def meter_xz(self) -> Point2: return (self.meter_pos[0], self.meter_pos[2]) + 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]) + def piece_at(self, s: float) -> Piece: for p in self.pieces: if p.s0 - EPS <= s <= p.s1 + EPS: @@ -250,14 +256,27 @@ def unobserved_ground(self) -> Geometry: return self._cache["ground"] def _unexplored_discs(self) -> list[Geometry]: - """Round an unexplored end the walls may turn any way, so the extension line past it - proves nothing: everything within reach of that end counts as unseen.""" + """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.""" ends = {"left": self.walls[0].a, "right": self.walls[-1].b} - return [ + discs = [ Point(ends[side]).buffer(self.reach_ft) for side, kind in self.end_kinds.items() if kind == "unexplored" ] + if not discs: + return [] + 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", []) + ] + ).buffer(1e-6) + # 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]) + return [d.difference(known) for d in discs] def unobserved_wall(self) -> Geometry: if "wall" not in self._cache: @@ -312,7 +331,8 @@ def _wedge(v: Point2, n1: Point2, n2: Point2, radius: float) -> Polygon: return poly if poly.is_valid and poly.area > EPS else Polygon() -# Baseline points closer than this to the line through their neighbours are one straight wall. +# Baseline points closer than this (0.6 in) to the line through their neighbours are one straight +# wall. Deliberately well below the tap error: a real step in the wall must stay a corner. COLLINEAR_FT = 0.05 @@ -321,20 +341,62 @@ def _merge_collinear(pts: list[Point2], tol: float, path: str) -> list[Point2]: wall's error), so a tap in the middle of a straight wall is not a corner the battery can't straddle.""" out = [pts[0]] + dropped: list[Point2] = [] for i in range(1, len(pts) - 1): - a, p, b = out[-1], pts[i], pts[i + 1] - ab = _sub(b, a) - length = _norm(ab) - if length < 1e-6: - raise SceneError(f"{path}/{i + 1}", "repeats an earlier point") - t_along = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / (length * length) - off = abs((p[0] - a[0]) * ab[1] - (p[1] - a[1]) * ab[0]) / length - if not (0 < t_along < 1 and off <= tol): - out.append(p) + a, b = out[-1], pts[i + 1] + # Every point dropped since the last kept one must stay on the new chord, so error + # can't accumulate along a gently curving line of taps. + if all(_on_chord(a, q, b, tol, f"{path}/{i + 1}") for q in [*dropped, pts[i]]): + dropped.append(pts[i]) + else: + out.append(pts[i]) + dropped = [] out.append(pts[-1]) return out +def _on_chord(a: Point2, p: Point2, b: Point2, tol: float, path: str) -> bool: + ab = _sub(b, a) + length = _norm(ab) + if length < 1e-6: + raise SceneError(path, "repeats an earlier point") + t_along = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / (length * length) + off = abs((p[0] - a[0]) * ab[1] - (p[1] - a[1]) * ab[0]) / length + return 0 < t_along < 1 and off <= tol + + +def _join_straight_walls(pieces: list[Piece]) -> list[Piece]: + """Two walls that meet in a straight line are one straight stretch a battery can span. The + joined piece keeps the first wall's id; Scene.wall_at names the wall under any s.""" + out: list[Piece] = [] + for p in pieces: + prev = out[-1] if out else None + if ( + prev is not None + and prev.kind == p.kind == "wall" + and prev.wall_id != p.wall_id + and _norm(_sub(prev.b, p.a)) <= COLLINEAR_FT + and _on_chord(prev.a, prev.b, p.b, COLLINEAR_FT, "/walls") + ): + length = _norm(_sub(p.b, prev.a)) + along = ((p.b[0] - prev.a[0]) / length, (p.b[1] - prev.a[1]) / length) + out[-1] = Piece( + "wall", + prev.wall_id, + prev.index, + prev.a, + p.b, + prev.s0, + prev.s0 + length, + along, + _outward(along), + max(prev.plus_minus, p.plus_minus), + ) + else: + out.append(p) + return out + + def _outward(along: Point2) -> Point2: # Points run left to right seen from outside, so outward is `along` turned clockwise viewed # from above: (ux, uz) -> (-uz, ux). @@ -423,7 +485,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N wall_ids.add(wid) pts = [_xz(p, f"/walls/{wi}/baseline") for p in wall["baseline"]] wall_err = _error(wall, errors.wall_ft.value) - pts = _merge_collinear(pts, max(wall_err, COLLINEAR_FT), f"/walls/{wi}/baseline") + 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: @@ -518,11 +580,9 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N ) for p in pieces ] - meter_piece = next( - p - for p in pieces - if p.kind == "wall" and p.wall_id == best[1].wall_id and p.index == best[1].index - ) + wall_spans = [(p.wall_id, p.s0, p.s1) for p in pieces if p.kind == "wall"] + pieces = _join_straight_walls(pieces) + 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 battery. c = rules.clearances @@ -565,6 +625,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N meter_pos=mpos, # type: ignore[arg-type] meter_plus_minus=_error(meter, errors.meter_ft.value), meter_piece=meter_piece, + wall_spans=wall_spans, objects=[], ground=[], overheads=[], diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index e838f327c..63255a268 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -26,6 +26,7 @@ "description": "Wall chain in order from the left end to the right end, as seen from outside. Consecutive walls that share an endpoint meet at a corner; a gap between the end of one wall and the start of the next is a stretch with no wall, which no battery can back onto and no cable can cross. The first wall starts at the chain's left end.", "type": "array", "minItems": 1, + "maxItems": 50, "items": { "type": "object", "additionalProperties": false, @@ -36,6 +37,7 @@ "description": "The wall's line along the ground, [x, z] points ordered left to right as seen from outside. The outward direction of each segment is therefore its direction turned 90 degrees clockwise in the x-z plane viewed from above (+y). Each straight segment is where a battery can sit flush.", "type": "array", "minItems": 2, + "maxItems": 500, "items": {"$ref": "#/$defs/point2"} }, "height_ft": {"type": "number", "exclusiveMinimum": 0}, @@ -45,6 +47,7 @@ }, "objects": { "type": "array", + "maxItems": 500, "items": { "type": "object", "additionalProperties": false, @@ -70,6 +73,7 @@ "description": "Optional ground outline of the object in plan, [x, z] points. Give it for anything that stands off the wall (a gas regulator, pipe run, AC unit, pool edge) so clearances are measured to every part. Without it the object is the stretch of wall line over `span_ft`.", "type": "array", "minItems": 1, + "maxItems": 500, "items": {"$ref": "#/$defs/point2"} } } @@ -77,13 +81,14 @@ }, "ground": { "type": "array", + "maxItems": 200, "items": { "type": "object", "additionalProperties": false, "required": ["type", "polygon"], "properties": { "type": {"enum": ["drive", "concrete", "gravel", "lawn", "mulch", "deck"]}, - "polygon": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}, + "polygon": {"type": "array", "minItems": 3, "maxItems": 500, "items": {"$ref": "#/$defs/point2"}}, "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft"} } } @@ -91,6 +96,7 @@ "overheads": { "description": "Anything overhead (porch roof, eave, stairs, landing) along the wall, with the clear height beneath it.", "type": "array", + "maxItems": 500, "items": { "type": "object", "additionalProperties": false, @@ -106,6 +112,7 @@ "facing": { "description": "Gap from the wall straight out to the nearest fence, hedge or wall.", "type": "array", + "maxItems": 500, "items": { "type": "object", "additionalProperties": false, @@ -133,6 +140,7 @@ }, "observed": { "type": "array", + "maxItems": 500, "items": { "type": "object", "additionalProperties": false, @@ -153,6 +161,7 @@ }, "keyframes": { "type": "array", + "maxItems": 5000, "items": { "type": "object", "additionalProperties": false, diff --git a/server/solver.py b/server/solver.py index cbb402f05..0a6e4fb6f 100644 --- a/server/solver.py +++ b/server/solver.py @@ -18,7 +18,7 @@ from functools import partial from typing import Any -from shapely import Geometry, LineString, Polygon, unary_union +from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value from scene import EPS, Piece, Scene, SceneObject, merge_intervals @@ -817,14 +817,23 @@ def starts(self, piece: Piece) -> list[float]: # 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): - boundary = geom.buffer(dist).boundary if dist != 0 else geom.boundary + region = geom.buffer(dist) if dist != 0 else geom + boundary = region.boundary for track in tracks: if track.distance(boundary) > EPS: continue for x, z in _coords_of(track.intersection(boundary)): u = piece.local((x, z))[0] points += [u, u - W] + # Where the region lies inside the footprint's strip, the footprint's side edges + # meet it at the region's extent along the wall (an object standing a little off the + # wall is reached by a side edge, not a corner). + inside = region.intersection(strip) + if not inside.is_empty: + us = [piece.local((x, z))[0] for x, z in get_coordinates(inside)] + points += [min(us), max(us), min(us) - W, max(us) - W] pts = sorted({round(p, 9) for p in points if lo - EPS <= p <= hi + EPS}) pts = [min(max(p, lo), hi) for p in pts] mids = [(a + b) / 2 for a, b in itertools.pairwise(pts) if b - a > 1e-6] @@ -937,7 +946,7 @@ def _spot_json(solver: Solver, c: Candidate) -> dict[str, Any]: mx, mz = solver.scene.meter_xz return { "outcome": c.outcome, - "wall_id": p.wall_id, + "wall_id": solver.scene.wall_at((c.s0 + c.s1) / 2), "segment": p.index, "span_ft": [_round(c.s0), _round(c.s1)], "width_ft": _round(solver.W), @@ -1003,17 +1012,18 @@ def _missing_json(c: Candidate) -> list[dict[str, Any]]: return out -def _sweep_json(cands: list[Candidate]) -> list[dict[str, Any]]: +def _sweep_json(cands: list[Candidate], scene: Scene) -> list[dict[str, Any]]: runs: list[dict[str, Any]] = [] for c in sorted(cands, key=lambda c: c.s0): - key = (c.piece.wall_id, c.piece.index, c.outcome, sorted(c.failing()), sorted(c.unsure())) + wall = scene.wall_at((c.s0 + c.s1) / 2) + key = (wall, c.piece.index, c.outcome, sorted(c.failing()), sorted(c.unsure())) if runs and runs[-1]["_key"] == key: runs[-1]["start_ft"][1] = _round(c.s0) else: runs.append( { "_key": key, - "wall_id": c.piece.wall_id, + "wall_id": wall, "segment": c.piece.index, "start_ft": [_round(c.s0), _round(c.s0)], "outcome": c.outcome, @@ -1207,7 +1217,7 @@ def past_end_requests() -> list[dict[str, Any]]: "missing_evidence": missing, "ends": ends, "sweep": sorted( - _sweep_json(cands) + far, key=lambda run: (run["start_ft"][0], run["segment"]) + _sweep_json(cands, scene) + far, key=lambda run: (run["start_ft"][0], run["segment"]) ), "stats": { "candidates": len(cands), diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py index 7c85177d1..18b112d73 100644 --- a/server/tests/test_review_regressions.py +++ b/server/tests/test_review_regressions.py @@ -391,3 +391,79 @@ def test_gas_clearance_needs_the_wall_observed() -> None: assert result["decision"] == "manual_review" gas = check(result, "gas_clearance") assert (gas["outcome"], gas.get("unsure_cause")) == ("unsure", "unobserved") + + +# --- second review round ---------------------------------------------------------------------- + + +def test_side_edge_reaches_an_object_standing_off_the_wall() -> None: + # Before: starts only came from where a footprint corner's track crosses a clearance circle, + # so a gas meter 0.9 ft off the wall (reached by a side edge) left the pass window unfound. + rules = golden_rules( + route={ + "confident_reach_ft": {"value": 30, "source": "t"}, + "max_ft": {"value": 40, "source": "t"}, + } + ) + raw = shared_fixture() + raw["ground"] = pads_ground([(8, 17)]) + for u in (14.005 - 3, 14.03 + W + 3): + raw["objects"].append( + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [u, u], + "bottom_ft": 3, + "top_ft": 4, + "source": "tap", + "plus_minus_ft": 0, + "footprint": [[u, 0.9]], + } + ) + result = run(raw, rules) + assert result["decision"] == "pass" + assert 14.005 < result["spot"]["span_ft"][0] < 14.03 + + +def test_curved_taps_are_not_merged_into_one_false_wall() -> None: + # Before: dropped points were never rechecked, so a gentle curve became one straight wall + # up to 1.6 ft off the taps. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[x, 0.004 * x * x] for x in range(-40, 41)] + scene = parsed(raw) + for p in scene.walls: + for x in range(-40, 41): + s, out = p.local((x, 0.004 * x * x)) + if p.s0 <= s <= p.s1: + assert abs(out) <= 0.05 + 1e-9 + + +def test_observed_ground_near_an_unexplored_end_still_counts() -> None: + # Before: everything within reach of an unexplored end was unseen, even ground the scan saw + # in front of the wall, so a fully observed pad asked for photos it already had. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [30, 0]] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = run(raw) + assert result["decision"] == "pass" + assert result["missing_evidence"] == [] + + +def test_walls_meeting_in_a_straight_line_are_one_stretch() -> None: + # Before: two collinear walls with different ids made a corner at the joint. + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [7.5, 0]], "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[7.5, 0], [40, 0]], "plus_minus_ft": 0}, + ] + result = run(raw) + assert result["decision"] == "pass" + assert result["spot"]["span_ft"] == pytest.approx([6, 6 + W]) + assert result["spot"]["wall_id"] in {"w1", "w2"} + + +def test_oversized_scene_is_refused() -> None: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[x / 10, (x % 2) / 10] for x in range(-400, 400)] + with pytest.raises(SceneError, match="is too long"): + parsed(raw) From 3d5105354dc5430e618916849a145c6cdb1f0a59 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 04:02:19 -0500 Subject: [PATCH 13/52] server: a route clearly between the reach lines is rule_requires_review A run past the confident reach but clearly under the maximum is not a measurement inside its error band; the policy sends it to a person. Only a run within its error of either line is a margin UNSURE. Found by the verification harness reading review_threshold_ft. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 18 +++++++++++++----- server/tests/test_review_regressions.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/server/solver.py b/server/solver.py index 0a6e4fb6f..5210d9a45 100644 --- a/server/solver.py +++ b/server/solver.py @@ -644,11 +644,19 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C if reach.outcome == FAIL: reach.reason = f"The cable run is {run}, over the {ft(r.max_ft.value)} maximum." elif reach.outcome == UNSURE: - reach.unsure_cause = "margin" - reach.reason = ( - f"The cable run is {run}: past the {ft(confident)} confident reach " - f"or within error of the {ft(r.max_ft.value)} maximum." - ) + near_a_line = any(abs(length - line) <= e + EPS for line in (confident, r.max_ft.value)) + if near_a_line: + reach.unsure_cause = "margin" + reach.reason = ( + f"The cable run is {run}, within its error of the {ft(confident)} confident " + f"reach or the {ft(r.max_ft.value)} maximum: too close to call." + ) + else: + reach.unsure_cause = "rule_requires_review" + reach.reason = ( + f"The cable run is {run}: past the {ft(confident)} confident reach, so the " + f"policy sends it to a person, though under the {ft(r.max_ft.value)} maximum." + ) else: reach.reason = f"The cable run is {run}, within the {ft(confident)} confident reach." route = Route( diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py index 18b112d73..5a9fa9c65 100644 --- a/server/tests/test_review_regressions.py +++ b/server/tests/test_review_regressions.py @@ -467,3 +467,15 @@ def test_oversized_scene_is_refused() -> None: raw["walls"][0]["baseline"] = [[x / 10, (x % 2) / 10] for x in range(-400, 400)] with pytest.raises(SceneError, match="is too long"): parsed(raw) + + +@pytest.mark.parametrize( + ("start", "cause"), [(15.0, "margin"), (16.0, "rule_requires_review"), (20.0, "margin")] +) +def test_route_length_unsure_cause(start, cause) -> None: + # On a line the run is too close to call; clearly between the lines the policy sends it to a + # person, which more photos can't settle either. + raw = shared_fixture() + raw["ground"] = pads_ground([(start, start + 3)]) + reach = check(run(raw), "route_length") + assert (reach["outcome"], reach["unsure_cause"]) == ("unsure", cause) From b04558df525df0d07653ff5c19e77a7a03a25edc Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 04:04:38 -0500 Subject: [PATCH 14/52] server: compute ground-patch buffers once per solve The ground check rebuffered every patch and the unclassified area for every candidate, though they depend only on the wall error. The example scene drops from about 0.29 s to 0.18 s locally; CI's shared runner is about 3.5 times slower and took 1.01 s before this. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/server/solver.py b/server/solver.py index 5210d9a45..244f9d97c 100644 --- a/server/solver.py +++ b/server/solver.py @@ -177,6 +177,7 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: 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) # --- geometry helpers ---------------------------------------------------------------------- @@ -184,6 +185,14 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: def footprint(self, piece: Piece, s0: float) -> Polygon: return piece.rect(s0, s0 + self.W, 0.0, self.D) + def _buffered(self, geom: Geometry, distance: float) -> Geometry: + """geom.buffer(distance), computed once per solve: ground patches and the unclassified + area only depend on the wall error, not on the candidate.""" + key = (id(geom), distance) + if key not in self._buffers: + self._buffers[key] = geom.buffer(distance) + return self._buffers[key] + def _covered(self, fp: Polygon, unobserved: Geometry, radius: float) -> bool: """True when nothing unobserved lies within `radius` of the footprint. Unseen areas count by area (touching along an edge is not overlap) and unseen wall lines by length.""" @@ -239,7 +248,7 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: ) ew = piece.plus_minus for g in self.bad: - core = g.polygon.buffer(-(g.plus_minus + ew)) + core = self._buffered(g.polygon, -(g.plus_minus + ew)) if not core.is_empty and fp.intersection(core).area > _MEASURE_EPS: c.outcome, c.subject = FAIL, f"ground[{g.index}] {g.type}" c.reason = f"The footprint stands on {g.type}, which is not an allowed surface." @@ -255,12 +264,12 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: # standing on it (golden test 01). on_good = self._good_union.covers(fp) near_bad = any( - fp.intersection(g.polygon.buffer(g.plus_minus + ew)).area > _MEASURE_EPS + fp.intersection(self._buffered(g.polygon, g.plus_minus + ew)).area > _MEASURE_EPS for g in self.bad ) near_unknown = ( not self._unclassified.is_empty - and fp.intersection(self._unclassified.buffer(self._ground_error + ew)).area + and fp.intersection(self._buffered(self._unclassified, self._ground_error + ew)).area > _MEASURE_EPS ) if on_good and not near_bad and not near_unknown: From 6fdb440af77ef8b3c522b869e4dc33a5881ac1a2 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 04:17:43 -0500 Subject: [PATCH 15/52] server: AR default errors grow with distance walked; sweep runs stay adjacent Error bars from real data (S1, PR #12): ARKit drift on real outdoor walks is p90 8.2 in after 3 ft, 22.3 in after 10 ft and 60.1 in after 30 ft (2018 iPhone 6s, ARKit 1.0), so docs/02's flat 0.3 ft holds only near the meter. Scenes may omit plus_minus_ft (the schema makes it optional and the app does not export yet), so the defaults matter: an AR-placed position (tap or vlm object, wall, ground patch) without its own error now gets its source default plus errors.drift_per_ft (0.16) per foot walked from the meter. 0.3 ft + 0.16 ft/ft covers S1's p90 at all three distances. Tape values, the meter anchor and explicit errors are unchanged. Each candidate is checked with the wall's error at its far edge from the meter. Sweep: a run only grows by a start on the same piece within one sweep step, so it can never claim starts nobody evaluated (S4 case g03 at 44607e0; already split by segment since 58632a1, now explicit). Speed: the grid puts the battery's centre on the 2 in grid instead of both edges (still mirror-symmetric, half the starts), and the ground check measures nearness to other surfaces by distance instead of buffering per candidate. Example scene about 0.17 s. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/rules.py | 1 + server/rules.yaml | 4 ++ server/scene.py | 46 +++++++++-------- server/solver.py | 68 +++++++++++++++---------- server/tests/helpers.py | 2 +- server/tests/test_drift.py | 52 +++++++++++++++++++ server/tests/test_review_regressions.py | 20 ++++++++ 7 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 server/tests/test_drift.py diff --git a/server/rules.py b/server/rules.py index 75dabb703..368c9215f 100644 --- a/server/rules.py +++ b/server/rules.py @@ -57,6 +57,7 @@ class Errors(_Strict): tape_ft: Value wall_ft: Value meter_ft: Value + drift_per_ft: Value class Sweep(_Strict): diff --git a/server/rules.yaml b/server/rules.yaml index 9cfec66ba..aebb5eb1b 100644 --- a/server/rules.yaml +++ b/server/rules.yaml @@ -30,6 +30,10 @@ errors: tape_ft: {value: 0.05, source: "Tape measure read to the nearest half inch", placeholder: true} wall_ft: {value: 0.3, source: "Walls come from AR taps: docs/02 Lane C estimate; untested"} meter_ft: {value: 0.3, source: "The meter is an AR tap: docs/02 Lane C estimate; untested"} + # Added per foot walked from the meter to the default error of AR-placed positions (tap and + # vlm objects, walls, ground patches); not to tape, the meter itself or explicit plus_minus_ft. + # 0.3 ft + 0.16 ft/ft covers S1's p90 at 3, 10 and 30 ft walked (0.78, 1.9, 5.1 ft). + drift_per_ft: {value: 0.16, source: "S1 real-data evals (PR #12): ARKit drift on outdoor ADVIO walks, p90 8.2 in after 3 ft, 22.3 in after 10 ft, 60.1 in after 30 ft, mostly ~7% scale error. 2018 iPhone 6s, ARKit 1.0; current phones untested"} sweep: step_ft: {value: 0.166666666667, source: "docs/01 C2: slide the footprint in 2 in steps"} diff --git a/server/scene.py b/server/scene.py index a53c29917..46035e5bf 100644 --- a/server/scene.py +++ b/server/scene.py @@ -15,7 +15,7 @@ import itertools import json import math -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any @@ -69,6 +69,13 @@ class Piece: along: Point2 outward: Point2 plus_minus: float + # Extra error per foot walked from the meter, for walls placed by AR taps without their own + # plus_minus_ft (rules.yaml errors.drift_per_ft). + drift: float = 0.0 + + def error_at(self, s: float) -> float: + """Position error of this piece's line at s.""" + return self.plus_minus + self.drift * abs(s) def point(self, s: float, out: float = 0.0) -> Point2: t = s - self.s0 @@ -391,6 +398,7 @@ def _join_straight_walls(pieces: list[Piece]) -> list[Piece]: along, _outward(along), max(prev.plus_minus, p.plus_minus), + max(prev.drift, p.drift), ) else: out.append(p) @@ -465,6 +473,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N if input_bytes is None: input_bytes = json.dumps(raw, sort_keys=True, separators=(",", ":")).encode() errors = rules.errors + drift = errors.drift_per_ft.value source_error = { "tap": errors.tap_ft.value, "vlm": errors.vlm_ft.value, @@ -477,7 +486,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N wall_ids: set[str] = set() s = 0.0 prev_end: Point2 | None = None - prev_err = 0.0 + prev_err = prev_drift = 0.0 for wi, wall in enumerate(raw["walls"]): wid = wall["id"] if wid in wall_ids: @@ -485,6 +494,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N wall_ids.add(wid) pts = [_xz(p, f"/walls/{wi}/baseline") for p in wall["baseline"]] wall_err = _error(wall, errors.wall_ft.value) + wall_drift = 0.0 if "plus_minus_ft" in wall else drift pts = _merge_collinear(pts, COLLINEAR_FT, f"/walls/{wi}/baseline") if prev_end is not None: gap = _norm(_sub(pts[0], prev_end)) @@ -507,6 +517,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N along, _outward(along), gap_err, + prev_drift + wall_drift, ) ) s += gap @@ -527,10 +538,11 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N along, _outward(along), wall_err, + wall_drift, ) ) s += length - prev_end, prev_err = pts[-1], wall_err + prev_end, prev_err, prev_drift = pts[-1], wall_err, wall_drift # Place the meter: s = 0 at its projection onto its own wall. meter = raw["meter"] @@ -565,21 +577,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N f"more than {max_off} ft means the scene is misaligned", ) shift = best[2] - pieces = [ - Piece( - p.kind, - p.wall_id, - p.index, - p.a, - p.b, - p.s0 - shift, - p.s1 - shift, - p.along, - p.outward, - p.plus_minus, - ) - for p in pieces - ] + 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"] pieces = _join_straight_walls(pieces) meter_piece = next(p for p in pieces if p.kind == "wall" and p.s0 - EPS <= 0 <= p.s1 + EPS) @@ -603,6 +601,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N first.along, first.outward, first.plus_minus, + first.drift, ) right_ext = Piece( "extension", @@ -615,6 +614,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N last.along, last.outward, last.plus_minus, + last.drift, ) pieces = [left_ext, *pieces, right_ext] @@ -661,7 +661,11 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N operable=attrs.get("operable"), well=attrs.get("well"), source=obj["source"], - plus_minus=_error(obj, source_error[obj["source"]]), + plus_minus=_error( + obj, + source_error[obj["source"]] + + (drift * max(abs(span[0]), abs(span[1])) if obj["source"] != "tape" else 0), + ), geom=geom, points=tuple(pts), ) @@ -669,7 +673,9 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N for i, patch in enumerate(raw.get("ground", [])): poly = _geometry([_xz(p, "") for p in patch["polygon"]], f"/ground/{i}/polygon") - scene.ground.append(GroundPatch(i, patch["type"], poly, _error(patch, errors.tap_ft.value))) + walked = max(abs(scene.s_of(c)) for c in poly.exterior.coords) + default = errors.tap_ft.value + drift * walked + scene.ground.append(GroundPatch(i, patch["type"], poly, _error(patch, default))) for key, target, value_key in ( ("overheads", scene.overheads, "clearance_ft"), diff --git a/server/solver.py b/server/solver.py index 244f9d97c..ed3008189 100644 --- a/server/solver.py +++ b/server/solver.py @@ -14,7 +14,7 @@ import math import time from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from functools import partial from typing import Any @@ -247,8 +247,11 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: rule_placeholder=self.r.ground.placeholder, ) ew = piece.plus_minus + # "Clearly on a disallowed surface" erodes the patch by the error, rounded up to 0.1 ft + # so the eroded patches are reused across candidates (at most 1.2 in more conservative). + erosion = math.ceil(round(ew, 9) * 10) / 10 for g in self.bad: - core = self._buffered(g.polygon, -(g.plus_minus + ew)) + core = self._buffered(g.polygon, -(g.plus_minus + erosion)) if not core.is_empty and fp.intersection(core).area > _MEASURE_EPS: c.outcome, c.subject = FAIL, f"ground[{g.index}] {g.type}" c.reason = f"The footprint stands on {g.type}, which is not an allowed surface." @@ -263,14 +266,10 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: # Containment is inclusive: touching a disallowed patch along an exact edge is not # standing on it (golden test 01). on_good = self._good_union.covers(fp) - near_bad = any( - fp.intersection(self._buffered(g.polygon, g.plus_minus + ew)).area > _MEASURE_EPS - for g in self.bad - ) + near_bad = any(fp.distance(g.polygon) < g.plus_minus + ew - _MEASURE_EPS for g in self.bad) near_unknown = ( not self._unclassified.is_empty - and fp.intersection(self._buffered(self._unclassified, self._ground_error + ew)).area - > _MEASURE_EPS + and fp.distance(self._unclassified) < self._ground_error + ew - _MEASURE_EPS ) if on_good and not near_bad and not near_unknown: c.reason = "The whole footprint stands on an allowed surface." @@ -531,7 +530,8 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C for gap in self.scene.gaps: if min(hi, gap.s1) - max(lo, gap.s0) > EPS: # A gap no longer than the two walls' errors may not be a gap at all. - clear = (gap.s1 - gap.s0) - gap.plus_minus > EPS + gap_err = gap.error_at(max(abs(gap.s0), abs(gap.s1))) + clear = (gap.s1 - gap.s0) - gap_err > EPS crossings.append( { "subject": "stretch with no wall", @@ -681,10 +681,15 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C # --- candidates ---------------------------------------------------------------------------- - def evaluate(self, piece: Piece, s0: float) -> Candidate: + def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: r = self.r s1 = s0 + self.W - fp = self.footprint(piece, s0) + 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 + ) c = r.clearances checks = [ self.check_backing(piece, s0, s1), @@ -781,15 +786,18 @@ def evaluate(self, piece: Piece, s0: float) -> Candidate: ] route, path, reach = self.route_for(piece, s0, s1) checks += [path, reach] - return Candidate(piece, s0, s1, fp, checks, route, worst([x.outcome for x in checks])) + return Candidate(wall_piece, s0, s1, fp, checks, route, worst([x.outcome for x in checks])) 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_max = self.scene.meter_plus_minus + piece.plus_minus + detour_err - return self.r.route.max_ft.value + e_max + 2 * EPS + e_fixed = self.scene.meter_plus_minus + piece.plus_minus + detour_err + if piece.drift >= 1: + return math.inf + # The route's error grows with |s| by the wall's drift: solve |s| - e(|s|) = max. + return (self.r.route.max_ft.value + e_fixed) / (1 - piece.drift) + 2 * EPS def starts(self, piece: Piece) -> list[float]: """Start positions (left edge, in s) to evaluate on one straight segment.""" @@ -800,13 +808,11 @@ def starts(self, piece: Piece) -> list[float]: return [] hi = max(hi, lo) step = self.r.sweep.step_ft.value - # The grid is anchored at the meter, in left-edge and right-edge form (starts k * step - # and k * step - W), so the start positions of a scene and of its mirror image map onto - # each other and left and right get the same treatment. + # The grid puts the battery's centre at k * step from the meter, so the start positions + # of a scene and of its mirror image map onto each other and left and right get the same + # treatment. k_lo, k_hi = math.floor(lo / step) - 1, math.ceil((hi + W) / step) + 1 - points = [lo, hi] - for k in range(k_lo, k_hi + 1): - points += [k * step, k * step - W] + points = [lo, hi] + [k * step - W / 2 for k in range(k_lo, k_hi + 1)] # Along the wall: every place an interval can start or stop mattering. boundaries = [0.0, *self.ws_span] for o in self.scene.objects: @@ -820,13 +826,13 @@ def starts(self, piece: Piece) -> list[float]: boundaries += [g.s0, g.s1] offsets = {0.0} for m in [*self.equipment, *self.scene.overheads, *self.scene.facing]: - for e in (m.plus_minus, m.plus_minus + piece.plus_minus): + for e in (m.plus_minus, m.plus_minus + piece.error_at(max(abs(lo), abs(hi)))): offsets |= {e, -e} for b in boundaries: for off in offsets: points += [b + off, b - W - off] rt = self.r.route - e_route = self.scene.meter_plus_minus + piece.plus_minus + e_route = self.scene.meter_plus_minus + piece.error_at(max(abs(lo), abs(hi))) for line in (rt.confident_reach_ft.value, rt.max_ft.value): for x in (line - e_route, line + e_route, line): points += [x, -x - W] @@ -859,7 +865,7 @@ def starts(self, piece: Piece) -> list[float]: def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float]]: """(geometry, offset) pairs whose offset outlines bound some check's outcome.""" c = self.r.clearances - ew = piece.plus_minus + ew = piece.error_at(max(abs(piece.s0), abs(piece.s1))) out: list[tuple[Geometry, float]] = [] for t, objs in ( (c.gas_ft.value, self.gas), @@ -1029,12 +1035,17 @@ def _missing_json(c: Candidate) -> list[dict[str, Any]]: return out -def _sweep_json(cands: list[Candidate], scene: Scene) -> list[dict[str, Any]]: +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.index, c.outcome, sorted(c.failing()), sorted(c.unsure())) - if runs and runs[-1]["_key"] == key: + key = (wall, 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: runs[-1]["start_ft"][1] = _round(c.s0) else: runs.append( @@ -1071,7 +1082,7 @@ def solve(scene: Scene, loaded: LoadedRules) -> dict[str, Any]: ("left", scene.s_min, scene.walls[0].a, scene.walls[0]), ("right", scene.s_max, scene.walls[-1].b, scene.walls[-1]), ): - beyond = abs(s_end) - (e_end + piece.plus_minus) - r.route.max_ft.value > EPS + beyond = abs(s_end) - (e_end + piece.error_at(s_end)) - r.route.max_ft.value > EPS ends[side] = { "kind": scene.end_kinds[side], "s_ft": _round(s_end), @@ -1234,7 +1245,8 @@ def past_end_requests() -> list[dict[str, Any]]: "missing_evidence": missing, "ends": ends, "sweep": sorted( - _sweep_json(cands, scene) + far, key=lambda run: (run["start_ft"][0], run["segment"]) + _sweep_json(cands, scene, r.sweep.step_ft.value) + far, + key=lambda run: (run["start_ft"][0], run["segment"]), ), "stats": { "candidates": len(cands), diff --git a/server/tests/helpers.py b/server/tests/helpers.py index dcef74282..5bf504edd 100644 --- a/server/tests/helpers.py +++ b/server/tests/helpers.py @@ -17,7 +17,7 @@ "policy": {"id": "golden-test", "version": "1", "auto_approve": True, "allow_reject": True}, "errors": { k: {"value": 0.0, "source": "exact test geometry"} - for k in ("tap_ft", "vlm_ft", "mesh_ft", "tape_ft", "wall_ft", "meter_ft") + for k in ("tap_ft", "vlm_ft", "mesh_ft", "tape_ft", "wall_ft", "meter_ft", "drift_per_ft") }, "facing": {"min_ft": {"value": 5.0, "source": "test"}, "measured_from": "wall"}, "route": { diff --git a/server/tests/test_drift.py b/server/tests/test_drift.py new file mode 100644 index 000000000..d623cf5cd --- /dev/null +++ b/server/tests/test_drift.py @@ -0,0 +1,52 @@ +"""AR-placed positions without their own plus_minus_ft get an error that grows with the distance +walked from the meter (rules.yaml errors.drift_per_ft, from S1's real-data drift evals).""" + +import pytest +from helpers import golden_rules, parsed, shared_fixture + +from solver import evaluate_start + +W = 31 / 12 +RULES = golden_rules( + errors={ + "tap_ft": {"value": 0.3, "source": "t"}, + "vlm_ft": {"value": 1.5, "source": "t"}, + "tape_ft": {"value": 0.05, "source": "t"}, + "wall_ft": {"value": 0.3, "source": "t"}, + "drift_per_ft": {"value": 0.16, "source": "t"}, + } +) + + +def obj(source, span, **extra): + return {"type": "window", "wall_id": "w1", "span_ft": span, "source": source, **extra} + + +def test_object_default_error_grows_with_distance_walked() -> None: + raw = shared_fixture() + raw["objects"] = [ + obj("tap", [2, 3]), + obj("tap", [-10, -9]), + obj("vlm", [2, 3]), + obj("tape", [9, 10]), + obj("tap", [9, 10], plus_minus_ft=0.1), + ] + errors = [o.plus_minus for o in parsed(raw, RULES).objects] + assert errors == pytest.approx([0.3 + 0.16 * 3, 0.3 + 0.16 * 10, 1.5 + 0.16 * 3, 0.05, 0.1]) + + +def test_wall_default_error_is_taken_at_the_batterys_far_edge() -> None: + raw = shared_fixture() + del raw["walls"][0]["plus_minus_ft"] + scene = parsed(raw, RULES) + for s0 in (6.0, 12.0, -12.0): + reach = next(c for c in evaluate_start(scene, RULES, s0).checks if c.id == "route_length") + far = max(abs(s0), abs(s0 + W)) + # The meter is exact in the fixture, so the route's error is the wall's at the far edge. + assert reach.plus_minus == pytest.approx(0.3 + 0.16 * far) + + +def test_explicit_wall_error_does_not_drift() -> None: + scene = parsed(shared_fixture(), RULES) # the fixture's wall has plus_minus_ft 0 + reach = next(c for c in evaluate_start(scene, RULES, 12.0).checks if c.id == "route_length") + assert reach.plus_minus == pytest.approx(0.0) diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py index 5a9fa9c65..5ffd048dc 100644 --- a/server/tests/test_review_regressions.py +++ b/server/tests/test_review_regressions.py @@ -479,3 +479,23 @@ def test_route_length_unsure_cause(start, cause) -> None: raw["ground"] = pads_ground([(start, start + 3)]) reach = check(run(raw), "route_length") assert (reach["outcome"], reach["unsure_cause"]) == ("unsure", cause) + + +def test_sweep_runs_only_claim_evaluated_starts_on_one_piece() -> None: + # Before (S4, case g03 at 44607e0): runs merged across pieces, so one pass run covered + # 15 ft of corner-crossing starts between two pads on a wall bent into short chords. + raw = shared_fixture() + arc = [[x, -0.02 * x * x] for x in (-9, -6, -4, -2, 0, 2, 4, 6, 7, 10)] + raw["walls"] = [{"id": "w1", "baseline": arc, "plus_minus_ft": 0}] + raw["ground"] = pads_ground([(-9.5, -5.5), (6.5, 10.5)], lo=-12, hi=12) + raw["coverage"] = everything_observed(-12, 12) + raw["facing"], raw["overheads"] = [], [] + scene = parsed(raw) + result = run(raw) + for r in result["sweep"]: + piece = next(p for p in scene.walls if p.index == r["segment"]) + a, b = r["start_ft"] + 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): + assert evaluate_start(scene, golden_rules(), s0).outcome == "pass", (r, s0) From 6a623d8a7e340c15072efa412dd34738818e4f7e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 04:21:06 -0500 Subject: [PATCH 16/52] server: cleanup pass; README and schema text say what a caller needs Behavior-preserving: the example scene's result JSON and site plan SVG are byte-identical before and after, and every test is unchanged. - solver.py: drop an unread Route field and attribute, merge the two missing-area helpers and the duplicated clearance-edge loops, type check_measured's entries, compute the wall error once in starts(). - scene.py: drop unread SceneObject.points and Scene.raw, an unused argument, and a type: ignore. - siteplan.py: imports at the top, drop an identity mapping and an unreachable branch, one local-to-page mapping instead of two. - tests: shared helpers (at_start, observed_band, W, D) instead of copies; one misnamed test renamed. - README rewritten around calling the API and writing and reading scenes; schema descriptions state that default errors grow with distance from the meter. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 101 ++++++------------ server/scene.py | 29 +++-- server/schemas/scene.schema.json | 6 +- server/siteplan.py | 60 ++++------- server/solver.py | 136 +++++++++--------------- server/tests/helpers.py | 30 +++++- server/tests/test_api.py | 5 +- server/tests/test_drift.py | 12 +-- server/tests/test_golden_public.py | 28 ++--- server/tests/test_properties.py | 7 +- server/tests/test_review_regressions.py | 20 +--- 11 files changed, 169 insertions(+), 265 deletions(-) diff --git a/server/README.md b/server/README.md index 6c24927ba..0060ba5d8 100644 --- a/server/README.md +++ b/server/README.md @@ -1,101 +1,70 @@ # server -The placement server (docs/02 lane C). It takes the phone's scan of the wall around the electric meter, decides whether a Base Power battery fits and where, and explains why. The app, the website and a future SDK all call the same HTTP API. +The placement server. It takes a scan of the wall around the electric meter and answers whether a Base Power battery fits, where, and why. The decision is plain code; every threshold lives in `rules.yaml` with its citation. -The decision is plain code, not AI. Every threshold lives in `rules.yaml` with its citation. +## Run -## Requirements - -[uv](https://docs.astral.sh/uv/) 0.11. It installs Python 3.12 (`.python-version`) and the locked dependencies. - -## Commands - -Run from `server/`: +Needs [uv](https://docs.astral.sh/uv/) 0.11. From `server/`: | Command | What it does | | --- | --- | -| `uv sync --locked` | Create `.venv` from `uv.lock` | -| `uv run uvicorn api:app --host 0.0.0.0 --port 8000` | Serve the API; `--reload` while developing | +| `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 ruff check .` | Lint | -| `uv run ruff format .` | Format; CI runs `ruff format --check .` | -| `uv run pytest -q` | Tests in `tests/` | +| `uv run pytest -q`, `uv run ruff check .`, `uv run ruff format .` | Test and lint (`make server` runs what CI runs) | -`make server` from the repository root runs what CI (`.github/workflows/server.yml`, job "Server checks") runs. +Without the web layer: `loaded = load_rules()`, then `solve(parse_scene(raw, loaded.rules), loaded)` (from `rules`, `scene` and `solver`). ## API | Endpoint | Body | Returns | | --- | --- | --- | -| `GET /health` | | `{"status": "ok", "schema_version", "policy"}`: which rules are loaded | -| `POST /v1/placements` | a scene | The result JSON (`schemas/result.schema.json`) | -| `POST /v1/placements/site-plan.svg` | a scene | The site plan as `image/svg+xml` | +| `GET /health` | | Which rules are loaded, and their hash | +| `POST /v1/placements` | a scene | The result, `schemas/result.schema.json` | +| `POST /v1/placements/site-plan.svg` | a scene | The site plan, `image/svg+xml` | -A scene is either bare `scene.json` (`Content-Type: application/json`) or a zip bundle (`application/zip`) holding `scene.json` plus the keyframe and close-up JPEGs it names. A browser form can also send either one as a multipart file field named `bundle`. The server reads a bundle in memory and refuses it if any image named in `scene.json` is missing. +A scene is bare `scene.json` (`application/json`), a zip holding `scene.json` and the JPEGs it names (`application/zip`), or either one as the multipart field `bundle`. `/openapi.json` declares all three. ```sh curl -s localhost:8000/v1/placements -H 'Content-Type: application/json' \ --data-binary @tests/fixtures/example-scene.json | jq .decision ``` -Every refusal is JSON with the field at fault: - -```json -{"error": {"code": "invalid_scene", "message": "Additional properties are not allowed ('plusminus_ft' was unexpected)", "path": "/objects/0"}} -``` +A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON pointer of the field at fault: | Status | When | | --- | --- | | 400 | The body or zip can't be read, or a zip entry has an absolute or `..` path | -| 413 | The body or unzipped bundle is over the limit (`HOUSESCAN_MAX_UPLOAD_MB`, default 256; `HOUSESCAN_MAX_UNZIPPED_MB`, default 512) | -| 415 | The content type is neither JSON, zip nor multipart | -| 422 | The scene breaks `schemas/scene.schema.json` or can't be placed (for example the meter is far from its wall) | - -## Contracts - -- `schemas/scene.schema.json` (C1): what the app uploads. It is docs/01's `scene.json` in feet with optional additions (`schema_version`, `coverage`, `plus_minus_ft`, `tape` as a source, a plan `footprint` per object). Unknown fields are refused, so a misspelled field fails loudly instead of being ignored. -- `schemas/result.schema.json` (C2): what the server answers. Both files change only by adding optional fields; `schema_version` goes up when they do. +| 413 | Over `HOUSESCAN_MAX_UPLOAD_MB` (default 256) or, unzipped, `HOUSESCAN_MAX_UNZIPPED_MB` (default 512) | +| 415 | Not JSON, zip or multipart | +| 422 | The scene breaks `schemas/scene.schema.json`, names an image the zip lacks, or can't be placed (for example the meter is far from its wall) | -Three conventions matter to anyone producing a scene: +## Writing a scene -1. Wall `baseline` points run **left to right as seen from outside**, and walls are listed in that order. The outward side of each wall segment follows from its point order. The server refuses a scene whose keyframe cameras sit mostly on the inward side. -2. `s` is feet along the unrolled walls from the meter, negative to the left. Spans (`span_ft`) and coverage use it. -3. `coverage` says what the capture actually saw. A check that depends on something unseen is UNSURE, never PASS. A scene without `coverage` can't pass. +`schemas/scene.schema.json` is the contract; it only gains optional fields. Lengths are feet. -## How a decision is made +- 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. +- 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. -`solve(parse_scene(raw, rules), loaded_rules)` is a pure function; `api.py` only adds HTTP. +## Reading a result -1. **Unroll the walls** (`scene.py`). The walls are chained left to right. Every straight segment keeps its outward direction, and s is measured from the meter's projection onto its wall. Stretches between walls that don't meet are gaps: nothing backs onto them, and no cable crosses them. -2. **Sweep the footprint** (`solver.py`). The battery (31 × 22 in from `rules.yaml`) slides along every straight segment. Start positions are a 2 in grid plus every position where a check can change outcome: an opening's clearance edge, a ground patch edge, a coverage boundary, the start at which the footprint comes exactly the clearance distance from a gas meter's corner. Midpoints between those positions are added too, so a legal stretch narrower than 2 in is still found. A footprint never bends round a corner. -3. **Check each position.** Distances are measured in plan (x, z) with shapely, so a gas meter just round an inside corner is as close as it really is. Each check is strict (contract C5): with a measured gap d ± e and a minimum T, PASS needs d − e > T, FAIL needs d + e < T, and anything else, including exact equality, is UNSURE. Errors add up: a gap between two tapped positions that are each ±0.3 ft is ±0.6 ft. A recognition confidence is never used as a distance error. +`decision` is `pass`, `manual_review` or `reject`. `spot` gives the battery's plan footprint and `meter_offset_ft`, its offset from the meter for AR; `route` is the cable run. Each entry in `checks` has an outcome, a reason, the measurement, its error and the rule it was held to: - | Check | Measures | - | --- | --- | - | `wall_backing` | Footprint flush on one straight, observed segment | - | `ground_surface` | Ground under the footprint is an allowed surface; patch edges count only where they meet a disallowed or unrecorded surface | - | `meter_working_space` | Clear of the NEC 110.26 space in front of the meter | - | `gas_clearance`, `ac_clearance`, `pool_clearance`, `drive_clearance` | Distance to every part of each object, and everything within the clearance was seen | - | `opening_clearance` | Distance to doors and windows; an unknown attribute that could exempt a window gives UNSURE, never a pass | - | `wall_equipment_above` | No box or vent on the wall above the battery | - | `facing_gap`, `headroom` | The smallest measurement over the battery's stretch of wall | - | `route_path` | The cable runs along continuous, observed wall; doors, garages and gaps block it; windows it detours round | - | `route_length` | Routed length, including detours and corners: PASS under the confident reach, FAIL clearly over the maximum | +| Check | Means | +| --- | --- | +| `wall_backing` | Flush on one straight, observed stretch of wall | +| `ground_surface` | Standing on an allowed surface | +| `meter_working_space` | Clear of the NEC 110.26 space in front of the meter | +| `gas_clearance`, `ac_clearance`, `pool_clearance`, `drive_clearance`, `opening_clearance` | Far enough from every part of each object, and the area in between was seen | +| `wall_equipment_above` | No box or vent on the wall above | +| `facing_gap`, `headroom` | Enough room in front and above | +| `route_path`, `route_length` | The cable runs along continuous wall, and isn't too long | -4. **Decide.** A spot passes when every check passes. With at least one passing spot the result is **pass**, choosing the shortest cable run. With none, the best spot without a failing check gives **manual_review**, with its reasons and the exact stretches still unseen. **reject** needs every spot to fail by a clear margin *and* both ends of the walk known: each end must be a real limit, or so far away that no spot beyond it could pass the route-length check. Otherwise the result asks for the walk to continue. A policy with `auto_approve: false` turns every pass or reject into manual review. +A check passes only when its margin exceeds its error and fails only when it misses by more than its error; anything else is UNSURE with an `unsure_cause`. `missing_evidence` lists the views that would settle an unseen area. A reject needs every spot within cable reach to fail and both ends of the walk known. ## Rules -`rules.yaml` holds every number, each with a `source`. `placeholder: true` marks a demo value that has no public source (driveway, pool, confident reach). The public file sets `auto_approve: false` for that reason. - -At startup the server deep-merges a git-ignored `private/rules.yaml` over it when one exists. The path comes from `HOUSESCAN_PRIVATE_RULES`, or else `/private/rules.yaml`. `GET /health` shows which files are loaded and the hash of the merged rules. Every result records the same hash. Never commit Base's values: `private/` is git-ignored. - -## Tests - -- `tests/test_golden_public.py`: the 14 golden tests from `docs/research/t3-lane-c-review.md` (branch `t3/research`). Every result is validated against the result schema. -- `tests/test_properties.py`: property tests. Missing coverage never passes, equality is unsure, left and right mirror, and a larger clearance never turns a pass into a fail. -- `tests/test_api.py`, `tests/test_schemas.py`, `tests/test_siteplan.py`: the HTTP layer, the published schemas, the SVG. - -Base-derived tests live in `private/tests/` (git-ignored). Run them with `uv run pytest ../private/tests -q`. - -Recognition prompts go in `server/prompts/`, which git ignores; they are never committed. +`rules.yaml` holds the public values. `placeholder: true` marks a value with no public source; because it has some, it sets `auto_approve: false`, which turns every pass or reject into manual review. At startup the server merges a git-ignored `private/rules.yaml` over it when one exists (path: `HOUSESCAN_PRIVATE_RULES`, else `/private/rules.yaml`). Never commit Base's values. Base-derived tests live beside them in `private/tests/`: `uv run pytest ../private/tests -q`. diff --git a/server/scene.py b/server/scene.py index 46035e5bf..094cad078 100644 --- a/server/scene.py +++ b/server/scene.py @@ -116,7 +116,6 @@ class SceneObject: source: str plus_minus: float geom: Geometry - points: tuple[Point2, ...] # vertices used to place candidate starts near the clearance edge @property def label(self) -> str: @@ -143,7 +142,6 @@ class Measured: @dataclass class Scene: - raw: dict[str, Any] input_sha256: str pieces: list[Piece] # extension, walls and gaps in chain order, extension meter_pos: tuple[float, float, float] @@ -208,9 +206,9 @@ def polyline(self, s_a: float, s_b: float) -> list[Point2]: def band_polygon(self, s_lo: float, s_hi: float, out: float) -> Geometry: """The ground strip in front of the chain from s_lo to s_hi, out to `out` from the wall, with the wedges that fill the outside of convex corners.""" - parts: list[Geometry] = [] if s_hi - s_lo <= EPS or out <= EPS: return Polygon() + parts: list[Geometry] = [] inside = [p for p in self.pieces if p.s1 > s_lo + EPS and p.s0 < s_hi - EPS] for p in inside: parts.append(p.rect(max(s_lo, p.s0), min(s_hi, p.s1), 0.0, out)) @@ -411,7 +409,7 @@ def _outward(along: Point2) -> Point2: return (-along[1], along[0]) -def _xz(point: list[float], path: str) -> Point2: +def _xz(point: list[float]) -> Point2: return (float(point[0]), float(point[1])) @@ -492,7 +490,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N if wid in wall_ids: raise SceneError(f"/walls/{wi}/id", f"duplicate wall id {wid!r}") wall_ids.add(wid) - pts = [_xz(p, f"/walls/{wi}/baseline") for p in wall["baseline"]] + pts = [_xz(p) for p in wall["baseline"]] wall_err = _error(wall, errors.wall_ft.value) wall_drift = 0.0 if "plus_minus_ft" in wall else drift pts = _merge_collinear(pts, COLLINEAR_FT, f"/walls/{wi}/baseline") @@ -548,7 +546,8 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N meter = raw["meter"] if meter["wall_id"] not in wall_ids: raise SceneError("/meter/wall_id", f"no wall with id {meter['wall_id']!r}") - mpos = tuple(float(v) for v in meter["pos"]) + mx, my, mz = (float(v) for v in meter["pos"]) + mpos = (mx, my, mz) mxz = (mpos[0], mpos[2]) best: tuple[float, Piece, float, float] | None = None for p in pieces: @@ -619,11 +618,10 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N pieces = [left_ext, *pieces, right_ext] scene = Scene( - raw=raw, input_sha256=hashlib.sha256(input_bytes).hexdigest(), pieces=pieces, - meter_pos=mpos, # type: ignore[arg-type] - meter_plus_minus=_error(meter, errors.meter_ft.value), + meter_pos=mpos, + meter_plus_minus=meter_err, meter_piece=meter_piece, wall_spans=wall_spans, objects=[], @@ -644,11 +642,9 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N if bottom is not None and top is not None and bottom > top: raise SceneError(path, f"bottom_ft {bottom} is above top_ft {top}") if "footprint" in obj: - pts = [_xz(p, f"{path}/footprint") for p in obj["footprint"]] - geom = _geometry(pts, f"{path}/footprint") + geom = _geometry([_xz(p) for p in obj["footprint"]], f"{path}/footprint") else: geom = scene.wall_line(*span) - pts = [scene.point_at(span[0]), scene.point_at(span[1])] attrs = obj.get("attrs", {}) scene.objects.append( SceneObject( @@ -667,12 +663,11 @@ 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, - points=tuple(pts), ) ) for i, patch in enumerate(raw.get("ground", [])): - poly = _geometry([_xz(p, "") for p in patch["polygon"]], f"/ground/{i}/polygon") + poly = _geometry([_xz(p) for p in patch["polygon"]], f"/ground/{i}/polygon") walked = max(abs(scene.s_of(c)) for c in poly.exterior.coords) default = errors.tap_ft.value + drift * walked scene.ground.append(GroundPatch(i, patch["type"], poly, _error(patch, default))) @@ -700,16 +695,16 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N for side, end in coverage.get("ends", {}).items(): scene.end_kinds[side] = end["kind"] - _check_orientation(scene) + _check_orientation(scene, raw.get("keyframes", [])) return scene -def _check_orientation(scene: Scene) -> None: +def _check_orientation(scene: Scene, keyframes: list[dict[str, Any]]) -> None: """Cameras stand outside the house. If most keyframes sit on the inward side of the wall they face, the baseline points were almost certainly ordered right to left.""" walls = scene.walls inward = total = 0 - for kf in scene.raw.get("keyframes", []): + for kf in keyframes: pose = kf["pose"] cam = (float(pose[12]), float(pose[14])) nearest = min(walls, key=lambda p: Point(cam).distance(LineString([p.a, p.b]))) diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index 63255a268..f214360c8 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -41,7 +41,7 @@ "items": {"$ref": "#/$defs/point2"} }, "height_ft": {"type": "number", "exclusiveMinimum": 0}, - "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of this wall's line. Absent means the rules' default for AR taps."} + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of this wall's line. Absent means the rules' wall default, growing with distance along the walls from the meter (errors.drift_per_ft)."} } } }, @@ -68,7 +68,7 @@ }, "source": {"enum": ["vlm", "tap", "tape"], "description": "How the position was measured: a vision model, an AR tap, or a tape measure. Sets the default error."}, "conf": {"type": "number", "minimum": 0, "maximum": 1, "description": "Recognition confidence. Not a distance error and never used as one."}, - "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error. Absent means the rules' default for `source`."}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error. Absent means the rules' default for `source`; for tap and vlm it grows with distance along the walls from the meter (errors.drift_per_ft)."}, "footprint": { "description": "Optional ground outline of the object in plan, [x, z] points. Give it for anything that stands off the wall (a gas regulator, pipe run, AC unit, pool edge) so clearances are measured to every part. Without it the object is the stretch of wall line over `span_ft`.", "type": "array", @@ -89,7 +89,7 @@ "properties": { "type": {"enum": ["drive", "concrete", "gravel", "lawn", "mulch", "deck"]}, "polygon": {"type": "array", "minItems": 3, "maxItems": 500, "items": {"$ref": "#/$defs/point2"}}, - "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft"} + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of the outline. Absent means the rules' tap default, growing with distance along the walls from the meter (errors.drift_per_ft)."} } } }, diff --git a/server/siteplan.py b/server/siteplan.py index 843496803..ba231ccaa 100644 --- a/server/siteplan.py +++ b/server/siteplan.py @@ -17,11 +17,12 @@ from pathlib import Path from typing import Any -from shapely import Geometry, LineString, Point, Polygon, box +from shapely import Geometry, LineString, Point, Polygon, box, unary_union +from shapely.affinity import affine_transform, translate from shapely.ops import nearest_points from rules import LoadedRules, load_rules -from scene import Scene, parse_scene +from scene import Piece, Scene, parse_scene from solver import solve from units import format_ft_in @@ -81,7 +82,6 @@ """ -GROUND_CLASS = {t: t for t in ("lawn", "mulch", "gravel", "concrete", "deck", "drive")} OBJECT_NAMES = { "window": "Window", "door": "Door", @@ -123,12 +123,12 @@ def _local_bounds(self, geom: Geometry) -> tuple[float, float, float, float]: return min(us), min(vs), max(us), max(vs) def px(self, p: tuple[float, float]) -> tuple[float, float]: - u, v = self.local(p) + return self._page(*self.local(p)) + + def _page(self, u: float, v: float) -> tuple[float, float]: return ((u - self.minu) * self.k + PAD_PX, (v - self.minv) * self.k + PAD_PX) def to_local_geom(self, geom: Geometry) -> Geometry: - from shapely.affinity import affine_transform - a, b = self.along, self.out ox, oz = self.origin # u = a.x*x + a.z*z - a.(o); v = b.x*x + b.z*z - b.(o) @@ -151,10 +151,7 @@ def path(self, geom: Geometry) -> str: return " ".join(parts) def _ring(self, coords: Any, close: bool) -> str: - pts = [ - ((u - self.minu) * self.k + PAD_PX, (v - self.minv) * self.k + PAD_PX) - for u, v in coords - ] + pts = [self._page(u, v) for u, v in coords] d = "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts) return d + (" Z" if close else "") @@ -187,13 +184,12 @@ def render(scene: Scene, result: dict[str, Any], rules: LoadedRules | None = Non """The site plan for one solved scene, as a standalone SVG document.""" r = (rules or load_rules()).rules spot = result.get("spot") or result.get("nearest_considered") - walls = [p for p in scene.walls] + walls = scene.walls focus_parts: list[Geometry] = [LineString([p.a, p.b]) for p in walls] focus_parts.append(Point(scene.meter_xz)) if spot: focus_parts.append(Polygon(spot["footprint"])) - frame = _Frame(scene, _union_bounds(focus_parts)) - k = frame.k + frame = _Frame(scene, unary_union(focus_parts).envelope) out: list[str] = [] w, h = WIDTH_PX, frame.height summary = result.get("summary", "") @@ -210,7 +206,7 @@ def render(scene: Scene, result: dict[str, Any], rules: LoadedRules | None = Non for g in scene.ground: d = frame.path(g.polygon) if d: - out.append(f'') + out.append(f'') fog = frame.path(scene.unobserved_ground()) if fog: out.append(f'') @@ -248,8 +244,8 @@ def render(scene: Scene, result: dict[str, Any], rules: LoadedRules | None = Non if o.type in ("window", "door", "garage_door"): line = scene.wall_line(*o.span) out.append(f'') + piece = scene.piece_at(sum(o.span) / 2) for off in (-0.25, 0.25): - piece = scene.piece_at(sum(o.span) / 2) out.append( f'' ) @@ -262,10 +258,7 @@ def render(scene: Scene, result: dict[str, Any], rules: LoadedRules | None = Non elif o.type != "pool": shape = o.geom.buffer(0.3) if not isinstance(o.geom, Polygon) else o.geom out.append(f'') - at = _label_point(scene, o.geom, 1.2) - if at[0] is None: - at = (shape.centroid.x, shape.centroid.y) - _label(out, frame, at, name, "small") + _label(out, frame, _label_point(scene, o.geom, 1.2), name, "small") # Cable, battery and its clearance. route = result.get("route") @@ -273,11 +266,11 @@ def render(scene: Scene, result: dict[str, Any], rules: LoadedRules | None = Non line = LineString(route["polyline"]) out.append(f'') mid = line.interpolate(0.5, normalized=True) - piece = scene.piece_at(scene.s_of((mid.x, mid.y))) + s_mid = scene.s_of((mid.x, mid.y)) _label( out, frame, - piece.point(scene.s_of((mid.x, mid.y)), -2.4), + scene.piece_at(s_mid).point(s_mid, -2.4), f"Cable {short(route['length_ft'])}", "cable-text", ) @@ -285,9 +278,9 @@ def render(scene: Scene, result: dict[str, Any], rules: LoadedRules | None = Non fp = Polygon(spot["footprint"]) ring = min(r.clearances.gas_ft.value, r.clearances.ac_ft.value) out.append(f'') - cls = "battery" if spot["outcome"] == "pass" else "battery unsure" - if result.get("spot") is None: - cls = "battery unsure" + # A nearest_considered spot is never drawn as a pass. + passed = result.get("spot") is not None and spot["outcome"] == "pass" + cls = "battery" if passed else "battery unsure" out.append(f'') _dimensions(out, frame, scene, fp, result) @@ -296,20 +289,12 @@ def render(scene: Scene, result: dict[str, Any], rules: LoadedRules | None = Non out.append(f'') _label(out, frame, scene.point_at(0.0, -1.6), "Meter", "label") - _legend(out, frame, k, h, result) + _legend(out, frame, result) out.append("") return "\n".join(out) -def _union_bounds(parts: list[Geometry]) -> Geometry: - from shapely import unary_union - - return unary_union(parts).envelope - - -def _offset(line: Geometry, piece: Any, distance: float) -> Geometry: - from shapely.affinity import translate - +def _offset(line: Geometry, piece: Piece, distance: float) -> Geometry: return translate(line, piece.outward[0] * distance, piece.outward[1] * distance) @@ -362,7 +347,6 @@ def _dimensions( f'x2="{x + tx:.1f}" y2="{y + ty:.1f}"/>' ) label = f"{short(c['measured_ft'])} (min {short(c['threshold_ft'])})" - # Label beside the line's midpoint. lx, ly = (x1 + x2) / 2 + tx * 3.2, (y1 + y2) / 2 + ty * 3.2 + 4 out.append( f' None: - y = h - LEGEND_PX + 26 +def _legend(out: list[str], frame: _Frame, result: dict[str, Any]) -> None: + y = frame.height - LEGEND_PX + 26 x = PAD_PX # Scale bar: 5 ft. - bar = 5 * k + bar = 5 * frame.k out.append( f'' ) diff --git a/server/solver.py b/server/solver.py index ed3008189..0ca2ebb93 100644 --- a/server/solver.py +++ b/server/solver.py @@ -21,7 +21,7 @@ from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value -from scene import EPS, Piece, Scene, SceneObject, merge_intervals +from scene import EPS, Measured, Piece, Scene, SceneObject, merge_intervals from units import format_ft_in PASS, FAIL, UNSURE = "pass", "fail", "unsure" @@ -121,7 +121,6 @@ class Route: outcome: str length: float plus_minus: float - near_s: float polyline: list[tuple[float, float]] detours: list[dict[str, Any]] crossings: list[dict[str, Any]] @@ -147,7 +146,6 @@ def unsure(self) -> list[str]: class Solver: def __init__(self, scene: Scene, loaded: LoadedRules) -> None: self.scene = scene - self.loaded = loaded r: Rules = loaded.rules self.r = r self.W = r.battery.width_ft.value @@ -258,7 +256,7 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: return c if not self._covered(fp, self.unobserved_ground, ew): c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing_later = partial(self._missing_ground, fp, ew) + c.missing_later = partial(self._missing, "ground", fp, ew) c.reason = "The ground under the footprint was not seen." return c # A patch edge's error matters only where an allowed surface meets a disallowed or @@ -283,23 +281,14 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: c.reason = "The footprint sits within measurement error of a surface boundary." return c - def _missing_ground(self, fp: Polygon, radius: float) -> list[tuple[str, float, float]]: - """The stretch of ground within `radius` of the footprint that nobody saw.""" - region = self.unobserved_ground.intersection(fp.buffer(radius) if radius > 0 else fp) - extent = self.scene.s_extent(region) - return [("ground", *extent)] if extent else [] - def _unobserved(self, band: str) -> Geometry: return self.unobserved_ground if band == "ground" else self.unobserved_wall - def _missing_band( - self, band: str, fp: Polygon, radius: float - ) -> list[tuple[str, float, float]]: - if band == "ground": - return self._missing_ground(fp, radius) - region = self.unobserved_wall.intersection(fp.buffer(radius) if radius > 0 else fp) + def _missing(self, band: str, fp: Polygon, radius: float) -> list[tuple[str, float, float]]: + """The stretch of `band` within `radius` of the footprint that nobody saw.""" + region = self._unobserved(band).intersection(fp.buffer(radius) if radius > 0 else fp) extent = self.scene.s_extent(region) - return [("wall", *extent)] if extent else [] + return [(band, *extent)] if extent else [] def check_clearance( self, @@ -343,8 +332,7 @@ def check_clearance( radius = t + piece.plus_minus bands = ["ground", "wall"] if band == "ground+wall" else [band] unseen = [b for b in bands if not self._covered(fp, self._unobserved(b), radius)] - covered = not unseen - band = unseen[0] if unseen else bands[0] + missing_later = partial(self._missing, unseen[0], fp, radius) if unseen else None if c.outcome == UNSURE: if c.unsure_cause == "unknown_attribute": c.reason = ( @@ -356,12 +344,11 @@ def check_clearance( f"{c.subject} is {ft(c.measured or 0)} (± {ft(c.plus_minus or 0)}) from the " f"battery against a {ft(t)} rule: too close to call." ) - if not covered: - c.missing_later = partial(self._missing_band, band, fp, radius) + c.missing_later = missing_later return c - if not covered: + if missing_later: c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing_later = partial(self._missing_band, band, fp, radius) + c.missing_later = missing_later c.reason = ( f"Not everything within {ft(t)} of the battery was seen, so a {noun} could " "hide there." @@ -467,7 +454,7 @@ def check_measured( check_id: str, label: str, band: str, - entries: list, + entries: list[Measured], rule_key: str, rule: Value, s0: float, @@ -592,7 +579,6 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C None, rule_source="docs/02 Lane C: the cable can't cross a door, a garage or a " "stretch with no wall", - rule_placeholder=False, ) if "fail" in effects: path.outcome = FAIL @@ -649,7 +635,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C comparison="at_most", ) run = f"{ft(length)} (± {ft(e)})" - confident = r.confident_reach_ft.value + 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: @@ -672,7 +658,6 @@ 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, - near_s=near, polyline=self.scene.polyline(0.0, near), detours=detours, crossings=crossings, @@ -824,15 +809,16 @@ def starts(self, piece: Piece) -> list[float]: boundaries += [a, b] for g in self.scene.gaps: boundaries += [g.s0, g.s1] + ew = piece.error_at(max(abs(lo), abs(hi))) offsets = {0.0} for m in [*self.equipment, *self.scene.overheads, *self.scene.facing]: - for e in (m.plus_minus, m.plus_minus + piece.error_at(max(abs(lo), abs(hi)))): + for e in (m.plus_minus, m.plus_minus + ew): offsets |= {e, -e} for b in boundaries: for off in offsets: points += [b + off, b - W - off] rt = self.r.route - e_route = self.scene.meter_plus_minus + piece.error_at(max(abs(lo), abs(hi))) + e_route = self.scene.meter_plus_minus + ew for line in (rt.confident_reach_ft.value, rt.max_ft.value): for x in (line - e_route, line + e_route, line): points += [x, -x - W] @@ -866,25 +852,18 @@ def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float]]: """(geometry, offset) pairs whose offset outlines bound some check's outcome.""" c = self.r.clearances ew = piece.error_at(max(abs(piece.s0), abs(piece.s1))) + 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.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.drive_ft.value, g.polygon, g.plus_minus) for g in self.drives), + ] out: list[tuple[Geometry, float]] = [] - for t, objs in ( - (c.gas_ft.value, self.gas), - (c.ac_ft.value, self.ac), - (c.pool_ft.value, self.pool), - (c.opening_ft.value, self.openings), - ): - for o in objs: - for d in (t, t + o.plus_minus + ew, t - o.plus_minus - ew): - if d > 0: - out.append((o.geom, d)) - for g in self.drives: - for d in ( - c.drive_ft.value, - c.drive_ft.value + g.plus_minus + ew, - c.drive_ft.value - g.plus_minus - ew, - ): + for t, geom, err in items: + for d in (t, t + err + ew, t - err - ew): if d > 0: - out.append((g.polygon, d)) + out.append((geom, d)) for g in self.scene.ground: for d in (0.0, g.plus_minus + ew, -(g.plus_minus + ew)): out.append((g.polygon, d)) @@ -901,34 +880,27 @@ def out_of_reach(self) -> list[dict[str, Any]]: """Sweep runs for wall that is not evaluated start by start: segments too short for the battery, and stretches too far along for any route to pass.""" runs = [] + + def fail_run(piece: Piece, a: float, b: float, check_id: str) -> dict[str, Any]: + return { + "wall_id": piece.wall_id, + "segment": piece.index, + "start_ft": [_round(a), _round(b)], + "outcome": FAIL, + "failing": [check_id], + "unsure": [], + } + for piece in self.scene.walls: limit = self.reach_limit(piece) lo, hi = piece.s0, piece.s1 - self.W if hi < lo - EPS: # Too short for the battery: any start runs off the end of the segment. - runs.append( - { - "wall_id": piece.wall_id, - "segment": piece.index, - "start_ft": [_round(lo), _round(lo)], - "outcome": FAIL, - "failing": ["wall_backing"], - "unsure": [], - } - ) + runs.append(fail_run(piece, lo, lo, "wall_backing")) continue for a, b in ((lo, min(hi, -limit - self.W)), (max(lo, limit), hi)): if b - a > EPS: - runs.append( - { - "wall_id": piece.wall_id, - "segment": piece.index, - "start_ft": [_round(a), _round(b)], - "outcome": FAIL, - "failing": ["route_length"], - "unsure": [], - } - ) + runs.append(fail_run(piece, a, b, "route_length")) return runs @@ -1108,6 +1080,10 @@ def past_end_requests() -> list[dict[str, Any]]: reasons: list[dict[str, Any]] = [] missing: list[dict[str, Any]] = [] spot = best = nearest = None + unexplored_reason = { + "code": "unexplored_end", + "message": "The wall continues past an end of the scan within cable reach.", + } policy_reason = { "code": "policy_not_approved", "message": ( @@ -1152,12 +1128,7 @@ def past_end_requests() -> list[dict[str, Any]]: } ) if open_ends: - reasons.append( - { - "code": "unexplored_end", - "message": "The wall continues past an end of the scan within cable reach.", - } - ) + reasons.append(unexplored_reason) missing += past_end_requests() spot_at = where((best.s0 + best.s1) / 2) if any(c.unsure_cause == "unobserved" for c in best.checks if c.outcome == UNSURE): @@ -1170,21 +1141,19 @@ def past_end_requests() -> list[dict[str, Any]]: f"A person needs to check the best spot, {spot_at}: " + "; ".join(labels) + "." ) else: - nearest = ( - min(fails, key=lambda c: (len(c.failing()), len(c.unsure()), c.route.length, c.s0)) - if fails - else None + best = nearest = min( + fails, + key=lambda c: (len(c.failing()), len(c.unsure()), c.route.length, c.s0), + default=None, ) - best = nearest fail_counts: dict[str, int] = {} for c in fails: for i in c.failing(): fail_counts[i] = fail_counts.get(i, 0) + 1 + if far: + fail_counts["route_length"] = fail_counts.get("route_length", 0) + len(far) top = sorted(fail_counts, key=lambda i: -fail_counts[i]) if cands or far: - if far: - fail_counts["route_length"] = fail_counts.get("route_length", 0) + len(far) - top = sorted(fail_counts, key=lambda i: -fail_counts[i]) reasons.append( { "code": "all_spots_fail", @@ -1202,12 +1171,7 @@ def past_end_requests() -> list[dict[str, Any]]: ) can_reject = auto and r.policy.allow_reject and not open_ends if open_ends: - reasons.append( - { - "code": "unexplored_end", - "message": "The wall continues past an end of the scan within cable reach.", - } - ) + reasons.append(unexplored_reason) missing = past_end_requests() if not auto: reasons.append(policy_reason) diff --git a/server/tests/helpers.py b/server/tests/helpers.py index 5bf504edd..088a4b3ba 100644 --- a/server/tests/helpers.py +++ b/server/tests/helpers.py @@ -10,7 +10,10 @@ from rules import LoadedRules, deep_merge, public_rules_dict, rules_from_dict from scene import Scene, parse_scene -from solver import solve +from solver import Check, evaluate_start, solve + +W = 31 / 12 # battery width (31 in) +D = 11 / 6 # battery depth (22 in) # Test settings, not Base policy. GOLDEN_RULES: dict[str, Any] = { @@ -80,9 +83,14 @@ def shared_fixture() -> dict[str, Any]: } -def run(raw: dict[str, Any], rules: LoadedRules | None = None) -> dict[str, Any]: - loaded = rules or golden_rules() - return solve(parse_scene(copy.deepcopy(raw), loaded.rules), loaded) +def observed_band( + raw: dict[str, Any], band: str, spans: list[tuple[float, float]], out: float = 30 +) -> None: + """Replace one coverage band with the given observed spans.""" + extra = {"out_ft": out} if band == "ground" else {} + raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != band] + [ + {"band": band, "span_ft": list(s), **extra} for s in spans + ] def parsed(raw: dict[str, Any], rules: LoadedRules | None = None) -> Scene: @@ -90,5 +98,19 @@ def parsed(raw: dict[str, Any], rules: LoadedRules | None = None) -> Scene: return parse_scene(copy.deepcopy(raw), loaded.rules) +def run(raw: dict[str, Any], rules: LoadedRules | None = None) -> dict[str, Any]: + loaded = rules or golden_rules() + return solve(parsed(raw, loaded), loaded) + + def check(result: dict[str, Any], check_id: str) -> dict[str, Any]: return next(c for c in result["checks"] if c["id"] == check_id) + + +def at_start( + raw: dict[str, Any], s0: float, check_id: str, rules: LoadedRules | None = None +) -> Check: + """One check of the candidate starting at s0, evaluated directly.""" + loaded = rules or golden_rules() + candidate = evaluate_start(parsed(raw, loaded), loaded, s0) + return next(c for c in candidate.checks if c.id == check_id) diff --git a/server/tests/test_api.py b/server/tests/test_api.py index 301cd0430..d9b692446 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -284,7 +284,7 @@ def test_empty_body_is_400(client: TestClient) -> None: assert error(resp)["code"] == "empty_body" -def test_multipart_without_bundle_field_is_400(client: TestClient) -> None: +def test_multipart_with_a_field_other_than_bundle_is_400(client: TestClient) -> None: resp = client.post(PLACEMENTS, files={"scene": ("scene.json", EXAMPLE_BYTES)}) assert resp.status_code == 400 assert error(resp)["code"] == "unexpected_form_field" @@ -363,6 +363,9 @@ def test_solving_the_example_takes_under_a_second() -> None: assert time.perf_counter() - started < 1.0 +# --- OpenAPI -------------------------------------------------------------------------------------- + + def test_openapi_publishes_the_scene_request_body(client: TestClient) -> None: # Clients (the verification harness, a future SDK) discover the upload formats here. paths = client.get("/openapi.json").json()["paths"] diff --git a/server/tests/test_drift.py b/server/tests/test_drift.py index d623cf5cd..8b6c6644d 100644 --- a/server/tests/test_drift.py +++ b/server/tests/test_drift.py @@ -2,11 +2,8 @@ walked from the meter (rules.yaml errors.drift_per_ft, from S1's real-data drift evals).""" import pytest -from helpers import golden_rules, parsed, shared_fixture +from helpers import W, at_start, golden_rules, parsed, shared_fixture -from solver import evaluate_start - -W = 31 / 12 RULES = golden_rules( errors={ "tap_ft": {"value": 0.3, "source": "t"}, @@ -38,15 +35,14 @@ def test_object_default_error_grows_with_distance_walked() -> None: def test_wall_default_error_is_taken_at_the_batterys_far_edge() -> None: raw = shared_fixture() del raw["walls"][0]["plus_minus_ft"] - scene = parsed(raw, RULES) for s0 in (6.0, 12.0, -12.0): - reach = next(c for c in evaluate_start(scene, RULES, s0).checks if c.id == "route_length") + reach = at_start(raw, s0, "route_length", RULES) far = max(abs(s0), abs(s0 + W)) # The meter is exact in the fixture, so the route's error is the wall's at the far edge. assert reach.plus_minus == pytest.approx(0.3 + 0.16 * far) def test_explicit_wall_error_does_not_drift() -> None: - scene = parsed(shared_fixture(), RULES) # the fixture's wall has plus_minus_ft 0 - reach = next(c for c in evaluate_start(scene, RULES, 12.0).checks if c.id == "route_length") + # The fixture's wall has plus_minus_ft 0. + reach = at_start(shared_fixture(), 12.0, "route_length", RULES) assert reach.plus_minus == pytest.approx(0.0) diff --git a/server/tests/test_golden_public.py b/server/tests/test_golden_public.py index 5112dbdb5..04e9eb5ac 100644 --- a/server/tests/test_golden_public.py +++ b/server/tests/test_golden_public.py @@ -7,16 +7,19 @@ listed left to right as scene.schema.json requires; the expected plan positions are unchanged. """ -import copy import json import math from pathlib import Path import pytest from helpers import ( + D, + W, + at_start, check, everything_observed, golden_rules, + observed_band, pads_ground, parsed, rect, @@ -27,8 +30,6 @@ from solver import Solver, evaluate_start -W = 31 / 12 # battery width (31 in) -D = 11 / 6 # battery depth (22 in) RESULT = Draft202012Validator( json.loads((Path(__file__).parents[1] / "schemas" / "result.schema.json").read_text()) ) @@ -48,11 +49,6 @@ def approx(values, expected): assert flat(values) == pytest.approx(flat(expected), abs=1e-6) -def at_start(raw, s0, check_id, rules=None): - candidate = evaluate_start(parsed(raw, rules), rules or golden_rules(), s0) - return next(c for c in candidate.checks if c.id == check_id) - - def pad_runs_fail_only(result, check_id, pad=(6, 9)): """Every sweep run of starts on the pad fails exactly this one check.""" runs = [ @@ -222,12 +218,7 @@ def test_07_headroom_covers_the_whole_footprint(underside, plus_minus, expected) def test_07_unobserved_headroom_is_manual_review() -> None: raw = shared_fixture() - raw["coverage"]["observed"] = [ - o for o in raw["coverage"]["observed"] if o["band"] != "overhead" - ] + [ - {"band": "overhead", "span_ft": [-40, 5]}, - {"band": "overhead", "span_ft": [10, 40]}, - ] + observed_band(raw, "overhead", [(-40, 5), (10, 40)]) result = solve_valid(raw) assert result["decision"] == "manual_review" head = check(result, "headroom") @@ -383,12 +374,7 @@ def l_scene(variant): ground.append({"type": "deck", "polygon": rect(12, 40, -40, -8), "plus_minus_ft": 0}) with_gas(raw, [[12 + front, front], [12 + front, -1]], span=(12, 13)) raw["coverage"] = everything_observed(-40, 40) - raw["coverage"]["observed"] = [ - o for o in raw["coverage"]["observed"] if o["band"] != "ground" - ] + [ - {"band": "ground", "span_ft": [-40, 14], "out_ft": 30}, - {"band": "ground", "span_ft": [20, 40], "out_ft": 30}, - ] + observed_band(raw, "ground", [(-40, 14), (20, 40)]) raw["walls"], raw["ground"] = walls, ground return raw @@ -508,7 +494,7 @@ def test_14_result_records_its_measurements() -> None: def test_result_is_stable_for_the_same_input() -> None: a = run(shared_fixture()) - b = run(copy.deepcopy(shared_fixture())) + b = run(shared_fixture()) a["stats"].pop("elapsed_ms") b["stats"].pop("elapsed_ms") assert a == b diff --git a/server/tests/test_properties.py b/server/tests/test_properties.py index 353d57421..65ac89f0f 100644 --- a/server/tests/test_properties.py +++ b/server/tests/test_properties.py @@ -332,8 +332,7 @@ def test_the_uncut_pad_scene_passes() -> None: @given(case=coverage_cuts(), flip=st.booleans()) def test_missing_coverage_never_passes(case, flip: bool) -> None: pad, band, mode, at, size = case - base = scene(pad) - raw = cut(base, band, mode, at, size) + raw = cut(scene(pad), band, mode, at, size) if flip: raw = mirror(raw) result = run(raw, FAST) @@ -392,7 +391,7 @@ def test_mirror_of_a_scene_whose_pad_ends_at_its_own_edges() -> None: raw = scene((6.0, 9.0), [gas(-5.0), window(12.0)]) a, b = run(raw, FAST), run(mirror(raw), FAST) assert a["decision"] == PASS - assert_mirrored(a, b, 1e-6) + assert_mirrored(a, b, EXACT) # Regression: the grid used to be anchored at the wall's left end, so with a clearance line @@ -402,7 +401,7 @@ def test_mirror_is_exact_when_a_clearance_bounds_the_pad() -> None: raw["objects"] = [gas(2.3)] a, b = run(raw), run(mirror(raw)) assert a["decision"] == PASS - assert_mirrored(a, b, 1e-6) + assert_mirrored(a, b, EXACT) # --- (d) monotonicity: more room never hurts, a stricter rule never helps diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py index 5ffd048dc..b2b44672f 100644 --- a/server/tests/test_review_regressions.py +++ b/server/tests/test_review_regressions.py @@ -9,9 +9,12 @@ import pytest from helpers import ( + W, + at_start, check, everything_observed, golden_rules, + observed_band, pads_ground, parsed, rect, @@ -22,23 +25,6 @@ from scene import SceneError from solver import evaluate_start -W = 31 / 12 # battery width (31 in) -D = 11 / 6 # battery depth (22 in) - - -def at_start(raw, s0, check_id, rules=None): - candidate = evaluate_start(parsed(raw, rules), rules or golden_rules(), s0) - return next(c for c in candidate.checks if c.id == check_id) - - -def observed_band(raw, band, spans, out=30): - """Replace one coverage band with the given observed spans.""" - extra = {"out_ft": out} if band == "ground" else {} - raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != band] + [ - {"band": band, "span_ft": list(s), **extra} for s in spans - ] - - # --- 1. unexplored end ---------------------------------------------------------------------------- From 26d28707473f25f7c7d1f3660fbbf8321704f609 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 05:04:03 -0500 Subject: [PATCH 17/52] server: deploy to Vercel as its own project pyproject names the FastAPI entrypoint (tool.vercel.entrypoint = "api:app"). .vercelignore keeps the virtualenv, caches, tests and any private/ copy out of the upload; the deploy root is server/, so the repository's private/ rules can't reach it. The uv pin's floor drops to 0.10.11, the version Vercel's Python build runs, after checking it reads this uv.lock with --locked. README gives the demo URL, the deploy command and Vercel's 4.5 MB request limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/.gitignore | 3 +++ server/.vercelignore | 11 +++++++++++ server/README.md | 10 ++++++++++ server/pyproject.toml | 10 ++++++++-- 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 server/.vercelignore diff --git a/server/.gitignore b/server/.gitignore index 6cff60015..ca91c4098 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.py[cod] .pytest_cache/ .ruff_cache/ +.hypothesis/ +.vercel/ +.env* diff --git a/server/.vercelignore b/server/.vercelignore new file mode 100644 index 000000000..5da980132 --- /dev/null +++ b/server/.vercelignore @@ -0,0 +1,11 @@ +# Only the app and its public rules deploy. private/ holds Base's thresholds: it lives outside +# server/, and is listed here too so a copy placed inside can never upload. +private/ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.hypothesis/ +.vercel/ +tests/ +.env* diff --git a/server/README.md b/server/README.md index 0060ba5d8..d2ddb2f3c 100644 --- a/server/README.md +++ b/server/README.md @@ -15,6 +15,16 @@ Needs [uv](https://docs.astral.sh/uv/) 0.11. From `server/`: Without the web layer: `loaded = load_rules()`, then `solve(parse_scene(raw, loaded.rules), loaded)` (from `rules`, `scene` and `solver`). +## Deploy + +The public demo runs at **https://house-scanning-server.vercel.app** (Vercel project `house-scanning-server`, public rules only). Redeploy from this directory: + +```sh +npx vercel@latest deploy --prod --scope sam-gus-projects-7a4b6082 +``` + +Only `server/` uploads (`.vercelignore`), so the repository's `private/` rules can't reach a deployment; `/health` shows `sources: ["public"]`. Vercel caps a request body at 4.5 MB, so send it bare `scene.json` or a zip without the keyframe JPEGs; the solver doesn't read the images. The project isn't connected to Git: a deploy is always this command. + ## API | Endpoint | Body | Returns | diff --git a/server/pyproject.toml b/server/pyproject.toml index 2d3b20039..9a24657c1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -24,8 +24,14 @@ dev = [ [tool.uv] # Modules sit flat in this directory and run in place; there is nothing to build or install. package = false -# setup-uv reads this in CI, so local and CI runs use the same uv minor version as uv.lock. -required-version = ">=0.11.15,<0.12" +# setup-uv reads this in CI and installs the newest uv in range. The floor is 0.10.11 because +# Vercel's Python build runs that version, and it reads this uv.lock (checked with +# `uvx --from uv==0.10.11 uv sync --locked`). +required-version = ">=0.10.11,<0.12" + +# Vercel deploys this directory as its own project and serves this FastAPI app. +[tool.vercel] +entrypoint = "api:app" [tool.ruff] line-length = 100 From 70ab0b0155aac48457c1a3ef09f566318466b3d8 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 05:25:55 -0500 Subject: [PATCH 18/52] server: fix the second review's findings; serve the schemas Each fix has a test in tests/test_hardening.py that failed before it. 1. Unseen ground could pass the pool check: the modelled outdoor area stopped at the largest clearance plus the battery, short of the clearance plus the footprint's own error. It now reaches as far as the largest wall error anywhere adds. Property test: shrinking the observed ground never improves any check. 2. Start positions grew as boundaries times error offsets (a 23 KB scene needed 219,396). Each boundary now takes only its own offsets (3,709 for that scene), total starts are capped at 50,000, and a solve past 20 s stops with scene_too_complex (422). 3. A small zip could make the server parse a huge scene.json. scene.json is capped at 10 MB, checked from the zip's declared size before reading and again while reading; bare bodies likewise. 4. The reach cutoff took drift at the near edge though checks take it at the far edge, so starts up to drift x W past it were reported as failing unchecked (cutoff 24.524 ft, now 25.016). beyond_reach uses the same cutoff. 5. JSON nested 5000 deep raised RecursionError (500); the number check now walks with a stack, and such input is a 422. 6. A private value without its own source kept the public citation. Such a file is refused at startup, and answers show private citations only as "Private rules". Also: GET /v1/schemas/scene.json and /v1/schemas/result.json. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 7 +- server/api.py | 45 ++++++- server/rules.py | 38 +++++- server/scene.py | 42 +++--- server/solver.py | 96 ++++++++++---- server/tests/test_hardening.py | 229 +++++++++++++++++++++++++++++++++ 6 files changed, 402 insertions(+), 55 deletions(-) create mode 100644 server/tests/test_hardening.py diff --git a/server/README.md b/server/README.md index d2ddb2f3c..5bf3ff5fc 100644 --- a/server/README.md +++ b/server/README.md @@ -32,6 +32,7 @@ Only `server/` uploads (`.vercelignore`), so the repository's `private/` rules c | `GET /health` | | Which rules are loaded, and their hash | | `POST /v1/placements` | a scene | The result, `schemas/result.schema.json` | | `POST /v1/placements/site-plan.svg` | a scene | The site plan, `image/svg+xml` | +| `GET /v1/schemas/scene.json`, `GET /v1/schemas/result.json` | | The contract this server honours | A scene is bare `scene.json` (`application/json`), a zip holding `scene.json` and the JPEGs it names (`application/zip`), or either one as the multipart field `bundle`. `/openapi.json` declares all three. @@ -45,9 +46,9 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON | Status | When | | --- | --- | | 400 | The body or zip can't be read, or a zip entry has an absolute or `..` path | -| 413 | Over `HOUSESCAN_MAX_UPLOAD_MB` (default 256) or, unzipped, `HOUSESCAN_MAX_UNZIPPED_MB` (default 512) | +| 413 | Over `HOUSESCAN_MAX_UPLOAD_MB` (default 256), unzipped over `HOUSESCAN_MAX_UNZIPPED_MB` (default 512), or `scene.json` itself over `HOUSESCAN_MAX_SCENE_MB` (default 10) | | 415 | Not JSON, zip or multipart | -| 422 | The scene breaks `schemas/scene.schema.json`, names an image the zip lacks, or can't be placed (for example the meter is far from its wall) | +| 422 | The scene breaks `schemas/scene.schema.json`, names an image the zip lacks, can't be placed (for example the meter is far from its wall), or needs more than 50,000 positions or 20 s to check (`scene_too_complex`) | ## Writing a scene @@ -77,4 +78,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; because it has some, it sets `auto_approve: false`, which turns every pass or reject into manual review. At startup the server merges a git-ignored `private/rules.yaml` over it when one exists (path: `HOUSESCAN_PRIVATE_RULES`, else `/private/rules.yaml`). 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; because it has some, it sets `auto_approve: false`, which turns every pass or reject into manual review. At startup the server merges a git-ignored `private/rules.yaml` over it when one exists (path: `HOUSESCAN_PRIVATE_RULES`, else `/private/rules.yaml`). 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 05a8b609f..adec822a3 100644 --- a/server/api.py +++ b/server/api.py @@ -19,11 +19,12 @@ import re import zipfile import zlib +from pathlib import Path from typing import Any from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, Response +from fastapi.responses import FileResponse, JSONResponse, Response from starlette.concurrency import run_in_threadpool from starlette.datastructures import UploadFile from starlette.exceptions import HTTPException @@ -31,7 +32,7 @@ import siteplan from rules import LoadedRules, load_rules from scene import Scene, SceneError, parse_scene -from solver import SCHEMA_VERSION, solve +from solver import SCHEMA_VERSION, SceneTooComplex, solve log = logging.getLogger("housescan.api") @@ -54,6 +55,9 @@ def _limit_bytes(env: str, default_mb: float) -> int: # 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) # Loaded at import: invalid rules must stop the server before it answers anything. LOADED: LoadedRules = load_rules() @@ -143,6 +147,20 @@ def health() -> dict[str, Any]: return {"status": "ok", "schema_version": SCHEMA_VERSION, "policy": _policy()} +# The contract, served by the server that honours it, so clients can fetch the version they talk to. +SCHEMAS = Path(__file__).resolve().parent / "schemas" + + +@app.get("/v1/schemas/scene.json") +def scene_schema() -> FileResponse: + return FileResponse(SCHEMAS / "scene.schema.json", media_type="application/schema+json") + + +@app.get("/v1/schemas/result.json") +def result_schema() -> FileResponse: + return FileResponse(SCHEMAS / "result.schema.json", media_type="application/schema+json") + + # --- reading the upload --------------------------------------------------------------------------- @@ -218,6 +236,15 @@ def _unsafe(name: str) -> bool: return n.startswith("/") or bool(_DRIVE.match(n)) or ".." in n.split("/") +def _check_scene_size(size: int) -> None: + if size > MAX_SCENE_BYTES: + raise ApiError( + 413, + "scene_too_large", + f"scene.json is {size} bytes, over the {MAX_SCENE_BYTES // _MB} MB limit.", + ) + + 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.""" try: @@ -243,8 +270,12 @@ def _open_bundle(data: bytes) -> tuple[bytes, set[str], str]: ) names = {i.filename for i in infos if not i.is_dir()} prefix = _scene_folder(names) + _check_scene_size(zf.getinfo(prefix + "scene.json").file_size) try: - scene_bytes = zf.read(prefix + "scene.json") + # Read at most one byte past the cap: a declared size can lie. + with zf.open(prefix + "scene.json") as entry: + scene_bytes = entry.read(MAX_SCENE_BYTES + 1) + _check_scene_size(len(scene_bytes)) except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError, EOFError) as exc: raise ApiError( 400, "unreadable_zip", f"scene.json in the bundle can't be read: {exc}" @@ -311,6 +342,7 @@ def _parse(payload: bytes, is_zip: bool) -> Scene: raw = _load_json(scene_bytes, "scene.json") else: scene_bytes, names, prefix = payload, None, "" + _check_scene_size(len(payload)) raw = _load_json(payload, "The body") try: scene = parse_scene(raw, LOADED.rules, scene_bytes) @@ -323,7 +355,12 @@ def _parse(payload: bytes, is_zip: bool) -> Scene: def _solve(payload: bytes, is_zip: bool) -> tuple[Scene, dict[str, Any]]: scene = _parse(payload, is_zip) - return scene, solve(scene, LOADED) + try: + return scene, solve(scene, LOADED) + except SceneTooComplex as exc: + raise ApiError( + 422, "scene_too_complex", f"The server can't place this scene: {exc}." + ) from None # The routes read the raw body so one endpoint can take JSON, a zip or a form, so FastAPI can't diff --git a/server/rules.py b/server/rules.py index 368c9215f..96b18cd9b 100644 --- a/server/rules.py +++ b/server/rules.py @@ -134,6 +134,8 @@ class LoadedRules: rules: Rules sources: tuple[str, ...] sha256: str + # Dotted keys the private file overrides. Answers withhold their source text. + private_keys: frozenset[str] = frozenset() def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: @@ -154,10 +156,35 @@ def _read_yaml(path: Path) -> dict[str, Any]: return data -def rules_from_dict(data: dict[str, Any], sources: tuple[str, ...] = ("public",)) -> LoadedRules: +def rules_from_dict( + data: dict[str, Any], + sources: tuple[str, ...] = ("public",), + private_keys: frozenset[str] = frozenset(), +) -> LoadedRules: rules = Rules.model_validate(data) canonical = json.dumps(rules.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) - return LoadedRules(rules, sources, hashlib.sha256(canonical.encode()).hexdigest()) + return LoadedRules(rules, sources, hashlib.sha256(canonical.encode()).hexdigest(), private_keys) + + +def overridden_keys(public: dict[str, Any], private: dict[str, Any], path: str = "") -> set[str]: + """Dotted keys the private file sets. A private threshold must carry its own source: merged + over the public one, it would otherwise be shown with the public citation.""" + keys: set[str] = set() + for key, value in private.items(): + dotted = f"{path}{key}" + base = public.get(key) + if isinstance(value, dict) and isinstance(base, dict): + if "value" in base and "source" in base and "source" not in value: + raise ValueError( + f"private rules: {dotted} overrides a cited value without its own source" + ) + if "value" in base: + keys.add(dotted) + else: + keys |= overridden_keys(base, value, f"{dotted}.") + else: + keys.add(dotted) + return keys def public_rules_dict() -> dict[str, Any]: @@ -173,7 +200,10 @@ def load_rules(private_path: Path | None = None) -> LoadedRules: private_path = Path(env) if env else DEFAULT_PRIVATE_RULES if env and not private_path.is_file(): raise FileNotFoundError(f"{PRIVATE_RULES_ENV}={env} does not name a file") + private_keys: frozenset[str] = frozenset() if private_path.is_file(): - data = deep_merge(data, _read_yaml(private_path)) + private = _read_yaml(private_path) + private_keys = frozenset(overridden_keys(data, private)) + data = deep_merge(data, private) sources = ("public", "private") - return rules_from_dict(data, sources) + return rules_from_dict(data, sources, private_keys) diff --git a/server/scene.py b/server/scene.py index 094cad078..832440196 100644 --- a/server/scene.py +++ b/server/scene.py @@ -439,25 +439,27 @@ def _error(item: dict[str, Any], default: float) -> float: MAX_COORDINATE_FT = 1e5 -def _check_numbers(value: Any, path: str) -> None: - """JSON Schema accepts NaN and infinities as numbers; nothing in a scene may be either.""" - if isinstance(value, dict): - for k, v in value.items(): - _check_numbers(v, f"{path}/{k}") - elif isinstance(value, list): - for i, v in enumerate(value): - _check_numbers(v, f"{path}/{i}") - elif isinstance(value, float | int) and not isinstance(value, bool): - if not math.isfinite(value): - raise SceneError(path or "/", f"{value!r} is not a finite number") - if abs(value) > MAX_COORDINATE_FT: - raise SceneError( - path or "/", f"{value} ft is beyond the {MAX_COORDINATE_FT:g} ft bound" - ) +def _check_numbers(raw: Any) -> None: + """JSON Schema accepts NaN and infinities as numbers; nothing in a scene may be either. + Walks with an explicit stack, so deeply nested input can't exhaust Python's recursion.""" + stack: list[tuple[Any, str]] = [(raw, "")] + while stack: + value, path = stack.pop() + if isinstance(value, dict): + stack.extend((v, f"{path}/{k}") for k, v in value.items()) + elif isinstance(value, list): + stack.extend((v, f"{path}/{i}") for i, v in enumerate(value)) + elif isinstance(value, float | int) and not isinstance(value, bool): + if not math.isfinite(value): + raise SceneError(path or "/", f"{value!r} is not a finite number") + if abs(value) > MAX_COORDINATE_FT: + raise SceneError( + path or "/", f"{value} ft is beyond the {MAX_COORDINATE_FT:g} ft bound" + ) def validate_schema(raw: Any) -> None: - _check_numbers(raw, "") + _check_numbers(raw) errors = sorted(_VALIDATOR.iter_errors(raw), key=lambda e: list(e.absolute_path)) if errors: e = errors[0] @@ -581,12 +583,16 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N pieces = _join_straight_walls(pieces) 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 battery. + # How far out from the walls the outdoor area matters: the largest clearance, plus the + # battery, plus the largest wall error anywhere, because a check must have seen everything + # within its clearance plus the footprint's own error. Anything out there nobody observed + # then counts as unseen. c = rules.clearances reach = max( c.gas_ft.value, c.ac_ft.value, c.opening_ft.value, c.drive_ft.value, c.pool_ft.value ) - reach += rules.battery.depth_ft.value + 1.0 + 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 first, last = pieces[0], pieces[-1] ext_len = reach + rules.battery.width_ft.value left_ext = Piece( diff --git a/server/solver.py b/server/solver.py index 0ca2ebb93..8158b1e61 100644 --- a/server/solver.py +++ b/server/solver.py @@ -29,6 +29,14 @@ # Areas and lengths below this are floating point slivers, not geometry. _MEASURE_EPS = 1e-6 SCHEMA_VERSION = "1.0" +# Operational bounds on one request: a realistic scan needs a few thousand positions and well +# under a second. Past these the input is refused rather than tying the server up. +MAX_STARTS = 50_000 +SOLVE_BUDGET_S = 20.0 + + +class SceneTooComplex(ValueError): + """The scene needs more positions checked, or more time, than one request may take.""" @dataclass @@ -55,7 +63,17 @@ class Check: def all_missing(self) -> list[tuple[str, float, float]]: return self.missing + (self.missing_later() if self.missing_later else []) - def to_json(self) -> dict[str, Any]: + def rule_keys(self) -> tuple[str, ...]: + # route_length's citation names the confident reach too. + extra = ("route.confident_reach_ft",) if self.id == "route_length" else () + return (self.rule_key, *extra) + + def to_json(self, private_keys: frozenset[str] = frozenset()) -> dict[str, Any]: + source = self.rule.source if self.rule else self.rule_source + if _is_private(self.rule_keys(), private_keys): + # The private file's citations stay on the server; answers say only where the value + # came from. + source = "Private rules" out: dict[str, Any] = { "id": self.id, "label": self.label, @@ -68,7 +86,7 @@ def to_json(self) -> dict[str, Any]: "subject": self.subject, "rule": { "key": self.rule_key, - "source": self.rule.source if self.rule else self.rule_source, + "source": source, "placeholder": self.rule.placeholder if self.rule else self.rule_placeholder, }, } @@ -79,6 +97,13 @@ def to_json(self) -> dict[str, Any]: return out +def _is_private(keys: tuple[str, ...], private_keys: frozenset[str]) -> bool: + """Whether the private file set any of these rules or anything under them.""" + return any( + k == p or p.startswith(f"{k}.") or k.startswith(f"{p}.") for k in keys for p in private_keys + ) + + def _round(v: float | None) -> float | None: return None if v is None else round(v, 6) @@ -781,8 +806,12 @@ def reach_limit(self, piece: Piece) -> float: e_fixed = self.scene.meter_plus_minus + piece.plus_minus + detour_err if piece.drift >= 1: return math.inf - # The route's error grows with |s| by the wall's drift: solve |s| - e(|s|) = max. - return (self.r.route.max_ft.value + e_fixed) / (1 - piece.drift) + 2 * EPS + # The route's error grows by the wall's drift at the battery's far edge, |s| + W from the + # meter (as evaluate takes it): solve |s| - e(|s| + W) = max. + 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 def starts(self, piece: Piece) -> list[float]: """Start positions (left edge, in s) to evaluate on one straight segment.""" @@ -798,23 +827,24 @@ 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)] - # Along the wall: every place an interval can start or stop mattering. - boundaries = [0.0, *self.ws_span] + # 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) + boundaries: list[tuple[float, tuple[float, ...]]] = [(0.0, around)] + boundaries += [(b, around) for b in self.ws_span] for o in self.scene.objects: - boundaries += list(o.span) + e = o.plus_minus + boundaries += [(b, (0.0, e, -e, e + ew, -(e + ew))) for b in o.span] for m in self.scene.overheads + self.scene.facing: - boundaries += list(m.span) + boundaries += [(b, around) for b in m.span] for band in self.scene.observed.values(): for a, b, _ in band: - boundaries += [a, b] + boundaries += [(a, around), (b, around)] for g in self.scene.gaps: - boundaries += [g.s0, g.s1] - ew = piece.error_at(max(abs(lo), abs(hi))) - offsets = {0.0} - for m in [*self.equipment, *self.scene.overheads, *self.scene.facing]: - for e in (m.plus_minus, m.plus_minus + ew): - offsets |= {e, -e} - for b in boundaries: + boundaries += [(g.s0, around), (g.s1, around)] + for b, offsets in boundaries: for off in offsets: points += [b + off, b - W - off] rt = self.r.route @@ -869,11 +899,22 @@ def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float]]: out.append((g.polygon, d)) return out - def candidates(self) -> list[Candidate]: + 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" + ) out = [] - for piece in self.scene.walls: - for s0 in self.starts(piece): - out.append(self.evaluate(piece, s0)) + for i, (piece, s0) in enumerate(starts): + if i % 64 == 0 and time.perf_counter() - started > budget_s: + raise SceneTooComplex( + f"checking the scene took longer than {budget_s:g} seconds " + f"({i} of {len(starts)} positions done)" + ) + out.append(self.evaluate(piece, s0)) return out def out_of_reach(self) -> list[dict[str, Any]]: @@ -1036,25 +1077,28 @@ def _sweep_json(cands: list[Candidate], scene: Scene, step: float) -> list[dict[ return runs -def solve(scene: Scene, loaded: LoadedRules) -> dict[str, Any]: - """Decide where the battery goes. Pure apart from the elapsed time it reports.""" +def solve(scene: Scene, loaded: LoadedRules, budget_s: float = SOLVE_BUDGET_S) -> dict[str, Any]: + """Decide where the battery goes. Pure apart from the elapsed time it reports. + + Raises SceneTooComplex when the scene needs more positions checked, or more time, than a + request may take.""" started = time.perf_counter() solver = Solver(scene, loaded) r = loaded.rules - cands = solver.candidates() + cands = solver.candidates(budget_s) far = solver.out_of_reach() passes = [c for c in cands if c.outcome == PASS] unsures = [c for c in cands if c.outcome == UNSURE] fails = [c for c in cands if c.outcome == FAIL] auto = r.policy.auto_approve and r.policy.id is not None - e_end = scene.meter_plus_minus ends: dict[str, dict[str, Any]] = {} for side, s_end, pt, piece in ( ("left", scene.s_min, scene.walls[0].a, scene.walls[0]), ("right", scene.s_max, scene.walls[-1].b, scene.walls[-1]), ): - beyond = abs(s_end) - (e_end + piece.error_at(s_end)) - r.route.max_ft.value > EPS + # Any spot past this end has its near edge at least |s_end| out. + beyond = abs(s_end) > solver.reach_limit(piece) ends[side] = { "kind": scene.end_kinds[side], "s_ft": _round(s_end), @@ -1204,7 +1248,7 @@ def past_end_requests() -> list[dict[str, Any]]: }, "spot": _spot_json(solver, spot) if spot else None, "route": _route_json(solver, spot) if spot else None, - "checks": [c.to_json() for c in best.checks] if best else [], + "checks": [c.to_json(loaded.private_keys) for c in best.checks] if best else [], "nearest_considered": _spot_json(solver, nearest) if nearest else None, "missing_evidence": missing, "ends": ends, diff --git a/server/tests/test_hardening.py b/server/tests/test_hardening.py new file mode 100644 index 000000000..79e7c353d --- /dev/null +++ b/server/tests/test_hardening.py @@ -0,0 +1,229 @@ +"""Regression tests for the second review of #11 (public rules only); each failed before its fix.""" + +import io +import json +import time +import zipfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from helpers import W, at_start, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st + +import api +from rules import deep_merge, load_rules, public_rules_dict, rules_from_dict +from scene import SceneError, parse_scene +from solver import SceneTooComplex, Solver, evaluate_start, solve + +SERVER = Path(__file__).resolve().parents[1] +# 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 default_error_scene(ground_out: float = 30.0) -> dict: + """A straight wall where every position takes the rules' default error (AR drift included).""" + return { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w1"}, + "walls": [{"id": "w1", "baseline": [[-40, 0], [60, 0]]}], + "objects": [], + "ground": [{"type": "lawn", "polygon": rect(-60, 80, 0, 40)}], + "overheads": [], + "facing": [], + "coverage": { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-60, 80]}, + {"band": "ground", "span_ft": [-60, 80], "out_ft": ground_out}, + {"band": "overhead", "span_ft": [-60, 80]}, + {"band": "facing", "span_ft": [-60, 80]}, + ], + }, + } + + +# --- 1. unseen ground beyond the modelled outdoor area ------------------------------------------ + + +def test_ground_seen_short_of_the_pool_radius_plus_error_is_not_clear() -> None: + # Before: the outdoor area stopped at 12.83 ft, so ground 12.9 to 12.98 ft out counted as seen. + raw = default_error_scene(ground_out=12.9) + pool = at_start(raw, 2.71, "pool_clearance", PUBLIC) + assert (pool.outcome, pool.unsure_cause) == ("unsure", "unobserved") + + +@settings(max_examples=25, deadline=None) +@given( + out=st.floats(min_value=0.5, max_value=30.0), + shrink=st.floats(min_value=0.0, max_value=0.9), + s0=st.floats(min_value=-20.0, max_value=20.0), +) +def test_shrinking_observed_ground_never_helps(out: float, shrink: float, s0: float) -> None: + full = evaluate_start(parse_scene(default_error_scene(out), PUBLIC.rules), PUBLIC, s0) + less = evaluate_start( + parse_scene(default_error_scene(out * (1 - shrink)), PUBLIC.rules), PUBLIC, s0 + ) + rank = {"pass": 0, "unsure": 1, "fail": 2} + for before, after in zip(full.checks, less.checks, strict=True): + assert rank[after.outcome] >= rank[before.outcome], (before.id, before, after) + + +# --- 2. start positions grow with boundaries times offsets -------------------------------------- + + +def crowded_scene() -> dict: + raw = shared_fixture() + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [-39 + 0.7 * i, -38.9 + 0.7 * i], + "bottom_ft": 4, + "top_ft": 5, + "source": "tap", + "plus_minus_ft": 0.01 + 0.001 * i, + } + for i in range(100) + ] + raw["coverage"]["observed"] += [ + {"band": "wall", "span_ft": [-39.5 + 0.7 * i, -39.2 + 0.7 * i]} for i in range(100) + ] + return raw + + +def test_start_positions_grow_linearly() -> None: + # Before: every boundary was combined with every object's error offset: 174k starts. + scene = parse_scene(crowded_scene(), PUBLIC.rules) + solver = Solver(scene, PUBLIC) + assert sum(len(solver.starts(p)) for p in scene.walls) < 10_000 + + +def test_crowded_scene_solves_quickly() -> None: + started = time.perf_counter() + solve(parse_scene(crowded_scene(), PUBLIC.rules), PUBLIC) + assert time.perf_counter() - started < 10 + + +def test_a_solve_over_budget_stops_with_a_clear_error() -> None: + scene = parse_scene(shared_fixture(), PUBLIC.rules) + with pytest.raises(SceneTooComplex, match="seconds"): + solve(scene, PUBLIC, budget_s=0.0) + + +# --- 3. a small upload that parses to gigabytes ------------------------------------------------- + + +@pytest.fixture +def client() -> TestClient: + return TestClient(api.app) + + +def test_zipped_scene_json_over_the_cap_is_refused_unread(client: TestClient) -> None: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("scene.json", '{"a": ' + " " * (11 * 1024 * 1024) + "1}") + body = buffer.getvalue() + assert len(body) < 100_000 # compresses to almost nothing + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/zip"}) + assert resp.status_code == 413 + assert resp.json()["error"]["code"] == "scene_too_large" + + +def test_bare_scene_json_over_the_cap_is_refused(client, monkeypatch) -> None: + monkeypatch.setattr(api, "MAX_SCENE_BYTES", 1024) + body = json.dumps({"meter": {}, "pad": "x" * 2000}) + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/json"}) + assert resp.status_code == 413 + assert resp.json()["error"]["code"] == "scene_too_large" + + +# --- 4. the cable-reach cutoff -------------------------------------------------------------------- + + +def test_starts_past_the_reach_cutoff_really_fail_route_length() -> None: + # Before: the cutoff took drift at the near edge, so starts up to drift x W beyond it were + # reported as route_length FAIL without evaluation; s0 = 24.62 ft actually gives UNSURE. + raw = default_error_scene() + scene = parse_scene(raw, PUBLIC.rules) + limit = Solver(scene, PUBLIC).reach_limit(scene.walls[0]) + assert limit > 24.62 + for s0 in (limit + 1e-5, limit + 1, limit + 10, -limit - W - 1e-5, -limit - W - 5): + reach = at_start(raw, s0, "route_length", PUBLIC) + assert reach.outcome == "fail", (s0, reach.reason) + # No run failing only route_length starts short of the cutoff (the old cutoff was 0.5 ft + # short); runs within 1e-3 ft of it are evaluated starts that fail on their own. + result = solve(scene, PUBLIC) + for run in result["sweep"]: + if run["failing"] == ["route_length"] and run["start_ft"][0] > 0: + assert run["start_ft"][0] >= limit - 1e-3, run + + +def test_unexplored_end_within_drifted_reach_blocks_a_reject() -> None: + # An end at 24.6 ft: a spot just past it has its far edge 24.6 + W out, where the drifted + # error still leaves the route length unsure, so the end is not beyond reach. + raw = default_error_scene() + raw["walls"][0]["baseline"] = [[-40, 0], [24.6, 0]] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = solve(parse_scene(raw, PUBLIC.rules), PUBLIC) + assert result["ends"]["right"]["beyond_reach"] is False + + +# --- 5. deep JSON ------------------------------------------------------------------------------- + + +def test_deeply_nested_input_is_a_scene_error() -> None: + raw = shared_fixture() + deep: list = [] + for _ in range(5000): + deep = [deep] + raw["extra"] = deep + with pytest.raises(SceneError): + parse_scene(raw, PUBLIC.rules) + + +def test_deeply_nested_body_is_422(client: TestClient) -> None: + body = '{"meter": ' + "[" * 5000 + "]" * 5000 + "}" + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/json"}) + assert resp.status_code == 422 + + +# --- 6. private overrides and their citations --------------------------------------------------- + + +def test_private_value_without_its_own_source_is_refused(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text("clearances:\n gas_ft: {value: 4.0}\n") + with pytest.raises(ValueError, match=r"clearances\.gas_ft"): + load_rules(private) + + +def test_private_sources_are_withheld_from_answers(tmp_path: Path) -> None: + 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: 4.0, source: 'SECRET CITATION'}\n" + ) + loaded = load_rules(private) + result = solve(parse_scene(shared_fixture(), loaded.rules), loaded) + assert "SECRET CITATION" not in json.dumps(result) + gas = next(c for c in result["checks"] if c["id"] == "gas_clearance") + ac = next(c for c in result["checks"] if c["id"] == "ac_clearance") + assert gas["rule"]["source"] == "Private rules" + assert ac["rule"]["source"].startswith("Base help page") + + +# --- served schemas ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", ["scene", "result"]) +def test_schemas_are_served(client: TestClient, name: str) -> None: + resp = client.get(f"/v1/schemas/{name}.json") + assert resp.status_code == 200 + assert resp.json() == json.loads((SERVER / "schemas" / f"{name}.schema.json").read_text()) + + +def test_battery_width_constant_matches_rules() -> None: + assert pytest.approx(PUBLIC.rules.battery.width_ft.value) == W From bc026222df56540fba579b1e5e49c14c2ebfdb1a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 05:33:01 -0500 Subject: [PATCH 19/52] server: cap validation messages instead of echoing the input jsonschema's message quotes the whole offending value, so a large scene with a type error near the top was sent back in full. The message now keeps its first 200 characters and says how much was cut; the error's path already locates the problem. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 13 ++++++++++++- server/tests/test_hardening.py | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/server/scene.py b/server/scene.py index 832440196..8c652f6ad 100644 --- a/server/scene.py +++ b/server/scene.py @@ -464,7 +464,18 @@ def validate_schema(raw: Any) -> None: if errors: e = errors[0] path = "/" + "/".join(str(p) for p in e.absolute_path) - raise SceneError(path, e.message) + raise SceneError(path, _capped(e.message)) + + +# jsonschema quotes the whole offending value in its message; a refusal shouldn't echo a large +# upload back, and the path already says where the problem is. +MESSAGE_CHARS = 200 + + +def _capped(message: str) -> str: + if len(message) <= MESSAGE_CHARS: + return message + return f"{message[:MESSAGE_CHARS]}… ({len(message) - MESSAGE_CHARS} more characters cut)" def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = None) -> Scene: diff --git a/server/tests/test_hardening.py b/server/tests/test_hardening.py index 79e7c353d..8a7a9c6b7 100644 --- a/server/tests/test_hardening.py +++ b/server/tests/test_hardening.py @@ -227,3 +227,24 @@ def test_schemas_are_served(client: TestClient, name: str) -> None: def test_battery_width_constant_matches_rules() -> None: assert pytest.approx(PUBLIC.rules.battery.width_ft.value) == W + + +# --- validation messages don't echo the input --------------------------------------------------- + + +def test_validation_message_is_capped() -> None: + # Before: jsonschema's message quoted the whole offending value, so a 10 MB scene with a + # type error at the top would send 10 MB back. + raw = shared_fixture() + raw["meter"] = "x" * 20_000 + with pytest.raises(SceneError) as caught: + parse_scene(raw, PUBLIC.rules) + assert caught.value.path == "/meter" + assert len(caught.value.message) < 300 + + +def test_refusal_body_stays_small(client: TestClient) -> None: + body = json.dumps({**shared_fixture(), "meter": [[[[1] * 3000]]]}) + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/json"}) + assert resp.status_code == 422 + assert len(resp.content) < 600 From f2705dd5580ccd8c18ebd34a743b448bcb59ae03 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 05:35:14 -0500 Subject: [PATCH 20/52] server: cut only the quoted value in validation messages Truncating the whole message dropped jsonschema's verdict, which follows the quoted value ("... is too long"). Now the quote is cut to 200 characters and the verdict stays; the message as a whole is still capped at 1,000 characters. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 22 ++++++++++++++-------- server/tests/test_hardening.py | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/server/scene.py b/server/scene.py index 8c652f6ad..3c49486a0 100644 --- a/server/scene.py +++ b/server/scene.py @@ -464,18 +464,24 @@ def validate_schema(raw: Any) -> None: if errors: e = errors[0] path = "/" + "/".join(str(p) for p in e.absolute_path) - raise SceneError(path, _capped(e.message)) + raise SceneError(path, _capped(e.message, e.instance)) -# jsonschema quotes the whole offending value in its message; a refusal shouldn't echo a large -# upload back, and the path already says where the problem is. -MESSAGE_CHARS = 200 +# jsonschema quotes the whole offending value in its message (" is too long"); a refusal +# shouldn't echo a large upload back. The quote is cut to its start, keeping the verdict after it, +# and the path already says where the problem is. +QUOTE_CHARS = 200 +MESSAGE_CHARS = 1000 -def _capped(message: str) -> str: - if len(message) <= MESSAGE_CHARS: - return message - return f"{message[:MESSAGE_CHARS]}… ({len(message) - MESSAGE_CHARS} more characters cut)" +def _capped(message: str, instance: Any) -> str: + quoted = repr(instance) + if len(quoted) > QUOTE_CHARS and quoted in message: + cut = len(quoted) - QUOTE_CHARS + message = message.replace(quoted, f"{quoted[:QUOTE_CHARS]}… ({cut} characters cut)", 1) + if len(message) > MESSAGE_CHARS: + message = f"{message[:MESSAGE_CHARS]}…" + return message def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = None) -> Scene: diff --git a/server/tests/test_hardening.py b/server/tests/test_hardening.py index 8a7a9c6b7..3b0f87228 100644 --- a/server/tests/test_hardening.py +++ b/server/tests/test_hardening.py @@ -241,6 +241,7 @@ def test_validation_message_is_capped() -> None: parse_scene(raw, PUBLIC.rules) assert caught.value.path == "/meter" assert len(caught.value.message) < 300 + assert caught.value.message.endswith("is not of type 'object'") def test_refusal_body_stays_small(client: TestClient) -> None: From 739fb6f747294b7253eb7d8936315392a80d178a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 06:32:57 -0500 Subject: [PATCH 21/52] server: fixes from CodeRabbit's review of #11 - missing_evidence lists every band a clearance didn't see, not only the first: a gas clearance with both ground and wall unseen asked only for ground views, so the next scan was still UNSURE on the wall. Requested spans are also rounded outward, since an end rounded inward left a sliver that capturing exactly the listed span never covered. A property test checks that capturing what a manual review lists leaves no check UNSURE for want of coverage. - ground_surface cites ground.source, which covers the whole ground group, so any private ground key now withholds its citation. A private ground.drivable plus ground.source used to show the private citation. Checks name the extra keys they cite (`cites`), replacing the route_length special case. - Rules files refuse a repeated key instead of keeping the last copy. - scene.schema.json: attrs rejects unknown keys, as the schema says of every object. The app's export and the ETH3D replay scene still validate. - README: send bare scene.json to the hosted API; a zip without the JPEGs its scene.json names is refused with missing_bundle_file. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/rules.py | 24 +++- server/schemas/scene.schema.json | 1 + server/solver.py | 27 ++++- server/tests/test_coderabbit_review.py | 145 +++++++++++++++++++++++++ server/tests/test_hardening.py | 16 ++- 6 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 server/tests/test_coderabbit_review.py diff --git a/server/README.md b/server/README.md index 5bf3ff5fc..52027616c 100644 --- a/server/README.md +++ b/server/README.md @@ -23,7 +23,7 @@ The public demo runs at **https://house-scanning-server.vercel.app** (Vercel pro npx vercel@latest deploy --prod --scope sam-gus-projects-7a4b6082 ``` -Only `server/` uploads (`.vercelignore`), so the repository's `private/` rules can't reach a deployment; `/health` shows `sources: ["public"]`. Vercel caps a request body at 4.5 MB, so send it bare `scene.json` or a zip without the keyframe JPEGs; the solver doesn't read the images. The project isn't connected to Git: a deploy is always this command. +Only `server/` uploads (`.vercelignore`), so the repository's `private/` rules can't reach a deployment; `/health` shows `sources: ["public"]`. Vercel caps a request body at 4.5 MB, so send bare `scene.json`: the solver doesn't read the images, and a zip must hold every JPEG its `scene.json` names or it is refused with 422 `missing_bundle_file`. The project isn't connected to Git: a deploy is always this command. ## API diff --git a/server/rules.py b/server/rules.py index 96b18cd9b..88e8e2b51 100644 --- a/server/rules.py +++ b/server/rules.py @@ -149,8 +149,30 @@ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any] return merged +class _UniqueKeyLoader(yaml.SafeLoader): + """SafeLoader that refuses a repeated key. PyYAML keeps the last copy silently, so a rules + file with two `clearances:` blocks would load a threshold nobody meant.""" + + +def _unique_mapping(loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool = False) -> Any: + seen: set[Any] = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in seen: + raise ValueError( + f"duplicate key {key!r} at line {key_node.start_mark.line + 1} of " + f"{key_node.start_mark.name}" + ) + seen.add(key) + return loader.construct_mapping(node, deep=deep) + + +_UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping) + + def _read_yaml(path: Path) -> dict[str, Any]: - data = yaml.safe_load(path.read_text()) + with path.open() as f: + data = yaml.load(f, Loader=_UniqueKeyLoader) if not isinstance(data, dict): raise ValueError(f"{path}: expected a mapping at the top level") return data diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index f214360c8..77c141e40 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -61,6 +61,7 @@ "attrs": { "type": "object", "description": "Recognised attributes. A missing attribute is unknown, never false.", + "additionalProperties": false, "properties": { "operable": {"type": "boolean"}, "well": {"type": "boolean"} diff --git a/server/solver.py b/server/solver.py index 8158b1e61..3a914fbdc 100644 --- a/server/solver.py +++ b/server/solver.py @@ -59,14 +59,15 @@ class Check: missing: list[tuple[str, float, float]] = field(default_factory=list) # Computing the exact unseen stretch is slow, so it is deferred until a result reports it. missing_later: Callable[[], list[tuple[str, float, float]]] | None = None + # Other rule keys whose values or citation the check uses; if the private file set any of + # them, the citation is withheld. + cites: tuple[str, ...] = () def all_missing(self) -> list[tuple[str, float, float]]: return self.missing + (self.missing_later() if self.missing_later else []) def rule_keys(self) -> tuple[str, ...]: - # route_length's citation names the confident reach too. - extra = ("route.confident_reach_ft",) if self.id == "route_length" else () - return (self.rule_key, *extra) + return (self.rule_key, *self.cites) def to_json(self, private_keys: frozenset[str] = frozenset()) -> dict[str, Any]: source = self.rule.source if self.rule else self.rule_source @@ -108,6 +109,12 @@ def _round(v: float | None) -> float | None: return None if v is None else round(v, 6) +def _outward(a: float, b: float) -> list[float]: + """A requested view's span, rounded to 6 decimals away from its middle: rounding an end + inward would leave a sliver that capturing exactly the listed span never covers.""" + return [math.floor(a * 1e6) / 1e6, math.ceil(b * 1e6) / 1e6] + + def at_least(value: float, error: float, threshold: float) -> str: """A clearance: PASS when value - error > threshold, FAIL when value + error < threshold.""" if value - error - threshold > EPS: @@ -268,6 +275,8 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: None, rule_source=self.r.ground.source, rule_placeholder=self.r.ground.placeholder, + # The citation is ground.source, shared by the whole ground group. + cites=("ground",), ) ew = piece.plus_minus # "Clearly on a disallowed surface" erodes the patch by the error, rounded up to 0.1 ft @@ -315,6 +324,13 @@ def _missing(self, band: str, fp: Polygon, radius: float) -> list[tuple[str, flo extent = self.scene.s_extent(region) return [(band, *extent)] if extent else [] + def _missing_bands( + self, bands: list[str], fp: Polygon, radius: float + ) -> list[tuple[str, float, float]]: + """Every unseen band, so one recapture settles the check (a clearance can need both the + ground and the wall).""" + return [m for band in bands for m in self._missing(band, fp, radius)] + def check_clearance( self, check_id: str, @@ -357,7 +373,7 @@ def check_clearance( radius = t + piece.plus_minus bands = ["ground", "wall"] if band == "ground+wall" else [band] unseen = [b for b in bands if not self._covered(fp, self._unobserved(b), radius)] - missing_later = partial(self._missing, unseen[0], fp, radius) if unseen else None + missing_later = partial(self._missing_bands, unseen, fp, radius) if unseen else None if c.outcome == UNSURE: if c.unsure_cause == "unknown_attribute": c.reason = ( @@ -658,6 +674,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C threshold=r.max_ft.value, review_threshold=cr.value, comparison="at_most", + cites=("route.confident_reach_ft",), ) run = f"{ft(length)} (± {ft(e)})" confident = cr.value @@ -1040,7 +1057,7 @@ def _missing_json(c: Candidate) -> list[dict[str, Any]]: { "kind": "band", "band": band, - "span_ft": [_round(a), _round(b)], + "span_ft": _outward(a, b), "checks": ids, "message": f"Show the {_BAND_TEXT[band]} from {where(a)} to {where(b)}.", } diff --git a/server/tests/test_coderabbit_review.py b/server/tests/test_coderabbit_review.py new file mode 100644 index 000000000..4ff641a24 --- /dev/null +++ b/server/tests/test_coderabbit_review.py @@ -0,0 +1,145 @@ +"""Regression tests for CodeRabbit's review of #11; each failed before its fix.""" + +import copy +from pathlib import Path + +import pytest +from helpers import at_start, golden_rules, observed_band, parsed, run, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st + +from rules import load_rules +from scene import SceneError +from solver import SOLVE_BUDGET_S, UNSURE, Solver, evaluate_start + +# --- missing_evidence names every unseen band ------------------------------------------------ + + +def both_bands_unseen() -> dict: + """The lawn pad at s 6 to 9 is the only spot; wall and ground are unseen from 9.5 to 11, + inside the gas clearance's reach but not behind or under the battery.""" + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 9.5), (11, 40)]) + observed_band(raw, "ground", [(-40, 9.5), (11, 40)]) + return raw + + +def test_a_check_reports_every_band_it_did_not_see() -> None: + # Before: gas_clearance (ground and wall) reported only the ground, so a recapture of the + # ground left it UNSURE on the wall. + gas = at_start(both_bands_unseen(), 6.0, "gas_clearance") + assert (gas.outcome, gas.unsure_cause) == (UNSURE, "unobserved") + assert {band for band, _, _ in gas.all_missing()} == {"ground", "wall"} + + +def test_missing_evidence_lists_the_check_under_each_band() -> None: + result = run(both_bands_unseen()) + for band in ("ground", "wall"): + item = next(m for m in result["missing_evidence"] if m.get("band") == band) + assert "gas_clearance" in item["checks"], item + + +BANDS = ("wall", "ground", "overhead", "facing") + + +@st.composite +def partly_seen_scenes(draw: st.DrawFn) -> dict: + """The shared wall with random gaps in each band's coverage and a gas meter somewhere.""" + raw = shared_fixture() + gas_at = draw(st.floats(min_value=-12, max_value=20)) + raw["objects"] = [ + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [gas_at, gas_at + 1], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tape", + "plus_minus_ft": 0.05, + "footprint": [[gas_at, 0], [gas_at + 1, 0], [gas_at + 1, 1], [gas_at, 1]], + } + ] + for band in BANDS: + # Pairs of cuts: each pair is a gap in the band's coverage. + cuts = draw(st.lists(st.floats(min_value=-15, max_value=25), max_size=4)) + edges = [-40.0, *sorted(cuts[: len(cuts) // 2 * 2]), 40.0] + spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b > a] + out = draw(st.floats(min_value=1, max_value=30)) + observed_band(raw, band, spans or [(-40.0, -39.0)], out) + return raw + + +@settings(max_examples=60, deadline=None) +@given(raw=partly_seen_scenes()) +def test_capturing_what_missing_evidence_asks_for_settles_coverage(raw: dict) -> None: + # With equal gas and opening clearances, the opening check asks for the same stretch of wall + # and hides a gas check that forgets it; rules may set them apart. + rules = golden_rules(clearances={"opening_ft": {"value": 1.0}}) + result = run(raw, rules) + # missing_evidence is the list of views that would settle a manual review; a pass or a + # reject lists none. + spot = result["spot"] + if result["decision"] != "manual_review" or spot is None: + return + # The capture shows each listed stretch; for ground, out as far as the checks reach. + reach = parsed(raw, rules).reach_ft + captured = copy.deepcopy(raw) + for item in result["missing_evidence"]: + if item["kind"] == "band": + extra = {"out_ft": reach} if item["band"] == "ground" else {} + captured["coverage"]["observed"].append( + {"band": item["band"], "span_ft": item["span_ft"], **extra} + ) + # The answer rounds the spot to 6 decimals; evaluate the exact start the solver chose, since + # a start rounded down can reach a sliver of a stretch that was never missing. + exact = min( + (c.s0 for c in Solver(parsed(raw, rules), rules).candidates(SOLVE_BUDGET_S)), + key=lambda s0: abs(s0 - spot["span_ft"][0]), + ) + after = evaluate_start(parsed(captured, rules), rules, exact) + unseen = [c.id for c in after.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + assert unseen == [], (unseen, result["missing_evidence"]) + + +# --- duplicate keys in rules files -------------------------------------------------------------- + + +def test_a_duplicate_key_in_the_private_rules_is_refused(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text( + "clearances:\n gas_ft: {value: 4.0, source: 'first'}\n" + "clearances:\n ac_ft: {value: 4.0, source: 'second'}\n" + ) + with pytest.raises(ValueError, match=r"duplicate key 'clearances'"): + load_rules(private) + + +def test_a_duplicate_nested_key_is_refused(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text( + "clearances:\n gas_ft: {value: 4.0, source: 'a'}\n gas_ft: {value: 5.0, source: 'b'}\n" + ) + with pytest.raises(ValueError, match=r"duplicate key 'gas_ft'"): + load_rules(private) + + +# --- attrs rejects unknown keys ------------------------------------------------------------------- + + +def test_a_misspelled_attribute_is_refused() -> None: + # Before: {"operabel": false} passed and read as unknown, so a typo sent the spot to review. + raw = shared_fixture() + raw["objects"] = [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [-14, -11], + "bottom_ft": 3, + "top_ft": 7, + "attrs": {"operabel": False}, + "source": "tap", + } + ] + with pytest.raises(SceneError) as caught: + parsed(raw) + assert caught.value.path == "/objects/0/attrs" diff --git a/server/tests/test_hardening.py b/server/tests/test_hardening.py index 3b0f87228..4092aa3f5 100644 --- a/server/tests/test_hardening.py +++ b/server/tests/test_hardening.py @@ -205,16 +205,30 @@ def test_private_sources_are_withheld_from_answers(tmp_path: Path) -> None: private.write_text( "policy: {id: p, version: '1', auto_approve: true, allow_reject: true}\n" "clearances:\n gas_ft: {value: 4.0, source: 'SECRET CITATION'}\n" + # The ground surface check cites ground.source, set here alongside another ground key. + "ground:\n drivable: [drive]\n source: 'SECRET GROUND CITATION'\n" ) loaded = load_rules(private) result = solve(parse_scene(shared_fixture(), loaded.rules), loaded) - assert "SECRET CITATION" not in json.dumps(result) + assert "SECRET" not in json.dumps(result) gas = next(c for c in result["checks"] if c["id"] == "gas_clearance") ac = next(c for c in result["checks"] if c["id"] == "ac_clearance") + ground = next(c for c in result["checks"] if c["id"] == "ground_surface") assert gas["rule"]["source"] == "Private rules" + assert ground["rule"]["source"] == "Private rules" assert ac["rule"]["source"].startswith("Base help page") +def test_a_private_ground_list_is_not_shown_with_the_public_citation(tmp_path: Path) -> None: + # ground has one citation for the whole group; a private list under it makes it private. + private = tmp_path / "rules.yaml" + private.write_text("ground:\n allowed: [concrete, gravel]\n") + loaded = load_rules(private) + result = solve(parse_scene(shared_fixture(), loaded.rules), loaded) + ground = next(c for c in result["checks"] if c["id"] == "ground_surface") + assert ground["rule"]["source"] == "Private rules" + + # --- served schemas ----------------------------------------------------------------------------- From 0bad4ded8865cf600885b386a4cfbe0ab4a724c9 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 07:47:31 -0500 Subject: [PATCH 22/52] server: fixes from S4's end-to-end run at 739fb6f - Every band request can be settled by the view it asks for. Past an unexplored end the walls may turn any way, so no view settles the area there and it is no longer requested (the past_end request covers it); in front of the scanned walls that area is asked for as ground, since a view of the ground is what rules a wall out. Zero-length slivers at an end are dropped. S4's c5-no-coverage and the app's replay upload asked for the same span again after it was shown. - Ground past a limit end is required within a clearance's reach and counts on both sides of the wall's continued line: a fence or corner doesn't make the ground beyond it clear (Sam's decision). Ground seen past the end covers it, so the request is met by pointing the camera past the end. S4's g05 passed pool_clearance with that ground unseen. - facing_gap and headroom report the range a value may take when the deciding measurement only might lie over the battery (within the wall's position error of its edge), so the numbers give the same UNSURE under the C5 rule; they reported a clear fail's numbers. - Faster: the coverage test is an exact distance instead of intersecting a buffered footprint (ETH3D scene 351 -> 136 ms, the app's replay upload 449 -> 130 ms locally). The budget drops from 20 s to 8 s and a scene is refused once the pace of its first 256 positions projects past it. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 5 +- server/scene.py | 50 +++++++- server/schemas/scene.schema.json | 2 +- server/solver.py | 151 +++++++++++++++++----- server/tests/test_s4_round.py | 207 +++++++++++++++++++++++++++++++ 5 files changed, 376 insertions(+), 39 deletions(-) create mode 100644 server/tests/test_s4_round.py diff --git a/server/README.md b/server/README.md index 52027616c..4c0881e49 100644 --- a/server/README.md +++ b/server/README.md @@ -48,7 +48,7 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON | 400 | The body or zip can't be read, or a zip entry has an absolute or `..` path | | 413 | Over `HOUSESCAN_MAX_UPLOAD_MB` (default 256), unzipped over `HOUSESCAN_MAX_UNZIPPED_MB` (default 512), or `scene.json` itself over `HOUSESCAN_MAX_SCENE_MB` (default 10) | | 415 | Not JSON, zip or multipart | -| 422 | The scene breaks `schemas/scene.schema.json`, names an image the zip lacks, can't be placed (for example the meter is far from its wall), or needs more than 50,000 positions or 20 s to check (`scene_too_complex`) | +| 422 | The scene breaks `schemas/scene.schema.json`, names an image the zip lacks, can't be placed (for example the meter is far from its wall), or needs more than 50,000 positions or 8 s to check (`scene_too_complex`; refused as soon as the pace of the first positions shows it) | ## Writing a scene @@ -57,6 +57,7 @@ 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. - 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. @@ -74,7 +75,7 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON | `facing_gap`, `headroom` | Enough room in front and above | | `route_path`, `route_length` | The cable runs along continuous wall, and isn't too long | -A check passes only when its margin exceeds its error and fails only when it misses by more than its error; anything else is UNSURE with an `unsure_cause`. `missing_evidence` lists the views that would settle an unseen area. A reject needs every spot within cable reach to fail and both ends of the walk known. +A check passes only when its margin exceeds its error and fails only when it misses by more than its error; anything else is UNSURE with an `unsure_cause`. `missing_evidence` lists the views that would settle an unseen area; showing exactly what a `band` request names settles it, and a `past_end` request asks to walk on. When a measurement only might lie in front of or over the battery (its end is within the wall's position error of the battery's edge), `measured_ft ± plus_minus_ft` spans the values the check could have, so the numbers give the same UNSURE. A reject needs every spot within cable reach to fail and both ends of the walk known. ## Rules diff --git a/server/scene.py b/server/scene.py index 3c49486a0..a6d1c07f3 100644 --- a/server/scene.py +++ b/server/scene.py @@ -203,15 +203,24 @@ def polyline(self, s_a: float, s_b: float) -> list[Point2]: pts.append(self.point_at(hi)) return pts if s_a <= s_b else pts[::-1] + def _past_limit(self, p: Piece) -> bool: + """Whether p is the straight continuation past an end the walk marked as a limit.""" + side = "left" if p is self.pieces[0] else "right" if p is self.pieces[-1] else None + return side is not None and self.end_kinds.get(side) == "limit" + def band_polygon(self, s_lo: float, s_hi: float, out: float) -> Geometry: """The ground strip in front of the chain from s_lo to s_hi, out to `out` from the wall, - with the wedges that fill the outside of convex corners.""" + with the wedges that fill the outside of convex corners. Past a limit end there is no + house behind the continued line, so the strip there covers both sides of it: a fence or + corner doesn't make the ground beyond it clear, and a camera pointed past the end from + where the walk stopped sees both sides.""" if s_hi - s_lo <= EPS or out <= EPS: return Polygon() parts: list[Geometry] = [] inside = [p for p in self.pieces if p.s1 > s_lo + EPS and p.s0 < s_hi - EPS] for p in inside: - parts.append(p.rect(max(s_lo, p.s0), min(s_hi, p.s1), 0.0, out)) + near = -out if self._past_limit(p) else 0.0 + parts.append(p.rect(max(s_lo, p.s0), min(s_hi, p.s1), near, out)) for prev, nxt in itertools.pairwise(inside): parts.append(_wedge(prev.b, prev.outward, nxt.outward, out)) return unary_union(parts) @@ -283,12 +292,41 @@ def _unexplored_discs(self) -> list[Geometry]: known = unary_union([seen_in_front, behind]) return [d.difference(known) for d in discs] - def unobserved_wall(self) -> Geometry: - if "wall" not in self._cache: + 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 + if band == "ground": + self._cache[key] = self.band_polygon(lo, hi, self.reach_ft) + else: + self._cache[key] = self.wall_line(lo, hi).buffer(1e-6, cap_style="flat") + return self._cache[key] + + def unobserved_wall_lines(self) -> Geometry: + """Stretches of the chain's line nobody saw, the part a view of the wall settles.""" + if "wall-lines" not in self._cache: lo, hi = self.pieces[0].s0, self.pieces[-1].s1 gaps = subtract_intervals((lo, hi), self.observed_intervals("wall")) - lines = [self.wall_line(a, b) for a, b in gaps] - self._cache["wall"] = unary_union([*lines, *self._unexplored_discs()]) + self._cache["wall-lines"] = unary_union([self.wall_line(a, b) for a, b in gaps]) + return self._cache["wall-lines"] + + def unexplored_area(self) -> Geometry: + """Where the walls past an unexplored end might run: in front of the scanned walls only + a view of the ground there rules that out.""" + if "unexplored" not in self._cache: + self._cache["unexplored"] = unary_union(self._unexplored_discs()) + return self._cache["unexplored"] + + def unobserved_wall(self) -> Geometry: + if "wall" not in self._cache: + self._cache["wall"] = unary_union( + [self.unobserved_wall_lines(), self.unexplored_area()] + ) return self._cache["wall"] diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index 77c141e40..fee4c9824 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -151,7 +151,7 @@ "enum": ["wall", "ground", "overhead", "facing"], "description": "wall: the wall face from the ground up to headroom height (boxes and vents above the battery, openings), which also covers the cable route. 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."}, + "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": "Required for the ground band: how far out from the wall the ground was seen."} }, "if": {"properties": {"band": {"const": "ground"}}}, diff --git a/server/solver.py b/server/solver.py index 3a914fbdc..889cdf5ab 100644 --- a/server/solver.py +++ b/server/solver.py @@ -26,13 +26,17 @@ PASS, FAIL, UNSURE = "pass", "fail", "unsure" _SEVERITY = {PASS: 0, UNSURE: 1, FAIL: 2} -# Areas and lengths below this are floating point slivers, not geometry. +# Areas, lengths and distances below this are floating point noise, not geometry. _MEASURE_EPS = 1e-6 SCHEMA_VERSION = "1.0" # Operational bounds on one request: a realistic scan needs a few thousand positions and well -# under a second. Past these the input is refused rather than tying the server up. +# under a second. Past these the input is refused rather than tying the server up. The budget is +# wall-clock on the hosted server, which ran about 4x slower than the development Mac (ETH3D scene: +# 1.6 s there against 0.4 s locally at 739fb6f), and sits far under Vercel's 300 s function limit. MAX_STARTS = 50_000 -SOLVE_BUDGET_S = 20.0 +SOLVE_BUDGET_S = 8.0 +# Positions evaluated before the total time is projected; enough for a stable per-position cost. +PROJECT_AFTER = 256 class SceneTooComplex(ValueError): @@ -105,6 +109,19 @@ def _is_private(keys: tuple[str, ...], private_keys: frozenset[str]) -> bool: ) +# A buffer's round corners are polygons inscribed in the true circle (16 segments a quarter +# turn), so they fall short of the radius by up to 0.12%. Scaling by 1/cos of half a segment's +# angle makes them circumscribe it: the region reaches at least as far as the exact distance +# test in Solver._covered. +_BUFFER_SEGMENTS = 16 +_CIRCUMSCRIBE = 1 / math.cos(math.pi / (4 * _BUFFER_SEGMENTS)) + + +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 + + def _round(v: float | None) -> float | None: return None if v is None else round(v, 6) @@ -224,19 +241,11 @@ def _buffered(self, geom: Geometry, distance: float) -> Geometry: return self._buffers[key] def _covered(self, fp: Polygon, unobserved: Geometry, radius: float) -> bool: - """True when nothing unobserved lies within `radius` of the footprint. Unseen areas count - by area (touching along an edge is not overlap) and unseen wall lines by length.""" - if unobserved.is_empty: - return True - if fp.distance(unobserved) > radius + _MEASURE_EPS: - return True - near = unobserved.intersection(fp.buffer(radius) if radius > 0 else fp) - for part in getattr(near, "geoms", [near]): - if part.geom_type.endswith("Polygon") and part.area > _MEASURE_EPS: - return False - if part.geom_type.endswith("LineString") and part.length > _MEASURE_EPS: - return False - return True + """True when nothing unobserved lies strictly within `radius` of the footprint: an unseen + area that only touches that distance (an exact shared edge) does not count. An exact + distance, rather than intersecting a buffered footprint, is both cheaper (this is most + of the solve time) and free of the buffer's polygonal approximation of a circle.""" + return unobserved.is_empty or fp.distance(unobserved) >= radius - _MEASURE_EPS # --- checks -------------------------------------------------------------------------------- @@ -319,10 +328,32 @@ def _unobserved(self, band: str) -> Geometry: return self.unobserved_ground if band == "ground" else self.unobserved_wall def _missing(self, band: str, fp: Polygon, radius: float) -> list[tuple[str, float, float]]: - """The stretch of `band` within `radius` of the footprint that nobody saw.""" - region = self._unobserved(band).intersection(fp.buffer(radius) if radius > 0 else fp) - extent = self.scene.s_extent(region) - return [(band, *extent)] if extent else [] + """The views that would show what nobody saw within `radius` of the footprint. Only what + a view can settle is requested; the rest lies past an unexplored end, which the + past_end request covers. For the wall band, the area past an unexplored end that lies in + front of the scanned walls is settled by a view of the ground there, so it is asked for + as ground.""" + within = _within(fp, radius) + if band == "ground": + parts = [("ground", self.unobserved_ground)] + else: + parts = [ + ("wall", self.scene.unobserved_wall_lines()), + ("ground", self.scene.unexplored_area()), + ] + out = [] + for view, unseen in parts: + region = unseen.intersection(within).intersection(self.scene.coverable(view)) + # 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] > _MEASURE_EPS + ] + if extents: + out.append((view, min(a for a, _ in extents), max(b for _, b in extents))) + return out def _missing_bands( self, bands: list[str], fp: Polygon, radius: float @@ -509,6 +540,9 @@ def check_measured( c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") missing = self.scene.missing(band, s0, s1) 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 + only_maybe = False for m in entries: # plus_minus is the error of the measured height or depth; where the stretch sits # along the wall is known to the wall's error. A measurement that lies under the @@ -520,19 +554,25 @@ def check_measured( if not possible: continue definite = a + wall_error < s1 - EPS and b - wall_error > s0 + EPS - outcome = at_least(m.value - subtract, m.plus_minus, t) - if not definite and outcome == FAIL: + value = m.value - subtract + if definite and (surely is None or value < surely[0]): + surely = (value, m.plus_minus) + outcome = at_least(value, m.plus_minus, t) + maybe = not definite and outcome == FAIL + if maybe: outcome = UNSURE - key = (_SEVERITY[outcome], -(m.value - m.plus_minus)) + key = (_SEVERITY[outcome], -(value - m.plus_minus)) if worst_key is None or key > worst_key: - worst_key = key - c.outcome, c.measured, c.plus_minus = outcome, m.value - subtract, m.plus_minus + worst_key, only_maybe = key, maybe + c.outcome, c.measured, c.plus_minus = outcome, value, m.plus_minus c.subject = f"{'overheads' if band == 'overhead' else 'facing'}[{m.index}]" val = f"{ft(c.measured or 0)} (± {ft(c.plus_minus or 0)})" if c.outcome == FAIL: c.reason = f"The {noun} is {val}; the rule needs more than {ft(t)}." return c - if c.outcome == UNSURE: + if c.outcome == UNSURE and only_maybe: + self._report_range(c, surely, wall_error, noun) + elif c.outcome == UNSURE: c.unsure_cause = "margin" c.reason = f"The {noun} is {val} against a {ft(t)} rule: too close to call." elif missing: @@ -546,6 +586,34 @@ def check_measured( c.missing = [(band, a, b) for a, b in missing] return c + @staticmethod + def _report_range( + c: Check, surely: tuple[float, float] | None, wall_error: float, noun: str + ) -> None: + """The deciding entry fails the rule but only might lie over the battery: its end is + within the wall's position error of the battery's edge. The value is then somewhere + between that entry's and the lowest one surely over the battery, and is reported as + that range (middle ± half its width plus the larger measurement error), so the numbers + give UNSURE under the C5 rule as the check did.""" + low, low_err = c.measured or 0.0, c.plus_minus or 0.0 + c.unsure_cause = "margin" + maybe = ( + f"{c.subject} ({ft(low)}) may or may not be over the battery: its end is within the " + f"wall's ± {ft(wall_error)} of the battery's edge" + ) + if surely is None: + # Nothing else surely lies over the battery, so nothing bounds the value from above. + c.measured = c.plus_minus = None + c.reason = f"{maybe}, and nothing else limits the {noun} there." + return + high, high_err = surely + c.measured = (low + high) / 2 + c.plus_minus = (high - low) / 2 + max(low_err, high_err) + c.reason = ( + f"{maybe}. Elsewhere over the battery the {noun} is {ft(high)}, so it is between " + f"{ft(low)} and {ft(high)} against a {ft(c.threshold or 0)} rule: too close to call." + ) + def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, Check]: r = self.r.route near = s0 if s0 > 0 else (s1 if s1 < 0 else 0.0) @@ -926,11 +994,20 @@ def candidates(self, budget_s: float) -> list[Candidate]: ) out = [] for i, (piece, s0) in enumerate(starts): - if i % 64 == 0 and time.perf_counter() - started > budget_s: + elapsed = time.perf_counter() - started + if i % 64 == 0 and elapsed > budget_s: raise SceneTooComplex( f"checking the scene took longer than {budget_s:g} seconds " f"({i} of {len(starts)} positions done)" ) + # Refuse as soon as the pace shows the budget can't be met, rather than after + # spending it. + if i == PROJECT_AFTER and elapsed / i * len(starts) > budget_s: + raise SceneTooComplex( + f"checking its {len(starts)} battery positions would take about " + f"{elapsed / i * len(starts):.0f} seconds, more than the {budget_s:g} this " + "server allows" + ) out.append(self.evaluate(piece, s0)) return out @@ -1044,7 +1121,7 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: } -def _missing_json(c: Candidate) -> list[dict[str, Any]]: +def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: by_band: dict[str, list[tuple[float, float, str]]] = {} for chk in c.checks: for band, a, b in chk.all_missing(): @@ -1059,12 +1136,26 @@ def _missing_json(c: Candidate) -> list[dict[str, Any]]: "band": band, "span_ft": _outward(a, b), "checks": ids, - "message": f"Show the {_BAND_TEXT[band]} from {where(a)} to {where(b)}.", + "message": f"Show the {_BAND_TEXT[band]} from {where(a)} to {where(b)}." + + _past_end_hint(scene, a, b), } ) return out +def _past_end_hint(scene: Scene, a: float, b: float) -> str: + """Only a limit end has requests past it (see Scene.coverable); they are met from where the + walk stopped.""" + sides = [ + s for s, past in (("left", a < scene.s_min - EPS), ("right", b > scene.s_max + EPS)) if past + ] + if not sides: + return "" + return ( + f" Part of it is past the {' and '.join(sides)} end: point the camera there from the end." + ) + + 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.""" @@ -1179,7 +1270,7 @@ def past_end_requests() -> list[dict[str, Any]]: + ".", } ) - missing = _missing_json(best) + missing = _missing_json(best, scene) if missing: reasons.append( { diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py new file mode 100644 index 000000000..b94302b79 --- /dev/null +++ b/server/tests/test_s4_round.py @@ -0,0 +1,207 @@ +"""Regression tests for S4's end-to-end run at 739fb6f; each failed before its fix.""" + +import copy + +import pytest +from helpers import at_start, observed_band, parsed, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st + +import solver +from rules import LoadedRules, public_rules_dict, rules_from_dict +from solver import FAIL, PASS, UNSURE, at_least, evaluate_start, solve + +PUBLIC = rules_from_dict(public_rules_dict()) +# Ground captured in answer to a request is seen this far out: past every clearance's reach. +FAR_FT = 40.0 + + +def answer(raw: dict, rules: LoadedRules = PUBLIC) -> dict: + return solve(parsed(raw, rules), rules) + + +def captured(raw: dict, result: dict) -> dict: + """The scene after the homeowner shows exactly the band views the answer asked for.""" + out = copy.deepcopy(raw) + observed = out.setdefault("coverage", {}).setdefault("observed", []) + for item in result["missing_evidence"]: + if item["kind"] == "band": + extra = {"out_ft": FAR_FT} if item["band"] == "ground" else {} + observed.append({"band": item["band"], "span_ft": item["span_ft"], **extra}) + return out + + +def repeated_requests(raw: dict, result: dict) -> list[dict]: + """Band requests the scene already satisfies: the same request coming back.""" + seen: dict[str, list[tuple[float, float]]] = {} + for o in raw.get("coverage", {}).get("observed", []): + if o["band"] != "ground" or o.get("out_ft", 0) >= FAR_FT: + seen.setdefault(o["band"], []).append(tuple(o["span_ft"])) + return [ + item + for item in result["missing_evidence"] + if item["kind"] == "band" + and any( + a <= item["span_ft"][0] and item["span_ft"][1] <= b + for a, b in seen.get(item["band"], []) + ) + ] + + +# --- 1. every request can be satisfied by what it asks for -------------------------------------- + + +def no_coverage() -> dict: + """S4 case c5-no-coverage: a 16 ft wall, nothing observed, both ends unexplored.""" + return { + "schema_version": "1.0", + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0}, + "walls": [{"id": "w1", "baseline": [[-6, 0.0], [10, 0.0]], "plus_minus_ft": 0.0}], + "objects": [], + "ground": [ + { + "type": "concrete", + "polygon": [[-6, 0], [10, 0], [10, 10], [-6, 10]], + "plus_minus_ft": 0, + } + ], + "facing": [{"wall_id": "w1", "span_ft": [-6.0, 10.0], "depth_ft": 9.0, "plus_minus_ft": 0}], + } + + +def test_requests_past_an_unexplored_end_do_not_come_back() -> None: + # Before: ground [-13.9, -6] and wall [-6.9, -6], past the unexplored left end, were asked + # for again after being shown; only walking past the end (a past_end request) settles them. + raw = no_coverage() + for _ in range(3): + result = answer(raw) + assert repeated_requests(raw, result) == [] + raw = captured(raw, result) + assert any(m["kind"] == "past_end" for m in result["missing_evidence"]) + + +@st.composite +def scenes_with_ends(draw: st.DrawFn) -> dict: + """The shared wall shortened at random, each end limit or unexplored, coverage with gaps.""" + raw = shared_fixture() + left = draw(st.floats(min_value=-12, max_value=-0.5)) + right = draw(st.floats(min_value=12, max_value=25)) + raw["walls"][0]["baseline"] = [[left, 0], [right, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [left, right] + raw["coverage"]["ends"] = { + side: {"kind": draw(st.sampled_from(["limit", "unexplored"]))} for side in ("left", "right") + } + for band in ("wall", "ground", "overhead", "facing"): + cuts = draw(st.lists(st.floats(min_value=left, max_value=right), max_size=4)) + edges = [left, *sorted(cuts[: len(cuts) // 2 * 2]), right] + spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b > a] + observed_band(raw, band, spans or [(left, left + 0.1)], draw(st.floats(1, 30))) + return raw + + +@settings(max_examples=40, deadline=None) +@given(raw=scenes_with_ends()) +def test_a_captured_request_never_comes_back(raw: dict) -> None: + for _ in range(3): + result = answer(raw) + assert repeated_requests(raw, result) == [], result["missing_evidence"] + raw = captured(raw, result) + + +# --- 2. ground past a limit end is not clear until it is seen ------------------------------------ + + +def limit_end_near_the_spot() -> dict: + """A limit end at s = -1, 7 ft left of the only spot: the pool clearance reaches past it.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-1, 0], [40, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-1, 40] + for band in ("wall", "ground", "overhead", "facing"): + observed_band(raw, band, [(-1, 40)]) + return raw + + +def test_pool_clearance_needs_the_ground_past_a_limit_end() -> None: + # Before: past a limit end the ground was never required, so the pool check passed with + # nothing seen beyond the fence. + raw = limit_end_near_the_spot() + result = answer(raw) + pool = next(c for c in result["checks"] if c["id"] == "pool_clearance") + assert (pool["outcome"], pool["unsure_cause"]) == (UNSURE, "unobserved") + ground = next(m for m in result["missing_evidence"] if m.get("band") == "ground") + assert ground["span_ft"][0] < -1 # past the end, shown by pointing the camera there + + +def test_showing_the_ground_past_a_limit_end_settles_it() -> None: + raw = limit_end_near_the_spot() + after = answer(captured(raw, answer(raw))) + pool = next(c for c in after["checks"] if c["id"] == "pool_clearance") + assert pool["outcome"] == PASS + + +def test_ground_behind_the_line_past_a_limit_end_counts() -> None: + # Ground seen only in front of the continued wall line leaves the other side unseen. + raw = limit_end_near_the_spot() + observed_band(raw, "ground", [(-20, 40)], out=FAR_FT) + s0 = answer(raw)["spot"]["span_ft"][0] + pool = at_start(raw, s0, "pool_clearance", PUBLIC) + assert pool.outcome == PASS # a span past the end covers both sides of the line + + +# --- 3. the reported error is the one the decision used ----------------------------------------- + + +def uncertain_obstruction() -> dict: + """An obstruction in front of the wall ends 0.3 ft before the battery, and the wall is only + known to ± 0.6 ft, so it may or may not be in front of the battery.""" + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.6 + raw["facing"] = [ + {"wall_id": "w1", "span_ft": [-40, 5.7], "depth_ft": 9, "plus_minus_ft": 0.1}, + {"wall_id": "w1", "span_ft": [5.7, 6.7], "depth_ft": 1.0, "plus_minus_ft": 0.1}, + {"wall_id": "w1", "span_ft": [6.7, 40], "depth_ft": 9, "plus_minus_ft": 0.1}, + ] + return raw + + +def test_a_band_check_reports_numbers_that_give_its_outcome() -> None: + # Before: "-0 ft 10 in (± 0 ft 1 in) against a 3 ft rule: too close to call", which the + # C5 rule makes a clear fail; the position error that made it UNSURE went unreported. + gap = evaluate_start(parsed(uncertain_obstruction(), PUBLIC), PUBLIC, 7.0) + facing = next(c for c in gap.checks if c.id == "facing_gap") + assert facing.outcome == UNSURE + assert at_least(facing.measured, facing.plus_minus, facing.threshold) == UNSURE + assert "facing[1]" in facing.reason + + +@settings(max_examples=60, deadline=None) +@given(s0=st.floats(min_value=4.0, max_value=9.0)) +def test_band_check_numbers_agree_with_c5(s0: float) -> None: + candidate = evaluate_start(parsed(uncertain_obstruction(), PUBLIC), PUBLIC, s0) + for c in candidate.checks: + decided_by_numbers = c.outcome in (PASS, FAIL) or c.unsure_cause == "margin" + if c.id in ("facing_gap", "headroom") and c.measured is not None and decided_by_numbers: + assert at_least(c.measured, c.plus_minus, c.threshold) == c.outcome, c + + +# --- 4. a scene too slow to place is refused early ------------------------------------------------ + + +def test_a_scene_that_would_overrun_is_refused_early(monkeypatch: pytest.MonkeyPatch) -> None: + # Before: the solver spent the whole budget before refusing. Now the pace after the first + # PROJECT_AFTER positions decides. A clock that advances 1 ms per reading makes the pace + # exact: the shared wall's positions project past a budget not yet spent at that point. + scene = parsed(shared_fixture(), PUBLIC) + positions = sum(len(solver.Solver(scene, PUBLIC).starts(p)) for p in scene.walls) + readings = iter(range(10**6)) + + class Clock: + @staticmethod + def perf_counter() -> float: + return next(readings) / 1000 + + monkeypatch.setattr(solver, "time", Clock) + budget = (solver.PROJECT_AFTER + positions) / 2 / 1000 + with pytest.raises(solver.SceneTooComplex, match="would take about"): + solver.solve(scene, PUBLIC, budget_s=budget) + assert next(readings) < solver.PROJECT_AFTER + 10 From 779f0c3c7d2e460b9228444e42104f67d69650e6 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 07:50:13 -0500 Subject: [PATCH 23/52] server: at radius 0, unseen ground under the footprint is not clear The exact-distance coverage test could not tell overlap from touching at radius 0 (a wall with no error): both are a distance of 0, so unseen ground under the footprint counted as seen. S4's harness caught it (ground_surface passed with no coverage in 26 cases). At radius 0 the test is overlap by area or line length again. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 14 +++++++++++++- server/tests/test_s4_round.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/server/solver.py b/server/solver.py index 889cdf5ab..f5895366a 100644 --- a/server/solver.py +++ b/server/solver.py @@ -245,7 +245,19 @@ def _covered(self, fp: Polygon, unobserved: Geometry, radius: float) -> bool: area that only touches that distance (an exact shared edge) does not count. An exact distance, rather than intersecting a buffered footprint, is both cheaper (this is most of the solve time) and free of the buffer's polygonal approximation of a circle.""" - return unobserved.is_empty or fp.distance(unobserved) >= radius - _MEASURE_EPS + if unobserved.is_empty: + return True + if radius > _MEASURE_EPS: + return fp.distance(unobserved) >= radius - _MEASURE_EPS + # At radius 0 (a wall with no error) a distance of 0 is either overlap or touching; only + # overlap, by area or by length along a line, leaves part of the footprint unseen. + overlap = unobserved.intersection(fp) + for part in getattr(overlap, "geoms", [overlap]): + if part.geom_type.endswith("Polygon") and part.area > _MEASURE_EPS: + return False + if part.geom_type.endswith("LineString") and part.length > _MEASURE_EPS: + return False + return True # --- checks -------------------------------------------------------------------------------- diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index b94302b79..e11482108 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -205,3 +205,18 @@ def perf_counter() -> float: with pytest.raises(solver.SceneTooComplex, match="would take about"): solver.solve(scene, PUBLIC, budget_s=budget) assert next(readings) < solver.PROJECT_AFTER + 10 + + +def test_unseen_ground_under_an_exact_footprint_is_not_clear() -> None: + # The coverage test at radius 0 (a wall with no error) must still see unseen ground under + # the footprint itself; a distance of 0 there is overlap, not touching. + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 5), (10, 40)]) + ground = at_start(raw, 6.0, "ground_surface", PUBLIC) + assert (ground.outcome, ground.unsure_cause) == (UNSURE, "unobserved") + + +def test_ground_seen_right_up_to_the_footprint_is_clear() -> None: + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 6), (6, 40)]) + assert at_start(raw, 6.0, "ground_surface", PUBLIC).outcome == PASS From 55cb4b027034ce600c4015d38617e2683c8eb88c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 08:06:35 -0500 Subject: [PATCH 24/52] server: a capture contract a first upload can meet - README "What settles each check": for each check, the scene fields it reads and the coverage (band, span, depth) that settles it, with the errors applied. A test supplies exactly that coverage for one spot and shows nothing stays unseen, and that 0.01 ft less leaves a check unseen. - Requests say how far: ground, facing and overhead items in missing_evidence carry out_ft (result schema, additive). Supplying the span with at least that out_ft settles them; the capture properties now supply exactly the request, depth included. - facing and overhead coverage may carry out_ft: seen clear only that far (a walked path, a tilt-up frame). Where no measurement covers the battery's stretch, facing_gap and headroom need that depth to exceed the rule, else they are UNSURE for coverage and ask for it. Absent out_ft keeps its meaning: the view reached whatever is there. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 31 ++++ server/scene.py | 28 ++++ server/schemas/result.schema.json | 3 +- server/schemas/scene.schema.json | 2 +- server/solver.py | 105 +++++++++---- server/tests/test_capture_contract.py | 201 +++++++++++++++++++++++++ server/tests/test_coderabbit_review.py | 13 +- server/tests/test_s4_round.py | 22 ++- 8 files changed, 362 insertions(+), 43 deletions(-) create mode 100644 server/tests/test_capture_contract.py diff --git a/server/README.md b/server/README.md index 4c0881e49..ae2a18b20 100644 --- a/server/README.md +++ b/server/README.md @@ -61,6 +61,37 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON - 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. +## What settles each check + +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 0.3 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). + +| Check | Reads | Coverage that settles it | +| --- | --- | --- | +| `wall_backing` | `walls` | wall band over [s0, s1] | +| `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 | +| `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 | +| `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] | +| `wall_equipment_above` (r = 0) | `objects` of type `elec_box`, `vent` | wall band over [s0 − r, s1 + r] | +| `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 | +| `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`; `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). + +Ends and corners: + +- A corner the walk follows is the next wall in `walls`, starting at the corner point; the chain is then continuous and the corner raises nothing. Keep walking until the answer's `ends..beyond_reach` is true or the wall really ends. +- `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 and overhead requests carry one). + ## Reading a result `decision` is `pass`, `manual_review` or `reject`. `spot` gives the battery's plan footprint and `meter_offset_ft`, its offset from the meter for AR; `route` is the cable run. Each entry in `checks` has an outcome, a reason, the measurement, its error and the rule it was held to: diff --git a/server/scene.py b/server/scene.py index a6d1c07f3..7ea7831c5 100644 --- a/server/scene.py +++ b/server/scene.py @@ -292,6 +292,34 @@ def _unexplored_discs(self) -> list[Geometry]: known = unary_union([seen_in_front, behind]) return [d.difference(known) for d in discs] + def seen_to(self, band: str, s_lo: float, s_hi: float) -> float: + """How far out (or up) the band was seen over all of [s_lo, s_hi]: at each s the + 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, []) + ] + 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 + for p, q in itertools.pairwise(cuts): + if q - p <= EPS: + continue + here = max((out for a, b, out in spans if a <= p + EPS and q - EPS <= b), default=0.0) + least = min(least, here) + return least + + def farthest_out(self, region: Geometry) -> float: + """The largest distance from the chain's line (walls and their continuations past the + ends) of any point of `region`: how far out a ground view must reach to cover it.""" + line = LineString(self.polyline(self.pieces[0].s0, self.pieces[-1].s1)) + points = [ + Point(x, z) + for part in getattr(region, "geoms", [region]) + if not part.is_empty + for x, z in (part.exterior.coords if hasattr(part, "exterior") else part.coords) + ] + return max((line.distance(p) for p in points), default=0.0) + 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 diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index 64c1cccdd..9c294c909 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -58,7 +58,7 @@ "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] }, "missing_evidence": { - "description": "Views that would settle an UNSURE result. Only areas nobody observed appear here; an UNSURE caused by a measurement inside its error band needs a person, not more photos. Empty for pass and reject.", + "description": "Views that would settle an UNSURE result. Only areas nobody observed appear here; an UNSURE caused by a measurement inside its error band needs a person, not more photos. Empty for pass and reject. Supplying exactly what a band request names settles it: a coverage.observed entry with the same band and span_ft, and out_ft at least the request's out_ft.", "type": "array", "items": { "type": "object", @@ -68,6 +68,7 @@ "kind": {"enum": ["band", "past_end"]}, "band": {"enum": ["wall", "ground", "overhead", "facing"]}, "span_ft": {"$ref": "#/$defs/span"}, + "out_ft": {"type": "number", "minimum": 0, "description": "For ground, facing and overhead requests: how far the view must reach, out from the wall (ground, facing) or up from the ground (overhead). Report it as the observed entry's out_ft; seeing further also settles it."}, "side": {"enum": ["left", "right"]}, "checks": {"type": "array", "items": {"type": "string"}}, "message": {"type": "string"} diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index fee4c9824..c5416cd66 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -152,7 +152,7 @@ "description": "wall: the wall face from the ground up to headroom height (boxes and vents above the battery, openings), which also covers the cable route. 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": "Required for the ground band: how far out from the wall the ground was seen."} + "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`."} }, "if": {"properties": {"band": {"const": "ground"}}}, "then": {"required": ["out_ft"]} diff --git a/server/solver.py b/server/solver.py index f5895366a..2dd392146 100644 --- a/server/solver.py +++ b/server/solver.py @@ -21,7 +21,7 @@ from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value -from scene import EPS, Measured, Piece, Scene, SceneObject, merge_intervals +from scene import EPS, Measured, Piece, Scene, SceneObject, merge_intervals, subtract_intervals from units import format_ft_in PASS, FAIL, UNSURE = "pass", "fail", "unsure" @@ -43,6 +43,18 @@ class SceneTooComplex(ValueError): """The scene needs more positions checked, or more time, than one request may take.""" +@dataclass(frozen=True) +class View: + """A view that would settle part of a check: `band` over s from a to b, seen at least `out` + far (ground and facing: out from the wall; overhead: up from the ground). None for the wall + band, which has no depth.""" + + band: str + a: float + b: float + out: float | None = None + + @dataclass class Check: id: str @@ -60,14 +72,14 @@ class Check: comparison: str | None = None subject: str | None = None unsure_cause: str | None = None - missing: list[tuple[str, float, float]] = field(default_factory=list) + missing: list[View] = field(default_factory=list) # Computing the exact unseen stretch is slow, so it is deferred until a result reports it. - missing_later: Callable[[], list[tuple[str, float, float]]] | None = None + missing_later: Callable[[], list[View]] | None = None # Other rule keys whose values or citation the check uses; if the private file set any of # them, the citation is withheld. cites: tuple[str, ...] = () - def all_missing(self) -> list[tuple[str, float, float]]: + def all_missing(self) -> list[View]: return self.missing + (self.missing_later() if self.missing_later else []) def rule_keys(self) -> tuple[str, ...]: @@ -126,6 +138,17 @@ def _round(v: float | None) -> float | None: return None if v is None else round(v, 6) +def _up(v: float) -> float: + """v rounded up to 6 decimals: a depth to show, never less than what is needed.""" + return math.ceil(v * 1e6) / 1e6 + + +def _above(v: float) -> float: + """The smallest 6-decimal value strictly above v: a view must reach past the rule's line, + since PASS needs the margin to exceed the error (here 0).""" + return math.floor(v * 1e6) / 1e6 + 1e-6 + + def _outward(a: float, b: float) -> list[float]: """A requested view's span, rounded to 6 decimals away from its middle: rounding an end inward would leave a sliver that capturing exactly the listed span never covers.""" @@ -280,7 +303,7 @@ def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: missing = self.scene.missing("wall", s0, s1) if missing: c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing = [("wall", a, b) for a, b in missing] + c.missing = [View("wall", a, b) for a, b in missing] c.reason = "Part of the wall behind the battery was not seen." return c c.reason = "The whole footprint backs onto one straight, observed wall segment." @@ -339,7 +362,7 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: def _unobserved(self, band: str) -> Geometry: return self.unobserved_ground if band == "ground" else self.unobserved_wall - def _missing(self, band: str, fp: Polygon, radius: float) -> list[tuple[str, float, float]]: + def _missing(self, band: str, fp: Polygon, radius: float) -> list[View]: """The views that would show what nobody saw within `radius` of the footprint. Only what a view can settle is requested; the rest lies past an unexplored end, which the past_end request covers. For the wall band, the area past an unexplored end that lies in @@ -364,12 +387,12 @@ def _missing(self, band: str, fp: Polygon, radius: float) -> list[tuple[str, flo if (e := self.scene.s_extent(part)) and e[1] - e[0] > _MEASURE_EPS ] if extents: - out.append((view, min(a for a, _ in extents), max(b for _, b in extents))) + a, b = min(a for a, _ in extents), max(b for _, b in extents) + depth = _up(self.scene.farthest_out(region)) if view == "ground" else None + out.append(View(view, a, b, depth)) return out - def _missing_bands( - self, bands: list[str], fp: Polygon, radius: float - ) -> list[tuple[str, float, float]]: + def _missing_bands(self, bands: list[str], fp: Polygon, radius: float) -> list[View]: """Every unseen band, so one recapture settles the check (a clearance can need both the ground and the wall).""" return [m for band in bands for m in self._missing(band, fp, radius)] @@ -499,7 +522,7 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: else: c.reason = "Nothing is mounted on the wall above the battery." if missing and c.outcome == UNSURE: - c.missing = [("wall", a, b) for a, b in missing] + c.missing = [View("wall", a, b) for a, b in missing] return c def check_meter_space(self, piece: Piece, fp: Polygon, s0: float, s1: float) -> Check: @@ -590,14 +613,32 @@ 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: + # 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" + c.reason = ( + 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)] elif c.measured is None: c.reason = f"Nothing limits the {noun} over the battery's stretch of wall." else: c.reason = f"The {noun} is {val}, clear of the {ft(t)} rule." if missing and c.outcome == UNSURE: - c.missing = [(band, a, b) for a, b in missing] + # Seen clear further than the rule needs, or with nothing in the way, settles it. + c.missing = [View(band, a, b, _above(t + subtract)) for a, b in missing] return c + 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 + cover the whole stretch.""" + measured = merge_intervals([m.span for m in entries]) + free = subtract_intervals((s0, s1), measured) + return min((self.scene.seen_to(band, a, b) for a, b in free), default=math.inf) + @staticmethod def _report_range( c: Check, surely: tuple[float, float] | None, wall_error: float, noun: str @@ -727,7 +768,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C ) elif missing: path.outcome, path.unsure_cause = UNSURE, "unobserved" - path.missing = [("wall", a, b) for a, b in missing] + path.missing = [View("wall", a, b) for a, b in missing] path.reason = "Part of the wall the cable would run along was not seen." else: path.reason = "The cable runs along continuous, observed wall with nothing blocking it." @@ -1125,6 +1166,11 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: } +_DEPTH_TEXT = { + "ground": ", at least {} out from the wall", + "facing": ", clear at least {} out from the wall", + "overhead": ", clear at least {} up", +} _BAND_TEXT = { "wall": "wall", "ground": "ground in front of the wall", @@ -1134,24 +1180,27 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: - by_band: dict[str, list[tuple[float, float, str]]] = {} + by_band: dict[str, list[tuple[View, str]]] = {} for chk in c.checks: - for band, a, b in chk.all_missing(): - by_band.setdefault(band, []).append((a, b, chk.id)) + for view in chk.all_missing(): + by_band.setdefault(view.band, []).append((view, chk.id)) out = [] for band, items in by_band.items(): - for a, b in merge_intervals([(a, b) for a, b, _ in items]): - ids = sorted({i for x0, x1, i in items if x0 < b + EPS and x1 > a - EPS}) - out.append( - { - "kind": "band", - "band": band, - "span_ft": _outward(a, b), - "checks": ids, - "message": f"Show the {_BAND_TEXT[band]} from {where(a)} to {where(b)}." - + _past_end_hint(scene, a, b), - } - ) + for a, b in merge_intervals([(v.a, v.b) for v, _ in items]): + within = [(v, i) for v, i in items if v.a < b + EPS and v.b > a - EPS] + depths = [v.out for v, _ in within if v.out is not None] + request: dict[str, Any] = { + "kind": "band", + "band": band, + "span_ft": _outward(a, b), + "checks": sorted({i for _, i in within}), + } + text = f"Show the {_BAND_TEXT[band]} from {where(a)} to {where(b)}" + if depths: + request["out_ft"] = _up(max(depths)) + text += _DEPTH_TEXT[band].format(ft(request["out_ft"])) + request["message"] = text + "." + _past_end_hint(scene, a, b) + out.append(request) return out diff --git a/server/tests/test_capture_contract.py b/server/tests/test_capture_contract.py new file mode 100644 index 000000000..bb4b5199e --- /dev/null +++ b/server/tests/test_capture_contract.py @@ -0,0 +1,201 @@ +"""The capture contract (README, "What settles each check"): supplying exactly what a request +names settles it, and the depth a walked path or a tilt-up view proves is credited.""" + +import copy +import json +from pathlib import Path + +import jsonschema +from helpers import D, at_start, observed_band, parsed, shared_fixture +from test_s4_round import PUBLIC, answer + +from solver import PASS, UNSURE, evaluate_start + +RESULT_SCHEMA = json.loads( + (Path(__file__).resolve().parents[1] / "schemas" / "result.schema.json").read_text() +) +FACING_NEED = D + 3.0 # battery depth plus facing.min_ft +HEADROOM_NEED = 6.5 # headroom.min_ft + + +def supplied(raw: dict, result: dict) -> dict: + """The scene after the app supplies exactly the coverage each band request names.""" + 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 seen_clear(raw: dict, band: str, out_ft: float) -> None: + observed_band(raw, band, []) + raw["coverage"]["observed"].append({"band": band, "span_ft": [-40, 40], "out_ft": out_ft}) + + +def walked(out_ft: float) -> dict: + """No facing measurements; the facing band is known clear only as far as the homeowner + walked from the wall.""" + raw = shared_fixture() + raw["facing"] = [] + seen_clear(raw, "facing", out_ft) + return raw + + +def test_a_walked_path_far_enough_out_settles_the_facing_gap() -> None: + facing = at_start(walked(FACING_NEED + 0.5), 6.0, "facing_gap", PUBLIC) + assert facing.outcome == PASS + + +def test_a_walked_path_too_close_is_unsure_and_asks_how_far() -> None: + raw = walked(FACING_NEED - 1) + facing = at_start(raw, 6.0, "facing_gap", PUBLIC) + assert (facing.outcome, facing.unsure_cause) == (UNSURE, "unobserved") + request = next(m for m in answer(raw)["missing_evidence"] if m.get("band") == "facing") + assert request["out_ft"] > FACING_NEED + + +def test_supplying_the_facing_request_settles_it() -> None: + raw = walked(FACING_NEED - 1) + after = answer(supplied(raw, answer(raw))) + assert next(c for c in after["checks"] if c["id"] == "facing_gap")["outcome"] == PASS + + +def test_a_measured_gap_is_not_capped_by_a_shorter_walk() -> None: + # A mesh measurement over the whole stretch says where the obstruction is; how far the + # homeowner walked adds nothing there. + raw = walked(2.0) + raw["facing"] = [{"wall_id": "w1", "span_ft": [-40, 40], "depth_ft": 9, "plus_minus_ft": 0}] + assert at_start(raw, 6.0, "facing_gap", PUBLIC).outcome == PASS + + +def tilted_up(out_ft: float) -> dict: + raw = shared_fixture() + raw["overheads"] = [] + seen_clear(raw, "overhead", out_ft) + return raw + + +def test_a_tilt_up_view_high_enough_settles_headroom() -> None: + assert at_start(tilted_up(HEADROOM_NEED + 0.5), 6.0, "headroom", PUBLIC).outcome == PASS + + +def test_supplying_the_overhead_request_settles_it() -> None: + raw = tilted_up(HEADROOM_NEED - 2) + result = answer(raw) + request = next(m for m in result["missing_evidence"] if m.get("band") == "overhead") + assert request["out_ft"] > HEADROOM_NEED + after = answer(supplied(raw, result)) + assert next(c for c in after["checks"] if c["id"] == "headroom")["outcome"] == PASS + + +def shallow_ground() -> dict: + """The app's current export: ground seen 1.2 m (3.94 ft) out everywhere.""" + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 40)], out=3.94) + return raw + + +def test_a_ground_request_names_how_far_out() -> None: + result = answer(shallow_ground()) + request = next(m for m in result["missing_evidence"] if m.get("band") == "ground") + # The pool clearance reaches its 10 ft radius past the battery's depth. + assert request["out_ft"] >= D + 10.0 + jsonschema.validate(result, RESULT_SCHEMA) + + +def test_supplying_the_ground_request_settles_it() -> None: + raw = shallow_ground() + after = answer(supplied(raw, answer(raw))) + unseen = [c["id"] for c in after["checks"] if c.get("unsure_cause") == "unobserved"] + assert unseen == [] + + +# --- a corner the homeowner marks ----------------------------------------------------------------- + + +def around_a_corner(next_wall_ft: float, right_end: str) -> dict: + """The meter wall turns a convex corner 10 ft right of the meter, and the app sends the next + wall as the next piece of the chain, as far as the homeowner followed it.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [10, 0]], "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[10, 0], [10, -next_wall_ft]], "plus_minus_ft": 0}, + ] + top = 10 + next_wall_ft + raw["overheads"] = [{"wall_id": "w1", "span_ft": [-20, 10], "clearance_ft": 9}] + raw["facing"] = [{"wall_id": "w1", "span_ft": [-20, 10], "depth_ft": 9}] + raw["ground"] = [ + {"type": "lawn", "polygon": [[-40, 0], [40, 0], [40, 30], [-40, 30]], "plus_minus_ft": 0}, + {"type": "lawn", "polygon": [[10, 0], [40, 0], [40, -40], [10, -40]], "plus_minus_ft": 0}, + ] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": right_end}}, + "observed": [ + {"band": "wall", "span_ft": [-20, top]}, + {"band": "ground", "span_ft": [-20 - 20, top + 20], "out_ft": 30}, + {"band": "overhead", "span_ft": [-20, top]}, + {"band": "facing", "span_ft": [-20, top]}, + ], + } + return raw + + +def test_a_corner_sent_as_the_next_piece_asks_for_no_walk() -> None: + # Followed round the corner past cable reach, the chain has no open end within reach. + result = answer(around_a_corner(20.0, "unexplored")) + assert result["ends"]["right"]["beyond_reach"] is True + assert not [m for m in result["missing_evidence"] if m["kind"] == "past_end"] + + +def test_a_corner_left_unexplored_within_reach_is_a_walk_request_when_no_spot_passes() -> None: + # Stopping at the corner: the wall continues, so a spot round it may exist within reach. + raw = around_a_corner(20.0, "unexplored") + raw["walls"] = raw["walls"][:1] + raw["ground"] = [{"type": "deck", "polygon": [[-40, 0], [40, 0], [40, 30], [-40, 30]]}] + result = answer(raw) + assert result["ends"]["right"]["beyond_reach"] is False + assert any(m["kind"] == "past_end" for m in result["missing_evidence"]) + + +# --- the README's table, exactly ------------------------------------------------------------------ + +S0 = 6.0 +S1 = S0 + 31 / 12 +# The README's coverage for a battery at [S0, S1] on a wall with no position error (e = 0). +EXACT = { + "wall": (0.0, S1 + 3), # route from the meter, and gas and openings 3 ft either side + "ground": (S0 - 10, S1 + 10, D + 10), # the pool clearance reaches furthest + "facing": (S0, S1), + "overhead": (S0, S1), +} + + +def readme_coverage(**trim: float) -> dict: + raw = shared_fixture() + observed = [] + for band, span in EXACT.items(): + cut = trim.get(band, 0.0) + if band == "ground": + a, b, out = span + observed.append({"band": band, "span_ft": [a, b], "out_ft": out - cut}) + else: + observed.append({"band": band, "span_ft": [span[0], span[1] - cut]}) + raw["coverage"]["observed"] = observed + return raw + + +def unobserved_at_spot(raw: dict) -> list[str]: + candidate = evaluate_start(parsed(raw, PUBLIC), PUBLIC, S0) + return [c.id for c in candidate.checks if c.unsure_cause == "unobserved"] + + +def test_the_readme_coverage_settles_every_check() -> None: + assert unobserved_at_spot(readme_coverage()) == [] + + +def test_less_than_the_readme_coverage_leaves_a_check_unseen() -> None: + for band in EXACT: + assert unobserved_at_spot(readme_coverage(**{band: 0.01})) != [], band diff --git a/server/tests/test_coderabbit_review.py b/server/tests/test_coderabbit_review.py index 4ff641a24..3592d10e3 100644 --- a/server/tests/test_coderabbit_review.py +++ b/server/tests/test_coderabbit_review.py @@ -29,7 +29,7 @@ def test_a_check_reports_every_band_it_did_not_see() -> None: # ground left it UNSURE on the wall. gas = at_start(both_bands_unseen(), 6.0, "gas_clearance") assert (gas.outcome, gas.unsure_cause) == (UNSURE, "unobserved") - assert {band for band, _, _ in gas.all_missing()} == {"ground", "wall"} + assert {view.band for view in gas.all_missing()} == {"ground", "wall"} def test_missing_evidence_lists_the_check_under_each_band() -> None: @@ -66,6 +66,10 @@ def partly_seen_scenes(draw: st.DrawFn) -> dict: spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b > a] out = draw(st.floats(min_value=1, max_value=30)) observed_band(raw, band, spans or [(-40.0, -39.0)], out) + if band in ("facing", "overhead") and draw(st.booleans()): + for o in raw["coverage"]["observed"]: + if o["band"] == band: + o["out_ft"] = draw(st.floats(1, 12)) return raw @@ -81,14 +85,13 @@ def test_capturing_what_missing_evidence_asks_for_settles_coverage(raw: dict) -> spot = result["spot"] if result["decision"] != "manual_review" or spot is None: return - # The capture shows each listed stretch; for ground, out as far as the checks reach. - reach = parsed(raw, rules).reach_ft + # The capture shows exactly what each request names, depth included. captured = copy.deepcopy(raw) for item in result["missing_evidence"]: if item["kind"] == "band": - extra = {"out_ft": reach} if item["band"] == "ground" else {} + depth = {"out_ft": item["out_ft"]} if "out_ft" in item else {} captured["coverage"]["observed"].append( - {"band": item["band"], "span_ft": item["span_ft"], **extra} + {"band": item["band"], "span_ft": item["span_ft"], **depth} ) # The answer rounds the spot to 6 decimals; evaluate the exact start the solver chose, since # a start rounded down can reach a sliver of a stretch that was never missing. diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index e11482108..560c7fb1a 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -26,24 +26,24 @@ def captured(raw: dict, result: dict) -> dict: observed = out.setdefault("coverage", {}).setdefault("observed", []) for item in result["missing_evidence"]: if item["kind"] == "band": - extra = {"out_ft": FAR_FT} if item["band"] == "ground" else {} - observed.append({"band": item["band"], "span_ft": item["span_ft"], **extra}) + # Exactly what the request names, depth included. + depth = {"out_ft": item["out_ft"]} if "out_ft" in item else {} + observed.append({"band": item["band"], "span_ft": item["span_ft"], **depth}) return out def repeated_requests(raw: dict, result: dict) -> list[dict]: """Band requests the scene already satisfies: the same request coming back.""" - seen: dict[str, list[tuple[float, float]]] = {} + seen: dict[str, list[tuple[float, float, float]]] = {} for o in raw.get("coverage", {}).get("observed", []): - if o["band"] != "ground" or o.get("out_ft", 0) >= FAR_FT: - seen.setdefault(o["band"], []).append(tuple(o["span_ft"])) + seen.setdefault(o["band"], []).append((*o["span_ft"], o.get("out_ft", float("inf")))) return [ item for item in result["missing_evidence"] if item["kind"] == "band" and any( - a <= item["span_ft"][0] and item["span_ft"][1] <= b - for a, b in seen.get(item["band"], []) + a <= item["span_ft"][0] and item["span_ft"][1] <= b and out >= item.get("out_ft", 0) + for a, b, out in seen.get(item["band"], []) ) ] @@ -96,6 +96,12 @@ def scenes_with_ends(draw: st.DrawFn) -> dict: edges = [left, *sorted(cuts[: len(cuts) // 2 * 2]), right] spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b > a] observed_band(raw, band, spans or [(left, left + 0.1)], draw(st.floats(1, 30))) + if band in ("facing", "overhead"): + # Seen clear only so far (a walked path, a tilt-up frame), or all the way. + depth = draw(st.none() | st.floats(1, 12)) + for o in raw["coverage"]["observed"]: + if o["band"] == band and depth is not None: + o["out_ft"] = depth return raw @@ -174,7 +180,7 @@ def test_a_band_check_reports_numbers_that_give_its_outcome() -> None: assert "facing[1]" in facing.reason -@settings(max_examples=60, deadline=None) +@settings(max_examples=40, deadline=None) @given(s0=st.floats(min_value=4.0, max_value=9.0)) def test_band_check_numbers_agree_with_c5(s0: float) -> None: candidate = evaluate_start(parsed(uncertain_obstruction(), PUBLIC), PUBLIC, s0) From e0ee8d330c03bea83e13c2a5807a757bca7521e9 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 08:08:00 -0500 Subject: [PATCH 25/52] server: a stretch that ends at the meter reads "to the meter" Requests said "from 12 ft 0 in left of the meter to at the meter". Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 7 ++++++- server/tests/test_capture_contract.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/server/solver.py b/server/solver.py index 2dd392146..e9cc3cedd 100644 --- a/server/solver.py +++ b/server/solver.py @@ -182,6 +182,11 @@ def ft(v: float) -> str: return format_ft_in(abs(v)) if v >= 0 else "-" + format_ft_in(abs(v)) +def _point(s: float) -> str: + """where(s) as the end of a stretch: "from 3 ft left of the meter to the meter".""" + return "the meter" if abs(s) < 1 / 24 else where(s) + + def where(s: float) -> str: if abs(s) < 1 / 24: return "at the meter" @@ -1195,7 +1200,7 @@ def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: "span_ft": _outward(a, b), "checks": sorted({i for _, i in within}), } - text = f"Show the {_BAND_TEXT[band]} from {where(a)} to {where(b)}" + text = f"Show the {_BAND_TEXT[band]} from {_point(a)} to {_point(b)}" if depths: request["out_ft"] = _up(max(depths)) text += _DEPTH_TEXT[band].format(ft(request["out_ft"])) diff --git a/server/tests/test_capture_contract.py b/server/tests/test_capture_contract.py index bb4b5199e..f3dbae55d 100644 --- a/server/tests/test_capture_contract.py +++ b/server/tests/test_capture_contract.py @@ -199,3 +199,10 @@ def test_the_readme_coverage_settles_every_check() -> None: def test_less_than_the_readme_coverage_leaves_a_check_unseen() -> None: for band in EXACT: assert unobserved_at_spot(readme_coverage(**{band: 0.01})) != [], band + + +def test_a_request_ending_at_the_meter_reads_naturally() -> None: + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 0), (3, 40)]) # the cable's first 3 ft unseen + messages = [m["message"] for m in answer(raw)["missing_evidence"] if m.get("band") == "wall"] + assert messages == ["Show the wall from the meter to 3 ft 0 in right of the meter."] From cd0da8b65e8c1254aeb45d2e6ab5cd6655cd4634 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 10:24:46 -0500 Subject: [PATCH 26/52] server: demo decisions, a key-guarded private mode, and example calls - The public rules' policy is now `demo`: it decides automatically with the public values and the placeholders (pool 10 ft, drive 5 ft), so the public endpoint returns pass and reject. Every answer says so in policy.notice (result schema, additive) and at the end of summary. HOUSESCAN_POLICY=strict restores sending everything to a person. - Private rules can come from HOUSESCAN_PRIVATE_RULES_B64 (the YAML, base64), so a deployment gets them from an env var, not a file. Setting it and the path variable together is an error; private rules drop the demo notice unless they set their own. - While private rules are loaded, every route but /health needs Authorization: Bearer : 401 without it, 503 when no key is configured. /health says only that private rules are loaded. - examples/: one scene per outcome under the demo policy, curl calls for both deployments, and `make smoke URL=... [KEY_FILE=...]`. - "1 check depends on areas the scan did not see", not "1 checks". Co-Authored-By: Claude Opus 5.5 (1M context) --- Makefile | 8 +- server/README.md | 23 +- server/api.py | 55 ++++- server/examples/README.md | 43 ++++ server/examples/pass-clear-side-wall.json | 174 ++++++++++++++ server/examples/reject-garage-in-the-way.json | 216 ++++++++++++++++++ server/examples/review-corner-not-walked.json | 128 +++++++++++ server/examples/smoke.py | 69 ++++++ server/rules.py | 83 +++++-- server/rules.yaml | 10 +- server/schemas/result.schema.json | 5 +- server/solver.py | 7 +- server/tests/conftest.py | 12 + server/tests/test_api.py | 4 +- server/tests/test_policy_and_auth.py | 173 ++++++++++++++ server/tests/test_siteplan.py | 3 +- 16 files changed, 979 insertions(+), 34 deletions(-) create mode 100644 server/examples/README.md create mode 100644 server/examples/pass-clear-side-wall.json create mode 100644 server/examples/reject-garage-in-the-way.json create mode 100644 server/examples/review-corner-not-walked.json create mode 100644 server/examples/smoke.py create mode 100644 server/tests/conftest.py create mode 100644 server/tests/test_policy_and_auth.py diff --git a/Makefile b/Makefile index 13bca16c8..b1350ec4b 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ XCODEGEN ?= xcodegen # Must match XCODEGEN_VERSION in .github/workflows/ios.yml; other versions write a different project. XCODEGEN_VERSION := 2.46.0 -.PHONY: check server web ios ios-project +.PHONY: check server web ios ios-project smoke check: server web ios @@ -34,3 +34,9 @@ ios-project: exit 1; \ fi $(XCODEGEN) generate --spec ios/project.yml + +# Posts each scene in server/examples to a placement server and prints its decision: +# make smoke URL=https://house-scanning-server.vercel.app [KEY_FILE=server/.env.private.local] +smoke: + @test -n "$(URL)" || { echo "usage: make smoke URL= [KEY_FILE=]" >&2; exit 2; } + python3 server/examples/smoke.py "$(URL)" $(if $(KEY_FILE),--key-file "$(KEY_FILE)") diff --git a/server/README.md b/server/README.md index ae2a18b20..69d1fcbc4 100644 --- a/server/README.md +++ b/server/README.md @@ -17,13 +17,30 @@ Without the web layer: `loaded = load_rules()`, then `solve(parse_scene(raw, loa ## Deploy -The public demo runs at **https://house-scanning-server.vercel.app** (Vercel project `house-scanning-server`, public rules only). Redeploy from this directory: +Two Vercel projects deploy this directory. Neither is connected to Git, so a merge doesn't redeploy them; only `server/` uploads (`.vercelignore`), so the repository's `private/` folder never reaches a deployment. + +| Deployment | Rules | Access | +| --- | --- | --- | +| **https://house-scanning-server.vercel.app** (`house-scanning-server`) | public, under the demo policy | open | +| **https://house-scanning-server-private.vercel.app** (`house-scanning-server-private`) | public with the private rules merged over them | `Authorization: Bearer ` on every route but `/health` | + +Redeploy the public one from this directory: + +```sh +npx vercel@latest deploy --prod --scope sam-gus-projects-7a4b6082 +``` + +The private one gets its rules from environment variables set in its Vercel project, never from an uploaded file: `HOUSESCAN_PRIVATE_RULES_B64` holds `private/rules.yaml` base64-encoded, and `HOUSESCAN_API_KEY` the key. While private rules are loaded, every route but `/health` answers 401 without the key and 503 if no key is set, and `/health` says only that private rules are loaded. The key is in `server/.env.private.local` (git-ignored, mode 600) on the machine that set it up. This directory is linked to the public project, so deploy the private one from a copy of it that is linked to `house-scanning-server-private`: ```sh +rsync -a --exclude .venv --exclude .vercel --exclude '.env*' --exclude tests ./ /tmp/private-deploy/ +cd /tmp/private-deploy +npx vercel@latest link --yes --project house-scanning-server-private --scope sam-gus-projects-7a4b6082 npx vercel@latest deploy --prod --scope sam-gus-projects-7a4b6082 +cd - && rm -rf /tmp/private-deploy # the link leaves a Vercel token in .env.local there ``` -Only `server/` uploads (`.vercelignore`), so the repository's `private/` rules can't reach a deployment; `/health` shows `sources: ["public"]`. Vercel caps a request body at 4.5 MB, so send bare `scene.json`: the solver doesn't read the images, and a zip must hold every JPEG its `scene.json` names or it is refused with 422 `missing_bundle_file`. The project isn't connected to Git: a deploy is always this command. +Vercel caps a request body at 4.5 MB, so send bare `scene.json`: the solver doesn't read the images, and a zip must hold every JPEG its `scene.json` names or it is refused with 422 `missing_bundle_file`. `examples/` has scenes and `curl` commands for both deployments, and `make smoke URL=...` posts them all. ## API @@ -110,4 +127,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; because it has some, it sets `auto_approve: false`, which turns every pass or reject into manual review. At startup the server merges a git-ignored `private/rules.yaml` over it when one exists (path: `HOUSESCAN_PRIVATE_RULES`, else `/private/rules.yaml`). 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 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`. diff --git a/server/api.py b/server/api.py index adec822a3..f4d2d62ac 100644 --- a/server/api.py +++ b/server/api.py @@ -12,6 +12,7 @@ Run: uv run uvicorn api:app --host 0.0.0.0 --port 8000 """ +import hmac import io import json import logging @@ -61,6 +62,9 @@ def _limit_bytes(env: str, default_mb: float) -> int: # Loaded at import: invalid rules must stop the server before it answers anything. LOADED: LoadedRules = load_rules() +# Required on every route but /health while private rules are loaded: answers carry each check's +# threshold, so a server holding Base's values must not answer strangers. +API_KEY: str | None = os.environ.get("HOUSESCAN_API_KEY") or None _ZIP_MAGIC = b"PK\x03\x04" _JSON_TYPES = {"application/json"} @@ -94,6 +98,46 @@ def _error_response(status: int, code: str, message: str, path: str | None) -> J version=SCHEMA_VERSION, description="Turns a phone capture (scene.json or a zip bundle) into a battery placement.", ) + + +def _private() -> bool: + return "private" in LOADED.sources + + +@app.middleware("http") +async def _require_key(request: Request, call_next: Any) -> Response: + # A browser's preflight carries no credentials; the request that follows it does. + if not _private() or request.url.path == "/health" or request.method == "OPTIONS": + return await call_next(request) + if API_KEY is None: + return _error_response( + 503, + "no_api_key", + "This server holds private rules but has no HOUSESCAN_API_KEY, so it answers nothing.", + None, + ) + if not _key_matches(request.headers.get("authorization", "")): + response = _error_response( + 401, + "unauthorized", + "This server holds private rules: send the header Authorization: Bearer .", + None, + ) + response.headers["WWW-Authenticate"] = "Bearer" + return response + return await call_next(request) + + +def _key_matches(header: str) -> bool: + scheme, _, key = header.partition(" ") + return ( + API_KEY is not None + and scheme.lower() == "bearer" + and hmac.compare_digest(key.strip().encode(), API_KEY.encode()) + ) + + +# Added after the key check, so it wraps it: refusals carry CORS headers a browser can read. app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -139,11 +183,20 @@ def _policy() -> dict[str, Any]: "auto_approve": p.auto_approve and p.id is not None, "sources": list(LOADED.sources), "rules_sha256": LOADED.sha256, + "notice": p.notice, } @app.get("/health") -def health() -> dict[str, Any]: +def health(request: Request) -> dict[str, Any]: + if _private() and not _key_matches(request.headers.get("authorization", "")): + # Anyone may learn that private rules are loaded and a key is needed, not which rules. + return { + "status": "ok", + "schema_version": SCHEMA_VERSION, + "policy": {"sources": list(LOADED.sources)}, + "auth": "bearer", + } return {"status": "ok", "schema_version": SCHEMA_VERSION, "policy": _policy()} diff --git a/server/examples/README.md b/server/examples/README.md new file mode 100644 index 000000000..62d52b884 --- /dev/null +++ b/server/examples/README.md @@ -0,0 +1,43 @@ +# Example calls + +Three synthetic scenes, one per outcome under the public demo policy: + +| Scene | Demo decision | Why | +| --- | --- | --- | +| `pass-clear-side-wall.json` | `pass` | a long side wall, everything around the spot seen | +| `reject-garage-in-the-way.json` | `reject` | a garage door and gas meters leave no spot; both ends of the walk are real ends | +| `review-corner-not-walked.json` | `manual_review` | the walk stopped at a corner; `missing_evidence` lists the views to add | + +Under the private rules the decisions can differ; that is what the private deployment is for. + +## Post them all + +From the repository root, with the decision per scene: + +```sh +make smoke URL=https://house-scanning-server.vercel.app +make smoke URL=https://house-scanning-server-private.vercel.app KEY_FILE=server/.env.private.local +``` + +`make smoke` runs `server/examples/smoke.py`, which needs only Python 3. It exits 1 when any request fails. + +## One call + +Public deployment: + +```sh +curl -s https://house-scanning-server.vercel.app/v1/placements \ + -H 'content-type: application/json' \ + --data-binary @server/examples/pass-clear-side-wall.json | python3 -m json.tool +``` + +Private deployment, reading the key from the git-ignored file: + +```sh +curl -s https://house-scanning-server-private.vercel.app/v1/placements \ + -H "Authorization: Bearer $(sed -n 's/^HOUSESCAN_API_KEY=//p' server/.env.private.local)" \ + -H 'content-type: application/json' \ + --data-binary @server/examples/review-corner-not-walked.json | python3 -m json.tool +``` + +The site plan for the same scene: post it to `/v1/placements/site-plan.svg` instead and write the answer to a file (`-o plan.svg`). diff --git a/server/examples/pass-clear-side-wall.json b/server/examples/pass-clear-side-wall.json new file mode 100644 index 000000000..d1cb2672a --- /dev/null +++ b/server/examples/pass-clear-side-wall.json @@ -0,0 +1,174 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -20.0, + 0.0 + ], + [ + 24.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [ + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -6.0, + -5.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -5.0, + 0.0 + ], + [ + -5.0, + 1.0 + ], + [ + -6.0, + 1.0 + ] + ] + }, + { + "type": "window", + "wall_id": "side", + "span_ft": [ + -14.0, + -11.0 + ], + "bottom_ft": 3.0, + "top_ft": 7.0, + "attrs": { + "operable": true + }, + "source": "tap", + "plus_minus_ft": 0.1 + } + ], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -20.0, + 24.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "limit" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 30.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 30.0 + ] + } + ] + } +} diff --git a/server/examples/reject-garage-in-the-way.json b/server/examples/reject-garage-in-the-way.json new file mode 100644 index 000000000..f515de37c --- /dev/null +++ b/server/examples/reject-garage-in-the-way.json @@ -0,0 +1,216 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -20.0, + 0.0 + ], + [ + 24.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [ + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -6.0, + -5.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -5.0, + 0.0 + ], + [ + -5.0, + 1.0 + ], + [ + -6.0, + 1.0 + ] + ] + }, + { + "type": "window", + "wall_id": "side", + "span_ft": [ + -14.0, + -11.0 + ], + "bottom_ft": 3.0, + "top_ft": 7.0, + "attrs": { + "operable": true + }, + "source": "tap", + "plus_minus_ft": 0.1 + }, + { + "type": "garage_door", + "wall_id": "side", + "span_ft": [ + 1.5, + 24.0 + ], + "bottom_ft": 0.0, + "top_ft": 7.0, + "source": "tap", + "plus_minus_ft": 0.1 + }, + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -18.0, + -17.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -18.0, + 0.0 + ], + [ + -17.0, + 0.0 + ], + [ + -17.0, + 1.0 + ], + [ + -18.0, + 1.0 + ] + ] + } + ], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -20.0, + 24.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "limit" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 30.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 30.0 + ] + } + ] + } +} diff --git a/server/examples/review-corner-not-walked.json b/server/examples/review-corner-not-walked.json new file mode 100644 index 000000000..9913cbbb9 --- /dev/null +++ b/server/examples/review-corner-not-walked.json @@ -0,0 +1,128 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -3.0, + 0.0 + ], + [ + 9.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -3.0, + 9.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "unexplored" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 9.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 9.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 9.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 9.0 + ] + } + ] + } +} diff --git a/server/examples/smoke.py b/server/examples/smoke.py new file mode 100644 index 000000000..927b11226 --- /dev/null +++ b/server/examples/smoke.py @@ -0,0 +1,69 @@ +"""Post each example scene to a placement server and print its decision. + + python3 server/examples/smoke.py URL [--key-file PATH] + +The key file holds a line HOUSESCAN_API_KEY= (server/.env.private.local for the private +deployment). Standard library only, so it runs without the server's environment. Exits 1 when a +request fails. +""" + +import argparse +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +EXAMPLES = Path(__file__).resolve().parent + + +def read_key(path: Path) -> str: + for line in path.read_text().splitlines(): + name, _, value = line.partition("=") + if name.strip() == "HOUSESCAN_API_KEY" and value.strip(): + return value.strip() + raise SystemExit(f"{path}: no HOUSESCAN_API_KEY= line") + + +def post(url: str, body: bytes, key: str | None) -> tuple[int, dict]: + headers = {"Content-Type": "application/json"} + if key: + headers["Authorization"] = f"Bearer {key}" + request = urllib.request.Request(url, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as error: + text = error.read().decode(errors="replace") + try: + return error.code, json.loads(text) + except json.JSONDecodeError: + # Not the placement server's error format: another server, or its host. + return error.code, {"error": {"code": "not_json", "message": text[:120]}} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument( + "url", help="server root, for example https://house-scanning-server.vercel.app" + ) + parser.add_argument("--key-file", type=Path, help="file with HOUSESCAN_API_KEY=") + args = parser.parse_args() + key = read_key(args.key_file) if args.key_file else None + endpoint = args.url.rstrip("/") + "/v1/placements" + failed = False + for scene in sorted(EXAMPLES.glob("*.json")): + status, body = post(endpoint, scene.read_bytes(), key) + if status == 200: + # Which rules answered, not the private policy's name. + rules = "+".join(body["policy"]["sources"]) + print(f"{scene.stem:30} {body['decision']:14} rules {rules}: {body['summary']}") + else: + failed = True + error = body.get("error", {}) + print(f"{scene.stem:30} HTTP {status} {error.get('code')}: {error.get('message')}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/rules.py b/server/rules.py index 88e8e2b51..2e431e686 100644 --- a/server/rules.py +++ b/server/rules.py @@ -4,6 +4,8 @@ a startup error naming the key, never a silent default. """ +import base64 +import binascii import copy import hashlib import json @@ -17,8 +19,19 @@ SERVER_DIR = Path(__file__).resolve().parent PUBLIC_RULES = SERVER_DIR / "rules.yaml" -PRIVATE_RULES_ENV = "HOUSESCAN_PRIVATE_RULES" +PRIVATE_RULES_ENV = "HOUSESCAN_PRIVATE_RULES" # a path to the private file +# The private file's YAML itself, base64-encoded: how a deployment gets it without uploading a file. +PRIVATE_RULES_B64_ENV = "HOUSESCAN_PRIVATE_RULES_B64" DEFAULT_PRIVATE_RULES = SERVER_DIR.parent / "private" / "rules.yaml" +# Which public policy applies when no private rules are loaded: rules.yaml's demo policy, which +# decides automatically, or the strict one, which sends every answer to a person because some +# public values are placeholders. +POLICY_ENV = "HOUSESCAN_POLICY" +STRICT_POLICY = { + "id": "public-strict", + "auto_approve": False, + "notice": "Public rules with placeholder values; a person confirms every answer.", +} Effect = Literal["fail", "review", "detour", "allow"] ObjectType = Literal[ @@ -42,6 +55,8 @@ class Policy(_Strict): version: str | None auto_approve: bool allow_reject: bool + # Shown with every answer: whose rules these are. + notice: str | None = None class Battery(_Strict): @@ -171,10 +186,13 @@ def _unique_mapping(loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool def _read_yaml(path: Path) -> dict[str, Any]: - with path.open() as f: - data = yaml.load(f, Loader=_UniqueKeyLoader) + return _parse_yaml(path.read_text(), str(path)) + + +def _parse_yaml(text: str, where: str) -> dict[str, Any]: + data = yaml.load(text, Loader=_UniqueKeyLoader) if not isinstance(data, dict): - raise ValueError(f"{path}: expected a mapping at the top level") + raise ValueError(f"{where}: expected a mapping at the top level") return data @@ -214,18 +232,45 @@ def public_rules_dict() -> dict[str, Any]: def load_rules(private_path: Path | None = None) -> LoadedRules: - """Public rules, with the private file merged over them when it exists.""" - data = public_rules_dict() - sources: tuple[str, ...] = ("public",) - if private_path is None: - env = os.environ.get(PRIVATE_RULES_ENV) - private_path = Path(env) if env else DEFAULT_PRIVATE_RULES - if env and not private_path.is_file(): - raise FileNotFoundError(f"{PRIVATE_RULES_ENV}={env} does not name a file") - private_keys: frozenset[str] = frozenset() - if private_path.is_file(): - private = _read_yaml(private_path) - private_keys = frozenset(overridden_keys(data, private)) - data = deep_merge(data, private) - sources = ("public", "private") - return rules_from_dict(data, sources, private_keys) + """Public rules under the policy HOUSESCAN_POLICY names, with the private rules merged over + them when there are any: from `private_path`, else HOUSESCAN_PRIVATE_RULES_B64 (the YAML + itself) or HOUSESCAN_PRIVATE_RULES (a path), else private/rules.yaml if it exists.""" + data = _public_policy(public_rules_dict()) + private = _private_rules(private_path) + 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) + + +def _public_policy(data: dict[str, Any]) -> dict[str, Any]: + name = os.environ.get(POLICY_ENV, "demo") + if name == "demo": + return data + if name == "strict": + return deep_merge(data, {"policy": STRICT_POLICY}) + raise ValueError(f"{POLICY_ENV}={name!r}: expected 'demo' or 'strict'") + + +def _private_rules(private_path: Path | None) -> dict[str, Any] | None: + if private_path is not None: + return _read_yaml(private_path) if private_path.is_file() else None + encoded = os.environ.get(PRIVATE_RULES_B64_ENV) + path = os.environ.get(PRIVATE_RULES_ENV) + if encoded and path: + raise ValueError(f"both {PRIVATE_RULES_B64_ENV} and {PRIVATE_RULES_ENV} are set; set one") + if encoded: + try: + text = base64.b64decode(encoded, validate=True).decode() + except (binascii.Error, UnicodeDecodeError): + raise ValueError(f"{PRIVATE_RULES_B64_ENV} is not base64-encoded UTF-8") from None + return _parse_yaml(text, PRIVATE_RULES_B64_ENV) + if path: + if not Path(path).is_file(): + raise FileNotFoundError(f"{PRIVATE_RULES_ENV}={path} does not name a file") + return _read_yaml(Path(path)) + return _read_yaml(DEFAULT_PRIVATE_RULES) if DEFAULT_PRIVATE_RULES.is_file() else None diff --git a/server/rules.yaml b/server/rules.yaml index aebb5eb1b..5e2dfc3b6 100644 --- a/server/rules.yaml +++ b/server/rules.yaml @@ -8,13 +8,15 @@ # anything else in the private file replaces the public value. Never commit Base's values. policy: - id: public-demo + id: demo version: "2026-09-26" - # False because driveway, pool and reach values below are placeholders: a spot that passes every - # check still goes to manual review, and nothing is rejected automatically. - auto_approve: false + # Decides automatically so the team can test real passes and rejects, although the driveway, + # pool and reach values below are placeholders. HOUSESCAN_POLICY=strict sends every answer to a + # person instead (rules.py STRICT_POLICY). + auto_approve: true # Whether this policy may reject a site at all (some policies only pass or send to review). allow_reject: true + notice: "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." battery: width_ft: {value: 2.583333333333, source: "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/01 C2)"} diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index 9c294c909..1c94d7115 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -35,9 +35,10 @@ "properties": { "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 file sets auto_approve false (the public rules.yaml does, because it holds placeholder values). When false, every would-be pass or reject becomes manual_review. Each check's rule.placeholder says which values decided it."}, + "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."}, "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."} + "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."} } }, "spot": { diff --git a/server/solver.py b/server/solver.py index e9cc3cedd..619cc8429 100644 --- a/server/solver.py +++ b/server/solver.py @@ -1352,7 +1352,8 @@ def past_end_requests() -> list[dict[str, Any]]: if any(c.unsure_cause == "unobserved" for c in best.checks if c.outcome == UNSURE): summary = ( f"More views are needed around the best spot, {spot_at}: " - f"{len(ids)} checks depend on areas the scan did not see." + + ("1 check depends" if len(ids) == 1 else f"{len(ids)} checks depend") + + " on areas the scan did not see." ) else: summary = ( @@ -1411,7 +1412,8 @@ def past_end_requests() -> list[dict[str, Any]]: return { "schema_version": SCHEMA_VERSION, "decision": decision, - "summary": summary, + # Every answer names whose rules decided it when the policy asks (the public demo). + "summary": f"{summary} {r.policy.notice}" if r.policy.notice else summary, "reasons": reasons, "policy": { "id": r.policy.id, @@ -1419,6 +1421,7 @@ def past_end_requests() -> list[dict[str, Any]]: "auto_approve": auto, "sources": list(loaded.sources), "rules_sha256": loaded.sha256, + "notice": r.policy.notice, }, "spot": _spot_json(solver, spot) if spot else None, "route": _route_json(solver, spot) if spot else None, diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 000000000..efaeeb3cb --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest + +import api +from rules import public_rules_dict, rules_from_dict + + +@pytest.fixture(autouse=True) +def public_api(monkeypatch: pytest.MonkeyPatch) -> None: + """API tests run against the public rules, whatever private file this checkout holds, and + without an API key; tests of the private mode set both themselves.""" + monkeypatch.setattr(api, "LOADED", rules_from_dict(public_rules_dict())) + monkeypatch.setattr(api, "API_KEY", None) diff --git a/server/tests/test_api.py b/server/tests/test_api.py index d9b692446..f072b19a0 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -1,4 +1,5 @@ import copy +import html import io import json import time @@ -105,6 +106,7 @@ def test_health_reports_the_loaded_policy(client: TestClient) -> None: "auto_approve": policy.auto_approve and policy.id is not None, "sources": list(api.LOADED.sources), "rules_sha256": api.LOADED.sha256, + "notice": policy.notice, } @@ -344,7 +346,7 @@ def test_site_plan_is_svg_for_the_same_upload(client: TestClient) -> None: assert svg.status_code == 200 assert svg.headers["content-type"].startswith("image/svg+xml") assert svg.text.startswith(" None: diff --git a/server/tests/test_policy_and_auth.py b/server/tests/test_policy_and_auth.py new file mode 100644 index 000000000..e3dcce315 --- /dev/null +++ b/server/tests/test_policy_and_auth.py @@ -0,0 +1,173 @@ +"""The demo policy, the strict one, private rules from the environment, and the key that guards a +server holding them. The private rules here are synthetic.""" + +import base64 +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from helpers import observed_band, shared_fixture + +import api +from rules import load_rules, public_rules_dict, rules_from_dict +from scene import parse_scene +from solver import solve + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" +SYNTHETIC_PRIVATE = ( + "policy: {id: private-test, version: '1', auto_approve: true, allow_reject: true}\n" + "clearances:\n gas_ft: {value: 4.0, source: 'test citation'}\n" +) +KEY = "test-key-not-a-secret" + + +def decide(name: str, loaded=None) -> dict: + loaded = loaded or rules_from_dict(public_rules_dict()) + raw = json.loads((EXAMPLES / name).read_text()) + return solve(parse_scene(raw, loaded.rules), loaded) + + +# --- the demo policy ------------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("name", "decision"), + [ + ("pass-clear-side-wall.json", "pass"), + ("reject-garage-in-the-way.json", "reject"), + ("review-corner-not-walked.json", "manual_review"), + ], +) +def test_each_example_gets_its_decision_under_the_demo_policy(name: str, decision: str) -> None: + result = decide(name) + assert result["decision"] == decision + assert result["policy"]["id"] == "demo" + # Every answer says whose rules decided it. + assert "not Base's" in result["policy"]["notice"] + assert "not Base's" in result["summary"] + + +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" + ] + + +def test_the_strict_policy_sends_everything_to_a_person(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_POLICY", "strict") + loaded = load_rules(Path("/nonexistent/rules.yaml")) + assert (loaded.rules.policy.id, loaded.rules.policy.auto_approve) == ("public-strict", False) + assert decide("pass-clear-side-wall.json", loaded)["decision"] == "manual_review" + + +def test_an_unknown_policy_name_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_POLICY", "lenient") + with pytest.raises(ValueError, match="HOUSESCAN_POLICY"): + load_rules(Path("/nonexistent/rules.yaml")) + + +# --- private rules from the environment ----------------------------------------------------------- + + +def test_private_rules_load_from_base64(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv( + "HOUSESCAN_PRIVATE_RULES_B64", base64.b64encode(SYNTHETIC_PRIVATE.encode()).decode() + ) + 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 + + +def test_bad_base64_is_a_startup_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_PRIVATE_RULES_B64", "not base64!") + with pytest.raises(ValueError, match="HOUSESCAN_PRIVATE_RULES_B64"): + load_rules() + + +def test_two_private_sources_are_refused(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + path = tmp_path / "rules.yaml" + path.write_text(SYNTHETIC_PRIVATE) + monkeypatch.setenv("HOUSESCAN_PRIVATE_RULES", str(path)) + monkeypatch.setenv( + "HOUSESCAN_PRIVATE_RULES_B64", base64.b64encode(SYNTHETIC_PRIVATE.encode()).decode() + ) + with pytest.raises(ValueError, match="both"): + load_rules() + + +# --- the key -------------------------------------------------------------------------------------- + + +@pytest.fixture +def private_api(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> TestClient: + path = tmp_path / "rules.yaml" + path.write_text(SYNTHETIC_PRIVATE) + monkeypatch.setattr(api, "LOADED", load_rules(path)) + monkeypatch.setattr(api, "API_KEY", KEY) + return TestClient(api.app) + + +SCENE = (EXAMPLES / "pass-clear-side-wall.json").read_bytes() +JSON = {"content-type": "application/json"} + + +@pytest.mark.parametrize("headers", [{}, {"authorization": "Bearer wrong"}, {"authorization": KEY}]) +def test_a_private_server_refuses_without_the_key(private_api: TestClient, headers: dict) -> None: + resp = private_api.post("/v1/placements", content=SCENE, headers=JSON | headers) + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "unauthorized" + assert resp.headers["www-authenticate"] == "Bearer" + + +@pytest.mark.parametrize("path", ["/openapi.json", "/docs", "/v1/schemas/scene.json"]) +def test_every_route_but_health_needs_the_key(private_api: TestClient, path: str) -> None: + assert private_api.get(path).status_code == 401 + + +def test_with_the_key_the_private_policy_answers(private_api: TestClient) -> None: + auth = {"authorization": f"Bearer {KEY}"} + resp = private_api.post("/v1/placements", content=SCENE, headers=JSON | auth) + assert resp.status_code == 200 + assert resp.json()["policy"]["id"] == "private-test" + + +def test_health_says_private_rules_are_loaded_but_not_what(private_api: TestClient) -> None: + body = private_api.get("/health").json() + assert body["policy"] == {"sources": ["public", "private"]} + assert body["auth"] == "bearer" + assert "private-test" not in json.dumps(body) + + +def test_a_browser_preflight_is_not_refused(private_api: TestClient) -> None: + resp = private_api.options( + "/v1/placements", + headers={"origin": "https://example.com", "access-control-request-method": "POST"}, + ) + assert resp.status_code == 200 + + +def test_private_rules_without_a_key_answer_nothing( + private_api: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(api, "API_KEY", None) + resp = private_api.post("/v1/placements", content=SCENE, headers=JSON) + assert resp.status_code == 503 + assert resp.json()["error"]["code"] == "no_api_key" + + +def test_the_public_server_needs_no_key() -> None: + resp = TestClient(api.app).post("/v1/placements", content=SCENE, headers=JSON) + assert resp.status_code == 200 + + +def test_one_unseen_check_reads_in_the_singular() -> None: + raw = shared_fixture() + observed_band(raw, "overhead", [(-40, 5), (10, 40)]) # headroom alone unseen at the pad + loaded = rules_from_dict(public_rules_dict()) + result = solve(parse_scene(raw, loaded.rules), loaded) + assert "1 check depends on areas the scan did not see" in result["summary"] diff --git a/server/tests/test_siteplan.py b/server/tests/test_siteplan.py index 6e3a4390a..e4e1e0fd9 100644 --- a/server/tests/test_siteplan.py +++ b/server/tests/test_siteplan.py @@ -1,3 +1,4 @@ +import html import json import re import xml.etree.ElementTree as ET @@ -26,7 +27,7 @@ def test_site_plan_is_well_formed_svg_with_the_key_elements(raw) -> None: root = ET.fromstring(svg) assert root.tag == f"{SVG}svg" assert root.find(f"{SVG}title") is not None - assert result["summary"] in svg + assert html.escape(result["summary"]) in svg classes = {el.get("class") for el in root.iter()} assert {"wall", "meter", "cable"} <= classes assert "battery" in classes or "battery unsure" in classes From 68084b5f764cc9e6dd9a2cd13840ddfbd67f4a3f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 12:06:55 -0500 Subject: [PATCH 27/52] server: a wall's source sets its default error S6's live map finds wall lines from the LiDAR mesh or ARKit's detected planes, not only from taps, and scene.json couldn't say so. Walls take an optional `source` (tap, mesh, plane; absent means tap, as before) that picks the default error when plus_minus_ft is absent: errors. wall_ft, mesh_ft, or the new plane_ft (0.75 ft, an untested estimate marked placeholder). All three keep the drift term. Additive within schema 1.0; the README's capture contract states the defaults. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +-- server/rules.py | 1 + server/rules.yaml | 3 ++ server/scene.py | 9 +++++- server/schemas/scene.schema.json | 3 +- server/tests/test_wall_source.py | 53 ++++++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 server/tests/test_wall_source.py diff --git a/server/README.md b/server/README.md index 69d1fcbc4..a1afe8d00 100644 --- a/server/README.md +++ b/server/README.md @@ -82,7 +82,7 @@ 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 0.3 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). +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). | Check | Reads | Coverage that settles it | | --- | --- | --- | @@ -99,7 +99,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `route_path` | `walls`, openings on the route | wall band from the meter to the battery's near edge | | `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`; `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). Ends and corners: diff --git a/server/rules.py b/server/rules.py index 2e431e686..5ba6c2f07 100644 --- a/server/rules.py +++ b/server/rules.py @@ -69,6 +69,7 @@ class Errors(_Strict): tap_ft: Value vlm_ft: Value mesh_ft: Value + plane_ft: Value tape_ft: Value wall_ft: Value meter_ft: Value diff --git a/server/rules.yaml b/server/rules.yaml index 5e2dfc3b6..983300f1c 100644 --- a/server/rules.yaml +++ b/server/rules.yaml @@ -29,6 +29,9 @@ errors: tap_ft: {value: 0.3, source: "docs/02 Lane C estimate for AR taps; untested"} vlm_ft: {value: 1.5, source: "docs/02 Lane C estimate for photo detections; untested"} mesh_ft: {value: 0.5, source: "docs/02 Lane C estimate for the LiDAR mesh; untested"} + # Walls from ARKit's detected planes on phones without LiDAR. No measurement exists; assumed + # coarser than the mesh until the tape test measures it. + plane_ft: {value: 0.75, source: "Untested estimate for ARKit plane detection without LiDAR; no measurement", placeholder: true} tape_ft: {value: 0.05, source: "Tape measure read to the nearest half inch", placeholder: true} wall_ft: {value: 0.3, source: "Walls come from AR taps: docs/02 Lane C estimate; untested"} meter_ft: {value: 0.3, source: "The meter is an AR tap: docs/02 Lane C estimate; untested"} diff --git a/server/scene.py b/server/scene.py index 7ea7831c5..51aa6ac18 100644 --- a/server/scene.py +++ b/server/scene.py @@ -576,7 +576,14 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N raise SceneError(f"/walls/{wi}/id", f"duplicate wall id {wid!r}") wall_ids.add(wid) pts = [_xz(p) for p in wall["baseline"]] - wall_err = _error(wall, errors.wall_ft.value) + # How the wall's line was found sets its default error: AR taps (also when `source` is + # absent), the LiDAR mesh, or detected planes. All three drift with distance walked. + default = { + "tap": errors.wall_ft, + "mesh": errors.mesh_ft, + "plane": errors.plane_ft, + }[wall.get("source", "tap")] + wall_err = _error(wall, default.value) wall_drift = 0.0 if "plus_minus_ft" in wall else drift pts = _merge_collinear(pts, COLLINEAR_FT, f"/walls/{wi}/baseline") if prev_end is not None: diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index c5416cd66..baed9c46e 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -41,7 +41,8 @@ "items": {"$ref": "#/$defs/point2"} }, "height_ft": {"type": "number", "exclusiveMinimum": 0}, - "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of this wall's line. Absent means the rules' wall default, growing with distance along the walls from the meter (errors.drift_per_ft)."} + "source": {"enum": ["tap", "mesh", "plane"], "description": "How the wall's line was found: AR taps, the LiDAR mesh, or ARKit's detected planes. Sets the default error when plus_minus_ft is absent (errors.wall_ft, mesh_ft, plane_ft in rules.yaml). Absent means tap."}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of this wall's line. Absent means the rules' default for the wall's source, growing with distance along the walls from the meter (errors.drift_per_ft)."} } } }, diff --git a/server/tests/test_wall_source.py b/server/tests/test_wall_source.py new file mode 100644 index 000000000..15642c34c --- /dev/null +++ b/server/tests/test_wall_source.py @@ -0,0 +1,53 @@ +"""A wall's `source` picks its default position error: tap (as when absent), mesh or plane.""" + +import pytest +from helpers import at_start, parsed, shared_fixture + +from rules import public_rules_dict, rules_from_dict +from scene import SceneError + +PUBLIC = rules_from_dict(public_rules_dict()) +ERRORS = PUBLIC.rules.errors + + +def wall_from(source: str | None) -> dict: + raw = shared_fixture() + del raw["walls"][0]["plus_minus_ft"] # take the default for the source + if source is not None: + raw["walls"][0]["source"] = source + return raw + + +@pytest.mark.parametrize( + ("source", "default"), + [ + (None, ERRORS.wall_ft), + ("tap", ERRORS.wall_ft), + ("mesh", ERRORS.mesh_ft), + ("plane", ERRORS.plane_ft), + ], +) +def test_each_source_takes_its_default_error(source: str | None, default) -> None: + meter_piece = parsed(wall_from(source), PUBLIC).meter_piece + assert meter_piece.plus_minus == pytest.approx(default.value) + + +def test_a_mesh_wall_and_a_tapped_wall_get_different_error_bars() -> None: + tapped = at_start(wall_from("tap"), 6.0, "meter_working_space", PUBLIC) + meshed = at_start(wall_from("mesh"), 6.0, "meter_working_space", PUBLIC) + # The same spot, measured against a wall known to 0.3 ft or to 0.5 ft (plus the same drift). + assert meshed.plus_minus - tapped.plus_minus == pytest.approx( + ERRORS.mesh_ft.value - ERRORS.wall_ft.value + ) + + +def test_an_explicit_error_overrides_the_source() -> None: + raw = wall_from("plane") + raw["walls"][0]["plus_minus_ft"] = 0.1 + assert parsed(raw, PUBLIC).meter_piece.plus_minus == pytest.approx(0.1) + + +def test_an_unknown_source_is_refused() -> None: + with pytest.raises(SceneError) as caught: + parsed(wall_from("lidar"), PUBLIC) + assert caught.value.path == "/walls/0/source" From 633f0469772297e987728cab6a8a745f67aa56d0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 12:36:14 -0500 Subject: [PATCH 28/52] server: coverage gaps under 1/8 in are float noise, not unseen S4's final app run got "Show the wall from 15 ft 0 in right of the meter to 15 ft 0 in right of the meter": the unrolled wall ends at 15.00006 ft and the app reported it seen to 15 ft. Gaps in a band's coverage narrower than COVERAGE_TOLERANCE_FT (0.01 ft) now count as seen, for the 1D bands and for ground (seen ground grows by half the tolerance), and narrower requests are dropped, so a check never waits on a view nobody could take. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 19 ++++++++++++++----- server/solver.py | 13 +++++++++++-- server/tests/test_capture_contract.py | 26 +++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/server/scene.py b/server/scene.py index 51aa6ac18..11da4b98b 100644 --- a/server/scene.py +++ b/server/scene.py @@ -252,8 +252,11 @@ def observed_intervals(self, band: str) -> list[tuple[float, float]]: return merge_intervals([(a, b) for a, b, _ in self.observed.get(band, [])]) def missing(self, band: str, s_lo: float, s_hi: float) -> list[tuple[float, float]]: - """Parts of [s_lo, s_hi] not observed in a 1D band.""" - return subtract_intervals((s_lo, s_hi), self.observed_intervals(band)) + """Parts of [s_lo, s_hi] not observed in a 1D band. A gap narrower than + COVERAGE_TOLERANCE_FT is rounding between the capture's spans and the unrolled walls (the + app reported a wall seen to 15 ft that unrolls to 15.00006 ft), not an unseen stretch.""" + gaps = subtract_intervals((s_lo, s_hi), self.observed_intervals(band)) + return [(a, b) for a, b in gaps if b - a >= COVERAGE_TOLERANCE_FT] def unobserved_ground(self) -> Geometry: if "ground" not in self._cache: @@ -265,7 +268,9 @@ def unobserved_ground(self) -> Geometry: for a, b, out in self.observed.get("ground", []) ] ) - unseen = outdoor.difference(seen.buffer(1e-6)) + # 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()]) return self._cache["ground"] @@ -286,7 +291,7 @@ def _unexplored_discs(self) -> list[Geometry]: 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", []) ] - ).buffer(1e-6) + ).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]) @@ -339,7 +344,7 @@ def unobserved_wall_lines(self) -> Geometry: """Stretches of the chain's line nobody saw, the part a view of the wall settles.""" if "wall-lines" not in self._cache: lo, hi = self.pieces[0].s0, self.pieces[-1].s1 - gaps = subtract_intervals((lo, hi), self.observed_intervals("wall")) + gaps = self.missing("wall", lo, hi) self._cache["wall-lines"] = unary_union([self.wall_line(a, b) for a, b in gaps]) return self._cache["wall-lines"] @@ -501,6 +506,10 @@ def _error(item: dict[str, Any], default: float) -> float: return float(item["plus_minus_ft"]) if "plus_minus_ft" in item else default +# 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 + # Coordinates beyond this are not a house scan; they would only exhaust memory in the sweep. MAX_COORDINATE_FT = 1e5 diff --git a/server/solver.py b/server/solver.py index 619cc8429..785f394e2 100644 --- a/server/solver.py +++ b/server/solver.py @@ -21,7 +21,16 @@ from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value -from scene import EPS, Measured, Piece, Scene, SceneObject, merge_intervals, subtract_intervals +from scene import ( + COVERAGE_TOLERANCE_FT, + EPS, + Measured, + Piece, + Scene, + SceneObject, + merge_intervals, + subtract_intervals, +) from units import format_ft_in PASS, FAIL, UNSURE = "pass", "fail", "unsure" @@ -389,7 +398,7 @@ def _missing(self, band: str, fp: Polygon, radius: float) -> list[View]: extents = [ e for part in getattr(region, "geoms", [region]) - if (e := self.scene.s_extent(part)) and e[1] - e[0] > _MEASURE_EPS + if (e := self.scene.s_extent(part)) and e[1] - e[0] >= COVERAGE_TOLERANCE_FT ] if extents: a, b = min(a for a, _ in extents), max(b for _, b in extents) diff --git a/server/tests/test_capture_contract.py b/server/tests/test_capture_contract.py index f3dbae55d..aeabf2013 100644 --- a/server/tests/test_capture_contract.py +++ b/server/tests/test_capture_contract.py @@ -9,6 +9,7 @@ from helpers import D, at_start, observed_band, parsed, shared_fixture from test_s4_round import PUBLIC, answer +from scene import COVERAGE_TOLERANCE_FT from solver import PASS, UNSURE, evaluate_start RESULT_SCHEMA = json.loads( @@ -198,7 +199,8 @@ def test_the_readme_coverage_settles_every_check() -> None: def test_less_than_the_readme_coverage_leaves_a_check_unseen() -> None: for band in EXACT: - assert unobserved_at_spot(readme_coverage(**{band: 0.01})) != [], band + # Twice the coverage tolerance: narrower gaps are float noise and count as seen. + assert unobserved_at_spot(readme_coverage(**{band: 2 * COVERAGE_TOLERANCE_FT})) != [], band def test_a_request_ending_at_the_meter_reads_naturally() -> None: @@ -206,3 +208,25 @@ def test_a_request_ending_at_the_meter_reads_naturally() -> None: observed_band(raw, "wall", [(-40, 0), (3, 40)]) # the cable's first 3 ft unseen messages = [m["message"] for m in answer(raw)["missing_evidence"] if m.get("band") == "wall"] assert messages == ["Show the wall from the meter to 3 ft 0 in right of the meter."] + + +# --- float noise at the end of the wall ----------------------------------------------------------- + + +def wall_seen_to_a_hair_short() -> dict: + """S4's replay upload: the unrolled wall ends at 11.00006 ft and the app reports it seen to + 11 ft. The gap is floating point noise, far under any error, not an unseen stretch.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [11.00006, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, 11.00006] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} # as the app sent it + observed_band(raw, "wall", [(-40, 11)]) + return raw + + +def test_a_hairline_gap_at_the_wall_end_asks_for_nothing() -> None: + # Before: "Show the wall from 11 ft 0 in right of the meter to 11 ft 0 in right of the meter". + result = answer(wall_seen_to_a_hair_short()) + assert [m for m in result["missing_evidence"] if m.get("band") == "wall"] == [] + opening = at_start(wall_seen_to_a_hair_short(), 6.0, "opening_clearance", PUBLIC) + assert [v for v in opening.all_missing() if v.band == "wall"] == [] From 737bf752526c50089de13f568259f18efd3f8567 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 13:09:48 -0500 Subject: [PATCH 29/52] server: repoint references to docs/01-03 before #14 deletes them Applies the S2 table in #14's re-pointing comment: rules.yaml sources and comment, the scene schema's description, the siteplan and solver docstrings, the route-crossing citation that reaches answers, and the minimal-scene test (renamed test_minimal_scene_validates). Sources now point to docs/00 (Conventions, Decisions) and docs/04 (Public rule values, What Base does today), or state their basis without a doc. No value changes; the rules fingerprint moves because source text is hashed. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/rules.yaml | 24 ++++++++++++------------ server/schemas/scene.schema.json | 2 +- server/siteplan.py | 6 +++--- server/solver.py | 11 ++++++----- server/tests/test_schemas.py | 4 ++-- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/server/rules.yaml b/server/rules.yaml index 983300f1c..2235bf7bc 100644 --- a/server/rules.yaml +++ b/server/rules.yaml @@ -19,29 +19,29 @@ policy: notice: "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." battery: - width_ft: {value: 2.583333333333, source: "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/01 C2)"} + width_ft: {value: 2.583333333333, source: "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/04, What Base does today)"} depth_ft: {value: 1.833333333333, source: "Base Core spec: 22 in deep"} height_ft: {value: 3.291666666667, source: "Base Core spec: 39.5 in tall"} # Default measurement error by how a position was measured, used when an item has no plus_minus_ft. -# These are the plan's estimates (docs/02 Lane C), not measured error bars; the D3 tape test checks them. +# Day-1 estimates, not measured error bars (docs/00, Conventions the code relies on); the field test in experiments/evals/field checks them. errors: - tap_ft: {value: 0.3, source: "docs/02 Lane C estimate for AR taps; untested"} - vlm_ft: {value: 1.5, source: "docs/02 Lane C estimate for photo detections; untested"} - mesh_ft: {value: 0.5, source: "docs/02 Lane C estimate for the LiDAR mesh; untested"} + tap_ft: {value: 0.3, source: "Day-1 estimate for AR taps; untested (docs/00, Conventions)"} + vlm_ft: {value: 1.5, source: "Day-1 estimate for photo detections; untested (docs/00, Conventions)"} + mesh_ft: {value: 0.5, source: "Day-1 estimate for the LiDAR mesh; untested (docs/00, Conventions)"} # Walls from ARKit's detected planes on phones without LiDAR. No measurement exists; assumed # coarser than the mesh until the tape test measures it. plane_ft: {value: 0.75, source: "Untested estimate for ARKit plane detection without LiDAR; no measurement", placeholder: true} tape_ft: {value: 0.05, source: "Tape measure read to the nearest half inch", placeholder: true} - wall_ft: {value: 0.3, source: "Walls come from AR taps: docs/02 Lane C estimate; untested"} - meter_ft: {value: 0.3, source: "The meter is an AR tap: docs/02 Lane C estimate; untested"} + wall_ft: {value: 0.3, source: "Walls come from AR taps: day-1 estimate; untested (docs/00, Conventions)"} + meter_ft: {value: 0.3, source: "The meter is an AR tap: day-1 estimate; untested (docs/00, Conventions)"} # Added per foot walked from the meter to the default error of AR-placed positions (tap and # vlm objects, walls, ground patches); not to tape, the meter itself or explicit plus_minus_ft. # 0.3 ft + 0.16 ft/ft covers S1's p90 at 3, 10 and 30 ft walked (0.78, 1.9, 5.1 ft). drift_per_ft: {value: 0.16, source: "S1 real-data evals (PR #12): ARKit drift on outdoor ADVIO walks, p90 8.2 in after 3 ft, 22.3 in after 10 ft, 60.1 in after 30 ft, mostly ~7% scale error. 2018 iPhone 6s, ARKit 1.0; current phones untested"} sweep: - step_ft: {value: 0.166666666667, source: "docs/01 C2: slide the footprint in 2 in steps"} + step_ft: {value: 0.166666666667, source: "Slide the footprint in 2 in steps (docs/00, Decisions)"} # Wall ends closer than this are one corner, not a stretch with no wall. Two taps of one corner can # differ by twice the tap error. wall_join_ft: {value: 0.6, source: "Twice the AR tap error estimate", placeholder: true} @@ -52,8 +52,8 @@ clearances: gas_ft: {value: 3.0, source: "Base help page: 3 ft from the gas meter; Austin Energy §1.9; Texas Gas Service"} ac_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries"} opening_ft: {value: 3.0, 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"} - drive_ft: {value: 5.0, source: "No public value (docs/01 rules table: TBD)", placeholder: true} - pool_ft: {value: 10.0, source: "No public value (docs/01 rules table: TBD); the demo ring drawn in docs/eli5.html", placeholder: true} + drive_ft: {value: 5.0, source: "No public value (docs/04, Public rule values)", placeholder: true} + pool_ft: {value: 10.0, source: "No public value (docs/04, Public rule values)", placeholder: true} # Wall-mounted equipment directly above the battery: minimum sideways gap along the wall. wall_equipment_ft: {value: 0.0, source: "No public value; 0 means only overlap fails", placeholder: true} @@ -86,7 +86,7 @@ ground: allowed: [concrete, gravel, lawn, mulch] # Surfaces vehicles reach, measured by clearances.drive_ft. drivable: [drive] - source: "docs/01 ground types; which surfaces are allowed is a demo choice" + source: "Ground types from schemas/scene.schema.json; which surfaces are allowed is a demo choice" placeholder: true route: @@ -94,7 +94,7 @@ route: # Past this length the route is UNSURE; beyond max_ft it fails. confident_reach_ft: {value: 15.0, source: "No public value", placeholder: true} height_ft: {value: 1.0, source: "Cable run height above ground", placeholder: true} - corner_allowance_ft: {value: 0.5, source: "docs/02 Lane C: a little per corner", placeholder: true} + corner_allowance_ft: {value: 0.5, source: "Day-1 estimate: a little per corner; untested", placeholder: true} # What a wall object across the route does: fail, review (UNSURE for a person), detour (go over or # under it at route height), allow (pass behind it). Types not listed are allowed. crossing: diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index baed9c46e..3e4a3d59f 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/SamGu-NRX/house-scanning/server/schemas/scene.schema.json", "title": "House scan scene", - "description": "What the phone measured around the electric meter (contract C1, docs/01-feature-map.md). Every length is in feet. Plan coordinates [x, z] are the horizontal axes of one gravity-aligned scene frame (ARKit .gravity world alignment: +y up), so [x, z] ignores height. `s` is the distance along the unrolled wall chain from the meter, negative to the left as seen by someone outside facing the wall. Unknown fields are rejected so a misspelled field fails loudly instead of being ignored.", + "description": "What the phone measured around the electric meter (the scene contract; server/README.md, \"Writing a scene\"). Every length is in feet. Plan coordinates [x, z] are the horizontal axes of one gravity-aligned scene frame (ARKit .gravity world alignment: +y up), so [x, z] ignores height. `s` is the distance along the unrolled wall chain from the meter, negative to the left as seen by someone outside facing the wall. Unknown fields are rejected so a misspelled field fails loudly instead of being ignored.", "type": "object", "additionalProperties": false, "required": ["meter", "walls"], diff --git a/server/siteplan.py b/server/siteplan.py index ba231ccaa..1173e1da3 100644 --- a/server/siteplan.py +++ b/server/siteplan.py @@ -1,8 +1,8 @@ -"""Draw a placement result as a top-down 2D site plan (docs/01 D2) in SVG. +"""Draw a placement result as a top-down 2D site plan in SVG. The plan is drawn in the meter wall's frame: the wall runs left to right across the page with the -house above it and the yard below, whatever the scene frame's axes are. Colours follow docs/01 D2 -(blue meter area, purple cable, red battery spot and its clearance); gas is utility-locate yellow. +house above it and the yard below, whatever the scene frame's axes are. Colours: blue meter area, +purple cable, red battery spot and its clearance; gas is utility-locate yellow. Ground nobody saw is hatched, as in the app's coverage fog. The deciding clearances are drawn as dimension lines with their measured values, so the drawing shows why the spot works. diff --git a/server/solver.py b/server/solver.py index 785f394e2..cdc536dd7 100644 --- a/server/solver.py +++ b/server/solver.py @@ -1,9 +1,10 @@ """The placement solver: a pure function from a parsed scene and the rules to a result. It slides the battery footprint along every straight wall segment, runs every check at each start -position, and decides. Every check follows the strict rule (plan contract C5, docs/02 Lane C): -PASS only when the margin is larger than the error, FAIL only when the value is past the threshold -by more than the error, otherwise UNSURE. An area nobody observed is never a pass. +position, and decides. Every check follows the strict rule (docs/00, Decisions: every check +answers PASS, FAIL or UNSURE): PASS only when the margin is larger than the error, FAIL only when +the value is past the threshold by more than the error, otherwise UNSURE. An area nobody observed +is never a pass. Start positions are a 2 in grid plus every position where some check can change its outcome (an opening's clearance edge, a coverage boundary, a ground patch edge), plus the midpoints between @@ -753,8 +754,8 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C "", "route.crossing", None, - rule_source="docs/02 Lane C: the cable can't cross a door, a garage or a " - "stretch with no wall", + rule_source="Demo rule: the cable can't cross a door, a garage or a stretch with " + "no wall", ) if "fail" in effects: path.outcome = FAIL diff --git a/server/tests/test_schemas.py b/server/tests/test_schemas.py index f5fb76c0b..08e3888e8 100644 --- a/server/tests/test_schemas.py +++ b/server/tests/test_schemas.py @@ -20,8 +20,8 @@ def test_example_scene_validates() -> None: Draft202012Validator(SCENE_SCHEMA).validate(EXAMPLE) -def test_docs01_minimal_scene_validates() -> None: - # The docs/01 fields alone, without any optional addition, are a valid scene. +def test_minimal_scene_validates() -> None: + # The required fields alone, without any optional addition, are a valid scene. minimal = { "meter": {"pos": [0, 5, 0], "wall_id": "w1"}, "walls": [{"id": "w1", "baseline": [[-10, 0], [10, 0]], "height_ft": 9}], From 930e8e5e0afe6ea9373fc65fbd351d6c257d475c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 14:07:01 -0500 Subject: [PATCH 30/52] server: wall coverage says how high up the face was seen The app hardcoded the wall band's top at headroom height, a clearance number living outside rules.yaml. A wall entry in coverage.observed may now carry out_ft, the height above the ground up to which the face was seen, and each check that reads the wall band credits it only where it exceeds the height that check needs, from rules.yaml: the battery's height behind it (wall_backing), the cable's run height (route_path), and headroom height for boxes, vents, gas and openings above the battery (or openings.exempt_bottom_above_ft when lower). Absent out_ft keeps today's meaning, seen to headroom height, so every answer is unchanged except that a wall request now carries the height that settles it (out_ft and ", seen at least ... up the wall"). Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 14 ++-- server/scene.py | 55 +++++++++----- server/schemas/result.schema.json | 2 +- server/schemas/scene.schema.json | 4 +- server/solver.py | 65 +++++++++++----- server/tests/test_capture_contract.py | 6 +- server/tests/test_wall_height.py | 105 ++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 47 deletions(-) create mode 100644 server/tests/test_wall_height.py diff --git a/server/README.md b/server/README.md index a1afe8d00..e456ac10e 100644 --- a/server/README.md +++ b/server/README.md @@ -82,21 +82,21 @@ 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). +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. | Check | Reads | Coverage that settles it | | --- | --- | --- | -| `wall_backing` | `walls` | wall band over [s0, s1] | +| `wall_backing` | `walls` | wall band over [s0, s1], seen higher than the battery (3.29) | | `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 | +| `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 | | `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] | -| `wall_equipment_above` (r = 0) | `objects` of type `elec_box`, `vent` | wall band over [s0 − r, s1 + r] | +| `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 | +| `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) | | `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). @@ -107,7 +107,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 and overhead requests carry one). +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 diff --git a/server/scene.py b/server/scene.py index 11da4b98b..5a351f08b 100644 --- a/server/scene.py +++ b/server/scene.py @@ -248,14 +248,28 @@ def wall_line(self, s_lo: float, s_hi: float) -> Geometry: # --- coverage -------------------------------------------------------------------------------- - def observed_intervals(self, band: str) -> list[tuple[float, float]]: - return merge_intervals([(a, b) for a, b, _ in self.observed.get(band, [])]) - - def missing(self, band: str, s_lo: float, s_hi: float) -> list[tuple[float, float]]: - """Parts of [s_lo, s_hi] not observed in a 1D band. A gap narrower than - COVERAGE_TOLERANCE_FT is rounding between the capture's spans and the unrolled walls (the - app reported a wall seen to 15 ft that unrolls to 15.00006 ft), not an unseen stretch.""" - gaps = subtract_intervals((s_lo, s_hi), self.observed_intervals(band)) + def observed_intervals( + self, band: str, up_to: float | None = None + ) -> list[tuple[float, float]]: + """Observed stretches of a 1D band. With `up_to`, only views that reached above that + height count: a wall entry's out_ft is how high up the face was seen, and one without + out_ft saw the whole band.""" + 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 + ] + ) + + def missing( + self, band: str, s_lo: float, s_hi: float, up_to: float | None = None + ) -> list[tuple[float, float]]: + """Parts of [s_lo, s_hi] not observed in a 1D band (above `up_to`, see + observed_intervals). A gap narrower than COVERAGE_TOLERANCE_FT is rounding between the + capture's spans and the unrolled walls (the app reported a wall seen to 15 ft that + unrolls to 15.00006 ft), not an unseen stretch.""" + gaps = subtract_intervals((s_lo, s_hi), self.observed_intervals(band, up_to)) return [(a, b) for a, b in gaps if b - a >= COVERAGE_TOLERANCE_FT] def unobserved_ground(self) -> Geometry: @@ -340,13 +354,15 @@ def coverable(self, band: str) -> Geometry: self._cache[key] = self.wall_line(lo, hi).buffer(1e-6, cap_style="flat") return self._cache[key] - def unobserved_wall_lines(self) -> Geometry: - """Stretches of the chain's line nobody saw, the part a view of the wall settles.""" - if "wall-lines" not in self._cache: + def unobserved_wall_lines(self, up_to: float | None = None) -> Geometry: + """Stretches of the chain's line nobody saw up to height `up_to`, the part a view of the + wall settles.""" + key = f"wall-lines-{up_to}" + if key not in self._cache: lo, hi = self.pieces[0].s0, self.pieces[-1].s1 - gaps = self.missing("wall", lo, hi) - self._cache["wall-lines"] = unary_union([self.wall_line(a, b) for a, b in gaps]) - return self._cache["wall-lines"] + gaps = self.missing("wall", lo, hi, up_to) + self._cache[key] = unary_union([self.wall_line(a, b) for a, b in gaps]) + return self._cache[key] def unexplored_area(self) -> Geometry: """Where the walls past an unexplored end might run: in front of the scanned walls only @@ -355,12 +371,13 @@ def unexplored_area(self) -> Geometry: self._cache["unexplored"] = unary_union(self._unexplored_discs()) return self._cache["unexplored"] - def unobserved_wall(self) -> Geometry: - if "wall" not in self._cache: - self._cache["wall"] = unary_union( - [self.unobserved_wall_lines(), self.unexplored_area()] + def unobserved_wall(self, up_to: float | None = None) -> Geometry: + key = f"wall-{up_to}" + if key not in self._cache: + self._cache[key] = unary_union( + [self.unobserved_wall_lines(up_to), self.unexplored_area()] ) - return self._cache["wall"] + return self._cache[key] def merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]: diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index 1c94d7115..a6896c301 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -69,7 +69,7 @@ "kind": {"enum": ["band", "past_end"]}, "band": {"enum": ["wall", "ground", "overhead", "facing"]}, "span_ft": {"$ref": "#/$defs/span"}, - "out_ft": {"type": "number", "minimum": 0, "description": "For ground, facing and overhead requests: how far the view must reach, out from the wall (ground, facing) or up from the ground (overhead). Report it as the observed entry's out_ft; seeing further also settles it."}, + "out_ft": {"type": "number", "minimum": 0, "description": "For ground, facing, overhead and wall requests: how far the view must reach, out from the wall (ground, facing) or up from the ground (overhead, wall). Report it as the observed entry's out_ft; seeing further also settles it."}, "side": {"enum": ["left", "right"]}, "checks": {"type": "array", "items": {"type": "string"}}, "message": {"type": "string"} diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index 3e4a3d59f..d8146e73b 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -150,10 +150,10 @@ "properties": { "band": { "enum": ["wall", "ground", "overhead", "facing"], - "description": "wall: the wall face from the ground up to headroom height (boxes and vents above the battery, openings), which also covers the cable route. 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." + "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`."} + "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\")."} }, "if": {"properties": {"band": {"const": "ground"}}}, "then": {"required": ["out_ft"]} diff --git a/server/solver.py b/server/solver.py index cdc536dd7..f07823f1b 100644 --- a/server/solver.py +++ b/server/solver.py @@ -253,7 +253,18 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: 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() - self.unobserved_wall = scene.unobserved_wall() + # 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 + # height above which a window no longer counts, when the rules set one). + above = r.headroom.min_ft.value + exempt = r.openings.exempt_bottom_above_ft + self.wall_height = { + "backing": r.battery.height_ft.value, + "route": r.route.height_ft.value, + "above": above, + "openings": above if exempt is None else min(above, exempt), + } ws = r.meter_working_space 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) @@ -315,10 +326,11 @@ 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 - missing = self.scene.missing("wall", s0, s1) + up_to = self.wall_height["backing"] + missing = self.scene.missing("wall", s0, s1, up_to) if missing: c.outcome, c.unsure_cause = UNSURE, "unobserved" - c.missing = [View("wall", a, b) for a, b in missing] + c.missing = [View("wall", a, b, _above(up_to)) for a, b in missing] c.reason = "Part of the wall behind the battery was not seen." return c c.reason = "The whole footprint backs onto one straight, observed wall segment." @@ -374,10 +386,12 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: c.reason = "The footprint sits within measurement error of a surface boundary." return c - def _unobserved(self, band: str) -> Geometry: - return self.unobserved_ground if band == "ground" else self.unobserved_wall + def _unobserved(self, band: str, up_to: float | None) -> Geometry: + return self.unobserved_ground if band == "ground" else self.scene.unobserved_wall(up_to) - def _missing(self, band: str, fp: Polygon, radius: float) -> list[View]: + def _missing( + self, band: str, fp: Polygon, radius: float, up_to: float | None = None + ) -> list[View]: """The views that would show what nobody saw within `radius` of the footprint. Only what a view can settle is requested; the rest lies past an unexplored end, which the past_end request covers. For the wall band, the area past an unexplored end that lies in @@ -388,7 +402,7 @@ def _missing(self, band: str, fp: Polygon, radius: float) -> list[View]: parts = [("ground", self.unobserved_ground)] else: parts = [ - ("wall", self.scene.unobserved_wall_lines()), + ("wall", self.scene.unobserved_wall_lines(up_to)), ("ground", self.scene.unexplored_area()), ] out = [] @@ -403,14 +417,19 @@ def _missing(self, band: str, fp: Polygon, radius: float) -> list[View]: ] if extents: a, b = min(a for a, _ in extents), max(b for _, b in extents) - depth = _up(self.scene.farthest_out(region)) if view == "ground" else None + if view == "ground": + depth = _up(self.scene.farthest_out(region)) + else: + depth = None if up_to is None else _above(up_to) out.append(View(view, a, b, depth)) return out - def _missing_bands(self, bands: list[str], fp: Polygon, radius: float) -> list[View]: + def _missing_bands( + self, bands: list[str], fp: Polygon, radius: float, up_to: float | None + ) -> list[View]: """Every unseen band, so one recapture settles the check (a clearance can need both the ground and the wall).""" - return [m for band in bands for m in self._missing(band, fp, radius)] + return [m for band in bands for m in self._missing(band, fp, radius, up_to)] def check_clearance( self, @@ -423,9 +442,12 @@ def check_clearance( items: list[tuple[str, Geometry, float, bool | None]], band: str, noun: str, + wall_height: float | 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) where counts None means an unknown attribute decides whether it applies. + `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") worst_key: tuple[int, float] | None = None @@ -453,8 +475,12 @@ def check_clearance( # have been seen reaches that much further. radius = t + piece.plus_minus bands = ["ground", "wall"] if band == "ground+wall" else [band] - unseen = [b for b in bands if not self._covered(fp, self._unobserved(b), radius)] - missing_later = partial(self._missing_bands, unseen, fp, radius) if unseen else None + unseen = [ + b for b in bands if not self._covered(fp, self._unobserved(b, wall_height), radius) + ] + missing_later = ( + partial(self._missing_bands, unseen, fp, radius, wall_height) if unseen else None + ) if c.outcome == UNSURE: if c.unsure_cause == "unknown_attribute": c.reason = ( @@ -527,7 +553,8 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: if c.outcome == FAIL: c.reason = f"{c.subject} is on the wall directly above the battery." return c - missing = self.scene.missing("wall", s0 - t, s1 + t) + up_to = self.wall_height["above"] + missing = self.scene.missing("wall", s0 - t, s1 + t, up_to) if c.outcome == UNSURE: c.unsure_cause = "margin" c.reason = f"{c.subject} ends within measurement error of the battery's edge." @@ -537,7 +564,7 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: else: c.reason = "Nothing is mounted on the wall above the battery." if missing and c.outcome == UNSURE: - c.missing = [View("wall", a, b) for a, b in missing] + c.missing = [View("wall", a, b, _above(up_to)) for a, b in missing] return c def check_meter_space(self, piece: Piece, fp: Polygon, s0: float, s1: float) -> Check: @@ -745,7 +772,8 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C length = ( (hi - lo) + corners * r.corner_allowance_ft.value + sum(d["extra_ft"] for d in detours) ) - missing = self.scene.missing("wall", lo, hi) + route_height = self.wall_height["route"] + missing = self.scene.missing("wall", lo, hi, route_height) path = Check( "route_path", @@ -783,7 +811,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C ) elif missing: path.outcome, path.unsure_cause = UNSURE, "unobserved" - path.missing = [View("wall", a, b) for a, b in missing] + path.missing = [View("wall", a, b, _above(route_height)) for a, b in missing] path.reason = "Part of the wall the cable would run along was not seen." else: path.reason = "The cable runs along continuous, observed wall with nothing blocking it." @@ -869,6 +897,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: # Gas meters hang on the wall as well as standing on the ground. "ground+wall", "gas meter or pipe", + self.wall_height["above"], ), self.check_clearance( "ac_clearance", @@ -916,6 +945,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: [(o.label, o.geom, o.plus_minus, self._opening_counts(o)) for o in self.openings], "wall", "door or window", + self.wall_height["openings"], ), self.check_along_wall(piece, s0, s1), self.check_measured( @@ -1182,6 +1212,7 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: _DEPTH_TEXT = { + "wall": ", seen at least {} up the wall", "ground": ", at least {} out from the wall", "facing": ", clear at least {} out from the wall", "overhead": ", clear at least {} up", diff --git a/server/tests/test_capture_contract.py b/server/tests/test_capture_contract.py index aeabf2013..e7ca3453b 100644 --- a/server/tests/test_capture_contract.py +++ b/server/tests/test_capture_contract.py @@ -207,7 +207,11 @@ def test_a_request_ending_at_the_meter_reads_naturally() -> None: raw = shared_fixture() observed_band(raw, "wall", [(-40, 0), (3, 40)]) # the cable's first 3 ft unseen messages = [m["message"] for m in answer(raw)["missing_evidence"] if m.get("band") == "wall"] - assert messages == ["Show the wall from the meter to 3 ft 0 in right of the meter."] + # The cable runs 1 ft up (route.height_ft), so that is how high the view must reach. + assert messages == [ + "Show the wall from the meter to 3 ft 0 in right of the meter, seen at least 1 ft 0 in " + "up the wall." + ] # --- float noise at the end of the wall ----------------------------------------------------------- diff --git a/server/tests/test_wall_height.py b/server/tests/test_wall_height.py new file mode 100644 index 000000000..8021607b7 --- /dev/null +++ b/server/tests/test_wall_height.py @@ -0,0 +1,105 @@ +"""How high up the wall the capture saw (`out_ft` on a wall entry), against the height each check +needs from rules.yaml. Absent `out_ft` means seen up to headroom height, as before.""" + +import copy +import itertools + +from helpers import at_start, observed_band, parsed, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +from solver import SOLVE_BUDGET_S, UNSURE, Solver, evaluate_start + +RULES = PUBLIC.rules +BATTERY_TOP = RULES.battery.height_ft.value # the wall behind the battery +ROUTE = RULES.route.height_ft.value # where the cable runs +HEADROOM = RULES.headroom.min_ft.value # boxes, vents, openings and gas on the wall + + +def wall_seen_to(height: float | None) -> dict: + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 40)]) + if height is not None: + raw["coverage"]["observed"][-1]["out_ft"] = height + return raw + + +def unseen(raw: dict, check_id: str) -> bool: + check = at_start(raw, 6.0, check_id, PUBLIC) + return check.outcome == UNSURE and check.unsure_cause == "unobserved" + + +def without_timing(result: dict) -> dict: + return result | {"stats": {k: v for k, v in result["stats"].items() if k != "elapsed_ms"}} + + +def test_absent_out_ft_is_seen_all_the_way_up() -> None: + absent, far = answer(wall_seen_to(None)), answer(wall_seen_to(100.0)) + # Only the input hash differs: the scenes differ by that one field. + far["stats"]["input_sha256"] = absent["stats"]["input_sha256"] + assert without_timing(absent) == without_timing(far) + + +def test_each_check_needs_its_own_height() -> None: + # Seen 2 ft up: enough for the cable at 1 ft, not for the battery's back or anything above it. + raw = wall_seen_to(2.0) + assert ROUTE < 2.0 < BATTERY_TOP + assert not unseen(raw, "route_path") + for check_id in ("wall_backing", "wall_equipment_above", "opening_clearance", "gas_clearance"): + assert unseen(raw, check_id), check_id + + +def test_the_battery_height_settles_its_back_but_not_what_is_above() -> None: + raw = wall_seen_to(BATTERY_TOP + 0.1) + assert not unseen(raw, "wall_backing") + assert unseen(raw, "wall_equipment_above") + + +def test_a_wall_request_names_the_height_that_settles_it() -> None: + request = next(m for m in answer(wall_seen_to(2.0))["missing_evidence"] if m["band"] == "wall") + assert request["out_ft"] > HEADROOM + assert "up the wall" in request["message"] + + +def supplied(raw: dict, result: dict, shortfall: float = 0.0) -> dict: + out = copy.deepcopy(raw) + for item in result["missing_evidence"]: + if item["kind"] == "band": + depth = {"out_ft": item["out_ft"] - shortfall} if "out_ft" in item else {} + out["coverage"]["observed"].append( + {"band": item["band"], "span_ft": item["span_ft"], **depth} + ) + return out + + +@settings(max_examples=40, deadline=None) +@given( + heights=st.lists(st.floats(min_value=0.2, max_value=9.0), min_size=1, max_size=4), + cuts=st.lists(st.floats(min_value=-15, max_value=25), min_size=3, max_size=3), +) +def test_supplying_the_requested_height_settles_it_and_less_does_not( + heights: list[float], cuts: list[float] +) -> None: + raw = shared_fixture() + edges = [-40.0, *sorted(cuts), 40.0] + observed_band(raw, "wall", []) + # Each stretch between cuts seen to its own height (the heights repeat if there are fewer). + for (a, b), height in zip(itertools.pairwise(edges), heights * 4, strict=False): + raw["coverage"]["observed"].append({"band": "wall", "span_ft": [a, b], "out_ft": height}) + result = answer(raw) + requests = [m for m in result["missing_evidence"] if m.get("band") == "wall"] + if not requests or result["spot"] is None: + return + # The exact start the solver chose; the answer's is rounded to 6 decimals. + s0 = min( + (c.s0 for c in Solver(parsed(raw, PUBLIC), PUBLIC).candidates(SOLVE_BUDGET_S)), + key=lambda start: abs(start - result["spot"]["span_ft"][0]), + ) + + def unseen_at_spot(scene: dict) -> list[str]: + candidate = evaluate_start(parsed(scene, PUBLIC), PUBLIC, s0) + return [c.id for c in candidate.checks if c.unsure_cause == "unobserved"] + + assert unseen_at_spot(supplied(raw, result)) == [] + assert unseen_at_spot(supplied(raw, result, shortfall=0.05)) != [] From e106acb11983a524110e92077399f983c36bb063 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 14:19:51 -0500 Subject: [PATCH 31/52] server: smoke.py keeps the private key on https and off redirects CodeRabbit on 930e8e5: smoke.py sent `Authorization: Bearer ` to any URL, plain http included, and urllib copies that header onto a followed 301/302/303 redirect, even to another server. With a key it now refuses any URL that isn't https or this machine, and reports a redirect instead of following it. Tests cover both, the redirect one with two local servers where the second must never see the key. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/examples/README.md | 2 +- server/examples/smoke.py | 35 ++++++++++++- server/tests/test_smoke_script.py | 87 +++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 server/tests/test_smoke_script.py diff --git a/server/examples/README.md b/server/examples/README.md index 62d52b884..c750a72ee 100644 --- a/server/examples/README.md +++ b/server/examples/README.md @@ -19,7 +19,7 @@ make smoke URL=https://house-scanning-server.vercel.app make smoke URL=https://house-scanning-server-private.vercel.app KEY_FILE=server/.env.private.local ``` -`make smoke` runs `server/examples/smoke.py`, which needs only Python 3. It exits 1 when any request fails. +`make smoke` runs `server/examples/smoke.py`, which needs only Python 3. It exits 1 when any request fails. With a key it sends only to an `https://` URL (or `http://localhost`) and doesn't follow redirects, so the key can't travel in the clear or to another server. ## One call diff --git a/server/examples/smoke.py b/server/examples/smoke.py index 927b11226..24905913b 100644 --- a/server/examples/smoke.py +++ b/server/examples/smoke.py @@ -11,6 +11,7 @@ import json import sys import urllib.error +import urllib.parse import urllib.request from pathlib import Path @@ -25,15 +26,45 @@ def read_key(path: Path) -> str: raise SystemExit(f"{path}: no HOUSESCAN_API_KEY= line") +# Plain http may carry the key only to this machine, where it never crosses a network. +LOOPBACK = {"localhost", "127.0.0.1", "::1"} + + +def check_keyed_url(url: str) -> None: + """Refuse to send the key where someone on the network could read it.""" + parts = urllib.parse.urlsplit(url) + if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in LOOPBACK): + raise SystemExit(f"{url}: the key is only sent over https (or to localhost)") + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """urllib copies Authorization onto a followed redirect, even to another server; a keyed + request reports the redirect instead of following it.""" + + def redirect_request(self, *args: object) -> None: + return None + + +_KEYED = urllib.request.build_opener(_NoRedirect) + + def post(url: str, body: bytes, key: str | None) -> tuple[int, dict]: headers = {"Content-Type": "application/json"} + opener = urllib.request.urlopen if key: + check_keyed_url(url) headers["Authorization"] = f"Bearer {key}" + opener = _KEYED.open request = urllib.request.Request(url, data=body, headers=headers, method="POST") try: - with urllib.request.urlopen(request, timeout=30) as response: + with opener(request, timeout=30) as response: return response.status, json.load(response) except urllib.error.HTTPError as error: + if key and 300 <= error.code < 400: + where = error.headers.get("Location") + return error.code, { + "error": {"code": "redirect_refused", "message": f"not following to {where}"} + } text = error.read().decode(errors="replace") try: return error.code, json.loads(text) @@ -50,6 +81,8 @@ def main() -> int: parser.add_argument("--key-file", type=Path, help="file with HOUSESCAN_API_KEY=") args = parser.parse_args() key = read_key(args.key_file) if args.key_file else None + if key: + check_keyed_url(args.url) endpoint = args.url.rstrip("/") + "/v1/placements" failed = False for scene in sorted(EXAMPLES.glob("*.json")): diff --git a/server/tests/test_smoke_script.py b/server/tests/test_smoke_script.py new file mode 100644 index 000000000..079c785a5 --- /dev/null +++ b/server/tests/test_smoke_script.py @@ -0,0 +1,87 @@ +"""examples/smoke.py sends the private key only over HTTPS (or to this machine), and never +follows a redirect with it.""" + +import http.server +import importlib.util +import threading +from pathlib import Path +from typing import ClassVar + +import pytest + +SMOKE = Path(__file__).resolve().parents[1] / "examples" / "smoke.py" +spec = importlib.util.spec_from_file_location("smoke", SMOKE) +smoke = importlib.util.module_from_spec(spec) +spec.loader.exec_module(smoke) + + +@pytest.mark.parametrize( + "url", + [ + "http://house-scanning-server-private.vercel.app", + "http://192.168.1.20:8000", + "ftp://x.example", + ], +) +def test_a_key_is_never_sent_in_the_clear(url: str) -> None: + with pytest.raises(SystemExit, match="https"): + smoke.check_keyed_url(url) + + +@pytest.mark.parametrize( + "url", + [ + "https://house-scanning-server-private.vercel.app", + "http://localhost:8000", + "http://127.0.0.1:9", + ], +) +def test_https_and_this_machine_may_carry_a_key(url: str) -> None: + smoke.check_keyed_url(url) + + +class Recorder(http.server.BaseHTTPRequestHandler): + """Answers every POST; `target` redirects to it, and records what reached it.""" + + seen: ClassVar[list[str | None]] = [] + location = "" + + def do_POST(self) -> None: + if self.location: + self.send_response(302) + self.send_header("Location", self.location) + self.end_headers() + return + type(self).seen.append(self.headers.get("Authorization")) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok": true}') + + # urllib follows a 302 as a GET, carrying the request's headers with it. + do_GET = do_POST + + def log_message(self, *args: object) -> None: + pass + + +def serve(handler: type) -> http.server.HTTPServer: + server = http.server.HTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +def test_a_keyed_request_does_not_follow_a_redirect() -> None: + target = serve(type("Target", (Recorder,), {"seen": [], "location": ""})) + port = target.server_address[1] + redirecting = serve( + type("Redirect", (Recorder,), {"seen": [], "location": f"http://127.0.0.1:{port}/"}) + ) + try: + url = f"http://127.0.0.1:{redirecting.server_address[1]}/v1/placements" + status, body = smoke.post(url, b"{}", "secret-for-this-test") + assert status == 302 + assert body["error"]["code"] == "redirect_refused" + assert target.RequestHandlerClass.seen == [] # the key never reached the other server + finally: + target.shutdown() + redirecting.shutdown() From 074bf1d5b69ada5659fe92ba2ec5d0951654c83f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 17:14:56 -0500 Subject: [PATCH 32/52] server: an existing battery keeps a new one 3 ft away (issue #27) The first real-phone run's wall already had a battery, and the scene couldn't say so. `battery` is now an object type, held to clearances.battery_ft (3 ft; Base's help page and IRC 2021 R328), measured in plan like the AC clearance under the strict rule. The cable detours over it on its route, as over a box, since it stands flush against the wall. The check appears when the scene marks a battery (or the rules make its reach larger than the AC clearance's); otherwise it would repeat the AC check's coverage, so every earlier answer is unchanged apart from the rules hash. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 1 + server/rules.py | 12 +++- server/rules.yaml | 4 ++ server/scene.py | 7 +- server/schemas/scene.schema.json | 2 +- server/solver.py | 25 ++++++++ server/tests/test_existing_battery.py | 92 +++++++++++++++++++++++++++ 7 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 server/tests/test_existing_battery.py diff --git a/server/README.md b/server/README.md index e456ac10e..fbdae3332 100644 --- a/server/README.md +++ b/server/README.md @@ -91,6 +91,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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 | | `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) | diff --git a/server/rules.py b/server/rules.py index 5ba6c2f07..88d78e2a7 100644 --- a/server/rules.py +++ b/server/rules.py @@ -35,7 +35,16 @@ Effect = Literal["fail", "review", "detour", "allow"] ObjectType = Literal[ - "window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool" + "window", + "door", + "garage_door", + "ac", + "gas_meter", + "elec_box", + "vent", + "downspout", + "pool", + "battery", ] GroundType = Literal["drive", "concrete", "gravel", "lawn", "mulch", "deck"] @@ -85,6 +94,7 @@ class Sweep(_Strict): class Clearances(_Strict): gas_ft: Value ac_ft: Value + battery_ft: Value opening_ft: Value drive_ft: Value pool_ft: Value diff --git a/server/rules.yaml b/server/rules.yaml index 2235bf7bc..e774c6edb 100644 --- a/server/rules.yaml +++ b/server/rules.yaml @@ -51,6 +51,8 @@ sweep: clearances: gas_ft: {value: 3.0, source: "Base help page: 3 ft from the gas meter; Austin Energy §1.9; Texas Gas Service"} ac_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries"} + # A battery already on the wall (objects of type battery; issue #27). + battery_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries; IRC 2021 R328: 3 ft between batteries unless UL 9540A testing allows closer (docs/04, Public rule values)"} opening_ft: {value: 3.0, 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"} drive_ft: {value: 5.0, source: "No public value (docs/04, Public rule values)", placeholder: true} pool_ft: {value: 10.0, source: "No public value (docs/04, Public rule values)", placeholder: true} @@ -105,5 +107,7 @@ route: vent: detour gas_meter: detour ac: allow + # Flush against the wall like a box, so the cable can't pass behind it; it goes over. + battery: detour downspout: allow pool: allow diff --git a/server/scene.py b/server/scene.py index 5a351f08b..dfda07fbe 100644 --- a/server/scene.py +++ b/server/scene.py @@ -705,7 +705,12 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N # then counts as unseen. c = rules.clearances reach = max( - c.gas_ft.value, c.ac_ft.value, c.opening_ft.value, c.drive_ft.value, c.pool_ft.value + c.gas_ft.value, + c.ac_ft.value, + c.battery_ft.value, + c.opening_ft.value, + c.drive_ft.value, + 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 diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json index d8146e73b..695e5e4a9 100644 --- a/server/schemas/scene.schema.json +++ b/server/schemas/scene.schema.json @@ -54,7 +54,7 @@ "additionalProperties": false, "required": ["type", "wall_id", "span_ft", "source"], "properties": { - "type": {"enum": ["window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool"]}, + "type": {"enum": ["window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool", "battery"], "description": "battery: a battery already installed, which a new one keeps clearances.battery_ft from. Disconnects, sub-panels, EV chargers and solar equipment are elec_box."}, "wall_id": {"type": "string", "minLength": 1}, "span_ft": {"$ref": "#/$defs/span", "description": "Stretch of the wall the object covers, in s."}, "bottom_ft": {"type": "number", "minimum": 0, "description": "Height of the object's lowest edge above the ground."}, diff --git a/server/solver.py b/server/solver.py index f07823f1b..eeab2ad6f 100644 --- a/server/solver.py +++ b/server/solver.py @@ -241,6 +241,13 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: 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. + self.battery_check = bool(self.batteries) or ( + r.clearances.battery_ft.value > r.clearances.ac_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] self.equipment = [o for o in objs if o.type in r.wall_equipment.types] @@ -910,6 +917,23 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: "ground", "AC unit", ), + *( + [ + self.check_clearance( + "battery_clearance", + "Distance from an existing battery", + "clearances.battery_ft", + c.battery_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True) for o in self.batteries], + "ground", + "battery", + ) + ] + if self.battery_check + else [] + ), self.check_clearance( "drive_clearance", "Distance from the driveway", @@ -1068,6 +1092,7 @@ def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float]]: 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.drive_ft.value, g.polygon, g.plus_minus) for g in self.drives), diff --git a/server/tests/test_existing_battery.py b/server/tests/test_existing_battery.py new file mode 100644 index 000000000..fc30a972e --- /dev/null +++ b/server/tests/test_existing_battery.py @@ -0,0 +1,92 @@ +"""A battery already on the wall (issue #27): `battery` objects keep a new battery +clearances.battery_ft away, measured in plan like the AC clearance, under the strict rule.""" + +import json +from pathlib import Path + +import pytest +from helpers import W, at_start, rect, shared_fixture +from jsonschema import Draft202012Validator +from test_s4_round import PUBLIC, answer + +from rules import load_rules +from solver import FAIL, PASS, UNSURE + +S0 = 6.0 # the new battery at [S0, S0 + W] on the shared wall's lawn pad +S1 = S0 + W +DEPTH = 22 / 12 + + +def existing_battery(gap: float, plus_minus: float = 0.1, top: float | None = 3.29) -> dict: + """An existing battery standing against the wall `gap` ft right of the new one.""" + raw = shared_fixture() + x0 = S1 + gap + item = { + "type": "battery", + "wall_id": "w1", + "span_ft": [x0, x0 + W], + "bottom_ft": 0, + "source": "tap", + "plus_minus_ft": plus_minus, + "footprint": rect(x0, x0 + W, 0, DEPTH), + } + if top is not None: + item["top_ft"] = top + raw["objects"] = [item] + return raw + + +@pytest.mark.parametrize( + ("gap", "outcome"), + [ + (1.0, FAIL), # misses the 3 ft rule by more than the 0.1 ft error + (4.0, PASS), # clears it by more than the error + (3.05, UNSURE), # within the error either way + ], +) +def test_an_existing_battery_is_held_to_the_rule(gap: float, outcome: str) -> None: + check = at_start(existing_battery(gap), S0, "battery_clearance", PUBLIC) + assert check.outcome == outcome + assert check.threshold == PUBLIC.rules.clearances.battery_ft.value == 3.0 + if outcome == UNSURE: + assert check.unsure_cause == "margin" + + +def test_exactly_at_the_rule_with_no_error_is_unsure() -> None: + # The strict rule: equality neither clears nor misses by more than the (zero) error. + check = at_start(existing_battery(3.0, plus_minus=0.0), S0, "battery_clearance", PUBLIC) + assert check.outcome == UNSURE + + +def test_the_answer_cites_the_rule() -> None: + result = answer(existing_battery(1.0)) + check = next(c for c in result["checks"] if c["id"] == "battery_clearance") + assert check["rule"]["key"] == "clearances.battery_ft" + assert "other batteries" in check["rule"]["source"] + + +def test_a_scene_without_one_has_no_battery_check() -> None: + # It would repeat the AC check's coverage (same band, same 3 ft), so answers stay as before. + assert "battery_clearance" not in {c["id"] for c in answer(shared_fixture())["checks"]} + + +def test_the_cable_detours_over_an_existing_battery() -> None: + # Between the meter (s = 0) and the new battery: the cable goes over it at route height. + raw = existing_battery(0.0) + raw["objects"][0]["span_ft"] = [2.0, 2.0 + W] + raw["objects"][0]["footprint"] = rect(2.0, 2.0 + W, 0, DEPTH) + route = at_start(raw, S0, "route_path", PUBLIC) + assert route.outcome == PASS + detours = at_start(raw, S0, "route_length", PUBLIC) + assert detours.measured == pytest.approx(S0 + 2 * (3.29 - 1.0), abs=1e-6) + + +def test_the_public_value_applies_when_private_rules_do_not_set_it(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text("clearances:\n gas_ft: {value: 4.0, source: 'test'}\n") + assert load_rules(private).rules.clearances.battery_ft.value == 3.0 + + +def test_the_schema_accepts_the_type() -> None: + schema = Path(__file__).resolve().parents[1] / "schemas" / "scene.schema.json" + Draft202012Validator(json.loads(schema.read_text())).validate(existing_battery(4.0)) From 90f94bd7bc6fa1f6e62db85b08fb895266ad6889 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 17:54:56 -0500 Subject: [PATCH 33/52] server: count only unseen checks in 'N checks depend on areas the scan did not see' (#45) Co-authored-by: Cursor --- server/solver.py | 14 +++++++++++--- server/tests/test_golden_public.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/server/solver.py b/server/solver.py index eeab2ad6f..dbec1ba57 100644 --- a/server/solver.py +++ b/server/solver.py @@ -1415,11 +1415,19 @@ def past_end_requests() -> list[dict[str, Any]]: reasons.append(unexplored_reason) missing += past_end_requests() spot_at = where((best.s0 + best.s1) / 2) - if any(c.unsure_cause == "unobserved" for c in best.checks if c.outcome == UNSURE): + unseen = [c for c in best.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + if unseen: + rest = [ + c.label.lower() + for c in best.checks + if c.outcome == UNSURE and c.unsure_cause != "unobserved" + ] summary = ( f"More views are needed around the best spot, {spot_at}: " - + ("1 check depends" if len(ids) == 1 else f"{len(ids)} checks depend") - + " on areas the scan did not see." + + ("1 check depends" if len(unseen) == 1 else f"{len(unseen)} checks depend") + + " on areas the scan did not see" + + ("; a person also needs to check " + "; ".join(rest) if rest else "") + + "." ) else: summary = ( diff --git a/server/tests/test_golden_public.py b/server/tests/test_golden_public.py index 04e9eb5ac..65bff3b26 100644 --- a/server/tests/test_golden_public.py +++ b/server/tests/test_golden_public.py @@ -344,6 +344,20 @@ def test_10_compound_error_adds() -> None: assert gas["unsure_cause"] == "margin" +def test_10_unseen_count_leaves_out_margin_checks() -> None: + # Issue #45: headroom unseen at the pad and the gas gap too close to call. More views settle + # only the headroom, so the summary counts one unseen check and names the gas one apart. + raw = gas_gap_scene(3.3, 0.3) + observed_band(raw, "overhead", [(-40, 5), (10, 40)]) + result = solve_valid(raw) + unsure = {c["id"]: c["unsure_cause"] for c in result["checks"] if c["outcome"] == "unsure"} + assert unsure["gas_clearance"] == "margin" + assert list(unsure.values()).count("unobserved") == 1 + gas = check(result, "gas_clearance")["label"].lower() + assert "1 check depends on areas the scan did not see" in result["summary"] + assert f"; a person also needs to check {gas}." in result["summary"] + + def l_scene(variant): """w1 along z = 0 to a corner at s = 12, then w2 down x = 12 (outward +x) to s = 32 (s = 12 - z on w2). A gas pipe 1 ft in front of the battery's front face fails every spot it runs along.""" From 9e429700ce6aceec4b3dee33cd49a38edefe4354 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 18:00:18 -0500 Subject: [PATCH 34/52] server: fixes from the real-phone run 2 (#44, #45, #42) - #44: among UNSURE spots, one whose best estimate is past a rule (a spot overlapping the meter's working space, UNSURE only because the meter's error is large) now ranks after every spot whose estimates all clear, whatever its cable run. The strict rule is unchanged. - #45: "N checks depend on areas the scan did not see" counts only the unseen checks, and names the rest: "and 1 needs a person to judge". - #42: an object with no footprint past an unexplored end is not put on the wall's straight extension, which may not exist; one across the end keeps its part on the scanned wall. Answers list set-aside marks in objects_not_used (result schema, additive). Moved spots in the suite and on real scenes all had a best estimate past a rule (gas at 2.96 ft of 3, openings at 0 to 2.5 ft); no decision changed. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/scene.py | 24 +++++- server/schemas/result.schema.json | 14 ++++ server/solver.py | 46 ++++++++++- server/tests/test_field_run_2.py | 131 ++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 server/tests/test_field_run_2.py diff --git a/server/README.md b/server/README.md index fbdae3332..27e30c3d6 100644 --- a/server/README.md +++ b/server/README.md @@ -124,7 +124,7 @@ Supplying exactly what a request in `missing_evidence` names settles it: a `cove | `facing_gap`, `headroom` | Enough room in front and above | | `route_path`, `route_length` | The cable runs along continuous wall, and isn't too long | -A check passes only when its margin exceeds its error and fails only when it misses by more than its error; anything else is UNSURE with an `unsure_cause`. `missing_evidence` lists the views that would settle an unseen area; showing exactly what a `band` request names settles it, and a `past_end` request asks to walk on. When a measurement only might lie in front of or over the battery (its end is within the wall's position error of the battery's edge), `measured_ft ± plus_minus_ft` spans the values the check could have, so the numbers give the same UNSURE. A reject needs every spot within cable reach to fail and both ends of the walk known. +A check passes only when its margin exceeds its error and fails only when it misses by more than its error; anything else is UNSURE with an `unsure_cause`. `missing_evidence` lists the views that would settle an unseen area; showing exactly what a `band` request names settles it, and a `past_end` request asks to walk on. When a measurement only might lie in front of or over the battery (its end is within the wall's position error of the battery's edge), `measured_ft ± plus_minus_ft` spans the values the check could have, so the numbers give the same UNSURE. A reject needs every spot within cable reach to fail and both ends of the walk known. `objects_not_used` names marks the server didn't measure: objects with no `footprint` past an `unexplored` end, where the wall may turn, so their place is unknown. Among UNSURE spots, one whose best estimate is past a rule (for example overlapping the meter's working space) ranks after every spot whose estimates all clear, whatever its cable run. ## Rules diff --git a/server/scene.py b/server/scene.py index dfda07fbe..48538522e 100644 --- a/server/scene.py +++ b/server/scene.py @@ -149,6 +149,9 @@ class Scene: meter_piece: Piece wall_spans: list[tuple[str | None, float, float]] # each wall's stretch of s, as uploaded 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). + set_aside: list[tuple[int, str, str, float]] ground: list[GroundPatch] overheads: list[Measured] facing: list[Measured] @@ -752,6 +755,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N meter_piece=meter_piece, wall_spans=wall_spans, objects=[], + set_aside=[], ground=[], overheads=[], facing=[], @@ -760,6 +764,11 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N reach_ft=reach, ) + # What each end is decides where an object without a footprint can be placed, so it is read + # before the objects. + for side, end in raw.get("coverage", {}).get("ends", {}).items(): + scene.end_kinds[side] = end["kind"] + for i, obj in enumerate(raw.get("objects", [])): path = f"/objects/{i}" if obj["wall_id"] not in wall_ids: @@ -771,7 +780,18 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N if "footprint" in obj: geom = _geometry([_xz(p) for p in obj["footprint"]], f"{path}/footprint") else: - geom = scene.wall_line(*span) + # Without a footprint an object lies on the wall's line. Past an unexplored end that + # line may not exist (the wall may turn), so only the part on the scanned wall counts. + lo, hi = span + if scene.end_kinds["left"] == "unexplored": + lo = max(lo, scene.s_min) + if scene.end_kinds["right"] == "unexplored": + hi = min(hi, scene.s_max) + if hi < lo - EPS: + side = "right" if span[0] > scene.s_max else "left" + scene.set_aside.append((i, obj["type"], side, (span[0] + span[1]) / 2)) + continue + geom = scene.wall_line(lo, max(lo, hi)) attrs = obj.get("attrs", {}) scene.objects.append( SceneObject( @@ -819,8 +839,6 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N 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"))) - for side, end in coverage.get("ends", {}).items(): - scene.end_kinds[side] = end["kind"] _check_orientation(scene, raw.get("keyframes", [])) return scene diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index a6896c301..e7ae5a67b 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -76,6 +76,20 @@ } } }, + "objects_not_used": { + "description": "Marked objects the server did not use: ones with no footprint past an unexplored end of the walk, where the wall may turn, so they have no known place. Tell the homeowner those marks weren't counted; walking further or giving them a footprint lets them count.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["object", "side", "message"], + "properties": { + "object": {"type": "string", "description": "objects[] , as in checks' subject."}, + "side": {"enum": ["left", "right"]}, + "message": {"type": "string"} + } + } + }, "ends": { "type": "object", "additionalProperties": false, diff --git a/server/solver.py b/server/solver.py index eeab2ad6f..fd22f1459 100644 --- a/server/solver.py +++ b/server/solver.py @@ -1188,8 +1188,26 @@ def _rank_pass(c: Candidate) -> tuple: return (c.route.length, abs((c.s0 + c.s1) / 2), c.s0) +def estimate_fails(c: Candidate) -> bool: + """Whether a check's best estimate is past its rule although the error leaves it UNSURE: + the measured value on the fail side (an overlap with the meter's working space, a clearance + under its minimum, a run over its maximum).""" + for check in c.checks: + if check.outcome != UNSURE or check.measured is None or check.threshold is None: + continue + if check.comparison == "at_least" and check.measured < check.threshold - EPS: + return True + if check.comparison == "at_most" and check.measured > check.threshold + EPS: + return True + return False + + def _rank_unsure(c: Candidate) -> tuple: - return (len(c.unsure()), c.route.length, c.s0) + """A candidate whose best estimate is past a rule (issue #44: a spot overlapping the meter's + working space, UNSURE only because the meter's error is large) ranks below every candidate + whose best estimates all clear, whatever its route. Within each group: fewest UNSURE checks, + then the shortest route.""" + return (estimate_fails(c), len(c.unsure()), c.route.length, c.s0) def _spot_json(solver: Solver, c: Candidate) -> dict[str, Any]: @@ -1415,11 +1433,20 @@ def past_end_requests() -> list[dict[str, Any]]: reasons.append(unexplored_reason) missing += past_end_requests() spot_at = where((best.s0 + best.s1) / 2) - if any(c.unsure_cause == "unobserved" for c in best.checks if c.outcome == UNSURE): + unseen = [c for c in best.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + if unseen: + # Only unseen checks are settled by more views; the rest need a person (issue #45). + others = len(ids) - len(unseen) summary = ( f"More views are needed around the best spot, {spot_at}: " - + ("1 check depends" if len(ids) == 1 else f"{len(ids)} checks depend") - + " on areas the scan did not see." + + ("1 check depends" if len(unseen) == 1 else f"{len(unseen)} checks depend") + + " on areas the scan did not see" + + ( + f", and {others} {'needs' if others == 1 else 'need'} a person to judge" + if others + else "" + ) + + "." ) else: summary = ( @@ -1494,6 +1521,17 @@ def past_end_requests() -> list[dict[str, Any]]: "checks": [c.to_json(loaded.private_keys) for c in best.checks] if best else [], "nearest_considered": _spot_json(solver, nearest) if nearest else None, "missing_evidence": missing, + "objects_not_used": [ + { + "object": f"objects[{index}] {kind}", + "side": side, + "message": ( + f"The {kind.replace('_', ' ')} marked {where(s)} is past the {side} end of " + "the scan, where the wall may turn, so it was not used." + ), + } + for index, kind, side, s in scene.set_aside + ], "ends": ends, "sweep": sorted( _sweep_json(cands, scene, r.sweep.step_ft.value) + far, diff --git a/server/tests/test_field_run_2.py b/server/tests/test_field_run_2.py new file mode 100644 index 000000000..de439f051 --- /dev/null +++ b/server/tests/test_field_run_2.py @@ -0,0 +1,131 @@ +"""Regression tests for the real-phone run 2 (issues #44, #45, #42); each failed before its fix.""" + +import copy + +from helpers import observed_band, parsed, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +from solver import SOLVE_BUDGET_S, UNSURE, Solver, estimate_fails + +# --- #44: a spot whose best estimate overlaps the meter's working space ---------------------- + + +def meter_known_loosely(meter_error: float = 0.792, wall_error: float = 0.713) -> dict: + """Run 2's numbers: the meter and wall errors are large, so a spot against the meter is only + UNSURE for the working space. Headroom is unseen everywhere. Right of the meter the ground is + seen but its surface wasn't recorded, so the spots that clear the working space are UNSURE + for as many checks as the one against the meter, and further from it. (The lawn also runs + past the left limit end, behind the wall's line, where that ground counts too.)""" + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = meter_error + raw["walls"][0]["baseline"] = [[-3, 0], [20, 0]] + raw["walls"][0]["plus_minus_ft"] = wall_error + raw["overheads"] = [] + raw["facing"][0]["span_ft"] = [-3, 20] + raw["ground"] = [{"type": "lawn", "polygon": rect(-40, 1.0, -30, 30), "plus_minus_ft": 0}] + raw["coverage"]["ends"] = {"left": {"kind": "limit"}, "right": {"kind": "limit"}} + observed_band(raw, "overhead", []) + return raw + + +def test_a_spot_over_the_meters_working_space_does_not_win() -> None: + # Before: the spot from -2.58 to 0 won on its 0 ft route although its best estimate + # overlapped the working space by 1.25 ft. + result = answer(meter_known_loosely()) + meter = next(c for c in result["checks"] if c["id"] == "meter_working_space") + assert meter["measured_ft"] >= 0, (result["spot"]["span_ft"], meter) + + +@settings(max_examples=40, deadline=None) +@given( + meter_error=st.floats(min_value=0.1, max_value=2.0), + wall_error=st.floats(min_value=0.0, max_value=1.5), +) +def test_an_estimate_past_a_rule_never_outranks_one_that_clears( + meter_error: float, wall_error: float +) -> None: + raw = meter_known_loosely(meter_error, wall_error) + scene = parsed(raw, PUBLIC) + candidates = Solver(scene, PUBLIC).candidates(SOLVE_BUDGET_S) + unsures = [c for c in candidates if c.outcome == UNSURE] + result = answer(raw) + if result["decision"] != "manual_review" or not any(not estimate_fails(c) for c in unsures): + return + chosen = min(unsures, key=lambda c: abs(c.s0 - result["spot"]["span_ft"][0])) + assert not estimate_fails(chosen) + + +# --- #45: the summary counts only unseen checks ----------------------------------------------- + + +def test_the_summary_counts_unseen_checks_and_names_the_rest() -> None: + # Before: "10 checks depend on areas the scan did not see", one of them too close to call. + raw = shared_fixture() + observed_band(raw, "overhead", []) # headroom unseen + raw["walls"][0]["plus_minus_ft"] = 0.2 + raw["objects"] = [ # an AC unit 3.1 ft from the pad's spot: within the 0.2 ft error + { + "type": "ac", + "wall_id": "w1", + "span_ft": [6 + 31 / 12 + 3.1, 6 + 31 / 12 + 5.1], + "source": "tape", + "plus_minus_ft": 0, + "footprint": rect(6 + 31 / 12 + 3.1, 6 + 31 / 12 + 5.1, 0, 2), + } + ] + summary = answer(raw)["summary"] + assert "1 check depends on areas the scan did not see, and 1 needs a person to judge" in ( + summary + ), summary + + +# --- #42: an object past an unexplored end ---------------------------------------------------- + + +def window_past_the_end(end: str, footprint: bool = False) -> dict: + """A window with no footprint 5 ft past the wall's right end at 12 ft.""" + 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": end} + window = { + "type": "window", + "wall_id": "w1", + "span_ft": [17, 20], + "bottom_ft": 3, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0.1, + } + if footprint: + window["footprint"] = [[17, 0], [20, 0]] + raw["objects"] = [window] + return raw + + +def test_a_mark_past_an_unexplored_end_is_set_aside_and_named() -> None: + # Before: the window was placed on the wall's straight extension, which may not exist. + scene = parsed(window_past_the_end("unexplored"), PUBLIC) + assert scene.objects == [] + result = answer(window_past_the_end("unexplored")) + assert [o["object"] for o in result["objects_not_used"]] == ["objects[0] window"] + assert "right end" in result["objects_not_used"][0]["message"] + + +def test_past_a_limit_end_the_wall_line_still_holds_it() -> None: + assert len(parsed(window_past_the_end("limit"), PUBLIC).objects) == 1 + assert answer(window_past_the_end("limit"))["objects_not_used"] == [] + + +def test_a_mark_with_its_own_footprint_is_measured_where_it_is() -> None: + assert len(parsed(window_past_the_end("unexplored", footprint=True), PUBLIC).objects) == 1 + + +def test_a_mark_across_the_end_keeps_the_part_on_the_wall() -> None: + raw = window_past_the_end("unexplored") + raw["objects"][0]["span_ft"] = [10, 14] + (window,) = parsed(copy.deepcopy(raw), PUBLIC).objects + xs = [x for x, _ in window.geom.coords] + assert min(xs) == 10 and max(xs) <= 12 + 1e-6 From 86fd555db60b802df76cfede32c737f538d47515 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 18:15:17 -0500 Subject: [PATCH 35/52] server: fixes from the caretaker's review of 9e42970 - A footprintless mark whose span starts exactly at an unexplored end (span [12, 20], end at 12) clipped to a single point and stayed measured; a mark with length that has nothing left on the scanned wall is now set aside and listed in objects_not_used. - The ranking penalty for a best estimate past a rule counts only checks unsure by their margin. A window of unknown operability may not be subject to the rule, so it no longer ranks like a known overlap. - The footprint test now checks that the solver measured the mark, not only that parsing kept it. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 4 ++- server/solver.py | 7 ++++-- server/tests/test_field_run_2.py | 42 ++++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/server/scene.py b/server/scene.py index 48538522e..c6c995505 100644 --- a/server/scene.py +++ b/server/scene.py @@ -787,7 +787,9 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N lo = max(lo, scene.s_min) if scene.end_kinds["right"] == "unexplored": hi = min(hi, scene.s_max) - if hi < lo - EPS: + # 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" scene.set_aside.append((i, obj["type"], side, (span[0] + span[1]) / 2)) continue diff --git a/server/solver.py b/server/solver.py index fd22f1459..be012dbea 100644 --- a/server/solver.py +++ b/server/solver.py @@ -1191,9 +1191,12 @@ def _rank_pass(c: Candidate) -> tuple: def estimate_fails(c: Candidate) -> bool: """Whether a check's best estimate is past its rule although the error leaves it UNSURE: the measured value on the fail side (an overlap with the meter's working space, a clearance - under its minimum, a run over its maximum).""" + under its minimum, a run over its maximum). Only a check unsure by its margin counts: one + unsure for an unknown attribute (a window that may not open) may not be subject to the rule.""" for check in c.checks: - if check.outcome != UNSURE or check.measured is None or check.threshold is None: + if check.outcome != UNSURE or check.unsure_cause != "margin": + continue + if check.measured is None or check.threshold is None: continue if check.comparison == "at_least" and check.measured < check.threshold - EPS: return True diff --git a/server/tests/test_field_run_2.py b/server/tests/test_field_run_2.py index de439f051..6c0b6c0ed 100644 --- a/server/tests/test_field_run_2.py +++ b/server/tests/test_field_run_2.py @@ -7,7 +7,10 @@ from hypothesis import strategies as st from test_s4_round import PUBLIC, answer -from solver import SOLVE_BUDGET_S, UNSURE, Solver, estimate_fails +from rules import deep_merge, public_rules_dict, rules_from_dict +from solver import SOLVE_BUDGET_S, UNSURE, Solver, estimate_fails, evaluate_start + +S1_PAD = 6 + 31 / 12 # far edge of the shared fixture's first spot # --- #44: a spot whose best estimate overlaps the meter's working space ---------------------- @@ -120,7 +123,18 @@ def test_past_a_limit_end_the_wall_line_still_holds_it() -> None: def test_a_mark_with_its_own_footprint_is_measured_where_it_is() -> None: - assert len(parsed(window_past_the_end("unexplored", footprint=True), PUBLIC).objects) == 1 + raw = window_past_the_end("unexplored", footprint=True) + assert len(parsed(raw, PUBLIC).objects) == 1 + opening = next(c for c in answer(raw)["checks"] if c["id"] == "opening_clearance") + assert opening["subject"] == "objects[0] window" # the nearest opening the check measured + + +def test_a_mark_starting_exactly_at_the_end_is_set_aside() -> None: + # Before: [12, 20] against an end at 12 clipped to the single point 12 and stayed measured. + raw = window_past_the_end("unexplored") + raw["objects"][0]["span_ft"] = [12, 20] + assert parsed(raw, PUBLIC).objects == [] + assert [o["object"] for o in answer(raw)["objects_not_used"]] == ["objects[0] window"] def test_a_mark_across_the_end_keeps_the_part_on_the_wall() -> None: @@ -129,3 +143,27 @@ def test_a_mark_across_the_end_keeps_the_part_on_the_wall() -> None: (window,) = parsed(copy.deepcopy(raw), PUBLIC).objects xs = [x for x, _ in window.geom.coords] assert min(xs) == 10 and max(xs) <= 12 + 1e-6 + + +def test_an_unknown_attribute_is_not_an_estimate_past_the_rule() -> None: + # A window of unknown operability 1 ft away, with fixed windows exempt: the rule may not apply + # at all, so its distance isn't evidence of a violation the way a known overlap is. + raw = shared_fixture() + raw["objects"] = [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [S1_PAD + 1, S1_PAD + 3], + "bottom_ft": 3, + "top_ft": 6, + "source": "tape", + "plus_minus_ft": 0, + } + ] + rules = rules_from_dict( + deep_merge(public_rules_dict(), {"openings": {"exempt_fixed_windows": True}}) + ) + candidate = evaluate_start(parsed(raw, rules), rules, 6.0) + opening = next(c for c in candidate.checks if c.id == "opening_clearance") + assert (opening.outcome, opening.unsure_cause) == (UNSURE, "unknown_attribute") + assert not estimate_fails(candidate) From 6c7ca23b63a5493e5a4224af0a5d12d025f3ba75 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 21:44:44 -0500 Subject: [PATCH 36/52] server: fixes from the main-track review of 9edcd4b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wall equipment: the gap's error now includes the battery's position error (the wall's), so a box 0.2 ft away on a wall known to ±0.5 is UNSURE, not a pass. - Route crossings are judged with the object's error and both route ends': a door that may or may not reach the route makes it UNSURE (it was a false pass on one side and a false reject on the other); a detour that may not be needed widens the run's error. - battery_clearance reads the wall as well as the ground, and is left out only when the gas check already covers both bands to its radius. - Start positions that depend on the battery's own error are placed with the error at each start when it drifts with distance, not the segment's largest error, so a passing interval narrower than the grid (8.005 to 8.030 in the review's scene) is found. - The site plan draws an existing battery (it raised KeyError). - smoke.py's keyed requests never go through a proxy. - A mark whose span starts exactly at the right end is listed as past the right end. No decision changes across the suite's 425 recorded answers or the real scenes. Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/examples/smoke.py | 4 +- server/scene.py | 3 +- server/siteplan.py | 1 + server/solver.py | 167 ++++++++++++++++++++++---- server/tests/test_main_review.py | 187 ++++++++++++++++++++++++++++++ server/tests/test_smoke_script.py | 12 ++ 7 files changed, 349 insertions(+), 27 deletions(-) create mode 100644 server/tests/test_main_review.py diff --git a/server/README.md b/server/README.md index 27e30c3d6..32b6d0d82 100644 --- a/server/README.md +++ b/server/README.md @@ -91,7 +91,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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) | 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/scene.py b/server/scene.py index c6c995505..e977e23f4 100644 --- a/server/scene.py +++ b/server/scene.py @@ -790,7 +790,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)) 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..a49fe8616 100644 --- a/server/solver.py +++ b/server/solver.py @@ -19,7 +19,7 @@ from functools import partial from typing import Any -from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union +from shapely import Geometry, LineString, Point, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value from scene import ( @@ -242,11 +242,11 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: 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] @@ -551,7 +551,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 + piece.plus_minus outcome = at_least(gap, e, t) key = (_SEVERITY[outcome], -(gap - e)) if worst_key is None or key > worst_key: @@ -744,15 +746,29 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C 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 + 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 and the wall's, + # 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 + 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: 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) if effect == "detour": @@ -771,6 +787,10 @@ 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) @@ -797,6 +817,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") @@ -927,8 +954,9 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: piece, fp, [(o.label, o.geom, o.plus_minus, True) for o in self.batteries], - "ground", + "ground+wall", "battery", + self.wall_height["above"], ) ] if self.battery_check @@ -1038,12 +1066,20 @@ def starts(self, piece: Piece) -> list[float]: # 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) + # 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) 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] + own = (0.0, e, -e) if drifts else (0.0, e, -e, e + ew, -(e + ew)) + boundaries += [(b, own) for b in o.span] + drifting += [(b, e) for b in o.span] for m in self.scene.overheads + self.scene.facing: boundaries += [(b, around) for b in m.span] for band in self.scene.observed.values(): @@ -1054,6 +1090,21 @@ def starts(self, piece: Piece) -> list[float]: 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 for line in (rt.confident_reach_ft.value, rt.max_ft.value): @@ -1062,17 +1113,30 @@ def starts(self, piece: Piece) -> list[float]: # 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)] + offsets = (0.0, D) + tracks = [LineString([piece.point(lo, v), piece.point(hi + W, v)]) for v in offsets] strip = piece.rect(lo, hi + W, 0.0, D) - for geom, dist in self._clearance_edges(piece): + for geom, base, fixed, k in self._clearance_edges(piece): + 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: + for v, track in zip(offsets, tracks, strict=True): if track.distance(boundary) > EPS: continue for x, z in _coords_of(track.intersection(boundary)): u = piece.local((x, z))[0] - points += [u, u - W] + if k == 0 or not drifts: + points += [u, u - W] + continue + # The offset used the segment's largest error; a start is judged with the + # error at its own position, so re-place it with that error. + for corner in (0.0, W): + points.append( + self._settle_on(piece, v, geom, base, fixed, k, u - corner, corner) + ) # Where the region lies inside the footprint's strip, the footprint's side edges # meet it at the region's extent along the wall (an object standing a little off the # wall is reached by a side edge, not a corner). @@ -1085,10 +1149,10 @@ 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) -> 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.""" c = self.r.clearances - ew = piece.error_at(max(abs(piece.s0), abs(piece.s1))) 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), @@ -1097,16 +1161,71 @@ def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float]]: *((c.opening_ft.value, o.geom, o.plus_minus) 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 _error(self, piece: Piece, s0: float) -> float: + """The battery's position error for a start at s0: the wall's error at its far edge from + the meter, as evaluate uses.""" + return piece.error_at(max(abs(s0), abs(s0 + self.W))) + + @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 _settle_on( + self, + piece: Piece, + v: float, + geom: Geometry, + base: float, + fixed: float, + k: float, + start: float, + corner: float, + ) -> float: + """Where the footprint corner `corner` along from the start, `v` out from the wall, is at + distance base + k * (fixed + the error at that start) from `geom` (signed: negative + inside it). Found by bracketing and bisecting near `start`, the largest-error estimate, + with point distances rather than a buffer per step, which is slow for large patches.""" + + def gap(s: float) -> float: + p = Point(piece.point(s + corner, v)) + d = ( + -geom.boundary.distance(p) + if geom.area > 0 and geom.contains(p) + else geom.distance(p) + ) + return d - (base + k * (fixed + self._error(piece, s))) + + g0 = gap(start) + if abs(g0) <= 1e-9: + return start + step = max( + abs(piece.error_at(max(abs(start), abs(start + self.W))) - self._error(piece, start)), + 0.01, + ) + for m in range(1, 9): + for other in (start - m * step, start + m * step): + if gap(other) * g0 < 0: + a, b = sorted((start, other)) + ga = gap(a) + for _ in range(40): + mid = (a + b) / 2 + gm = gap(mid) + if gm * ga <= 0: + b = mid + else: + a, ga = mid, gm + return (a + b) / 2 + return start + 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)] diff --git a/server/tests/test_main_review.py b/server/tests/test_main_review.py new file mode 100644 index 000000000..15b0e8738 --- /dev/null +++ b/server/tests/test_main_review.py @@ -0,0 +1,187 @@ +"""Regression tests for the main-track review of #11 at 9edcd4b; each failed before its fix.""" + +import json + +from fastapi.testclient import TestClient +from helpers import at_start, observed_band, pads_ground, parsed, rect, shared_fixture +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() -> tuple[dict, float, float]: + lo, hi = 8.005, 8.030 + + def e(s: float) -> float: + return 0.3 + 0.16 * (s + W) + + 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"] = [] + for x in (lo - 3 - e(lo), hi + W + 3 + e(hi)): + raw["objects"].append( + { + "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, 0.9]], + } + ) + return raw, lo, hi + + +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_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)] From 14dd051d56a95893cbce1264240432e1fb9659ae Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 23:07:05 -0500 Subject: [PATCH 37/52] server: find drift-dependent passing intervals by bracketing The caretaker's follow-up to 6c7ca23: with the gas points 1 ft in front of the battery the narrow passing interval was missed again, because the re-placement stepped a fixed 0.01 ft around the largest-error estimate and gave up after 0.08 ft. A start's error lies between the segment's least and most, so each boundary lies between where the footprint meets the outline drawn with the least error and the one drawn with the most (by a corner or a side edge). Those positions bracket it, and bisection on the footprint's distance finds it. A property test checks that any start evaluate_start passes is found by the solve, for gas points level with or in front of the battery and intervals down to 0.004 ft; it passed 200 examples. No decision or spot changed across the suite's 425 recorded answers; CPU time on the real scenes is equal or lower. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 115 +++++++++++++++++-------------- server/tests/test_main_review.py | 62 ++++++++++++----- 2 files changed, 111 insertions(+), 66 deletions(-) diff --git a/server/solver.py b/server/solver.py index a49fe8616..2063f7964 100644 --- a/server/solver.py +++ b/server/solver.py @@ -19,7 +19,7 @@ from functools import partial from typing import Any -from shapely import Geometry, LineString, Point, Polygon, get_coordinates, unary_union +from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value from scene import ( @@ -1113,30 +1113,31 @@ def starts(self, piece: Piece) -> list[float]: # 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. - offsets = (0.0, D) - tracks = [LineString([piece.point(lo, v), piece.point(hi + W, v)]) for v in offsets] + 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) + 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): + 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 v, track in zip(offsets, tracks, strict=True): + for track in tracks: if track.distance(boundary) > EPS: continue for x, z in _coords_of(track.intersection(boundary)): u = piece.local((x, z))[0] - if k == 0 or not drifts: - points += [u, u - W] - continue - # The offset used the segment's largest error; a start is judged with the - # error at its own position, so re-place it with that error. - for corner in (0.0, W): - points.append( - self._settle_on(piece, v, geom, base, fixed, k, u - corner, corner) - ) + points += [u, u - W] # Where the region lies inside the footprint's strip, the footprint's side edges # meet it at the region's extent along the wall (an object standing a little off the # wall is reached by a side edge, not a corner). @@ -1179,52 +1180,66 @@ def _settle(place: Callable[[float], float], start: float) -> float: start = place(start) return start - def _settle_on( + def _drifting_outline( self, piece: Piece, - v: float, geom: Geometry, base: float, fixed: float, k: float, - start: float, - corner: float, - ) -> float: - """Where the footprint corner `corner` along from the start, `v` out from the wall, is at - distance base + k * (fixed + the error at that start) from `geom` (signed: negative - inside it). Found by bracketing and bisecting near `start`, the largest-error estimate, - with point distances rather than a buffer per step, which is slow for large patches.""" + 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: - p = Point(piece.point(s + corner, v)) - d = ( - -geom.boundary.distance(p) - if geom.area > 0 and geom.contains(p) - else geom.distance(p) + # 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)) ) - return d - (base + k * (fixed + self._error(piece, s))) - - g0 = gap(start) - if abs(g0) <= 1e-9: - return start - step = max( - abs(piece.error_at(max(abs(start), abs(start + self.W))) - self._error(piece, start)), - 0.01, - ) - for m in range(1, 9): - for other in (start - m * step, start + m * step): - if gap(other) * g0 < 0: - a, b = sorted((start, other)) - ga = gap(a) - for _ in range(40): - mid = (a + b) / 2 - gm = gap(mid) - if gm * ga <= 0: - b = mid - else: - a, ga = mid, gm - return (a + b) / 2 - return start + + 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 = time.perf_counter() diff --git a/server/tests/test_main_review.py b/server/tests/test_main_review.py index 15b0e8738..20a9ab73e 100644 --- a/server/tests/test_main_review.py +++ b/server/tests/test_main_review.py @@ -1,9 +1,12 @@ """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 @@ -106,32 +109,59 @@ def test_the_battery_check_is_left_out_only_when_the_gas_check_covers_it() -> No # --- 4. start positions where the error grows with drift --------------------------------------- -def drift_window() -> tuple[dict, float, float]: - lo, hi = 8.005, 8.030 +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"] = [] - for x in (lo - 3 - e(lo), hi + W + 3 + e(hi)): - raw["objects"].append( - { - "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, 0.9]], - } - ) + 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. From 903d86fdac1d201ece79c35258d1344b921a814e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 23:33:47 -0500 Subject: [PATCH 38/52] server: fixes from the Codex review of 6c7ca23 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A wall's declared height_ft is honoured: a wall lower than the battery fails wall_backing (a 2 ft wall passed a 3.29 ft battery). Undeclared heights are unchanged. - Facing and headroom coverage must span every place the battery may sit (its stretch widened by the wall's error), not only the nominal one; requests widen to match. - Two walls meet only when the space between them is within both walls' errors (capped at sweep.wall_join_ft); otherwise it is a real gap that s counts and no cable crosses. Tapped walls (±0.3 each) keep today's 0.6 ft; exact walls keep any real gap. - A spot's wall_id and segment (and each sweep run's) come from the same original wall segment; joined walls reported the first wall's index. - sweep.step_ft must be positive; zero is refused when the rules load instead of dividing by zero in every solve. - Under private rules, policy.notice names the checks still on public placeholder values and is never cleared while any remain. No decision or spot changed across the suite's 451 recorded answers or the real scenes. Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 9 +- server/rules.py | 66 +++++++++++-- server/scene.py | 27 +++-- server/solver.py | 53 +++++++--- server/tests/test_codex_review.py | 141 +++++++++++++++++++++++++++ server/tests/test_policy_and_auth.py | 7 +- 6 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 server/tests/test_codex_review.py diff --git a/server/README.md b/server/README.md index 32b6d0d82..1ab78d46b 100644 --- a/server/README.md +++ b/server/README.md @@ -75,6 +75,7 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON - `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. +- 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); `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. - 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. @@ -86,7 +87,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | 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 | wall band over [s0, s1], seen higher than the battery (3.29); a wall 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) | @@ -95,8 +96,8 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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 | +| `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 | wall band from the meter to the battery's near edge, seen higher than the cable's run (`route.height_ft`, 1.0) | | `route_length` | `walls`, `meter` | nothing to observe | @@ -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/rules.py b/server/rules.py index 88d78e2a7..097a06c21 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 @@ -251,11 +258,58 @@ 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: + private = any(p == path or p.startswith(f"{path}.") for p in private_keys) + if not private: + 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 e977e23f4..289fe01ea 100644 --- a/server/scene.py +++ b/server/scene.py @@ -72,6 +72,8 @@ class Piece: # Extra error per foot walked from the meter, for walls placed by AR taps without their own # plus_minus_ft (rules.yaml errors.drift_per_ft). drift: float = 0.0 + # The wall's declared height_ft, when the scene gives one. + height: float | None = None def error_at(self, s: float) -> float: """Position error of this piece's line at s.""" @@ -147,7 +149,9 @@ 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's (wall id, segment index, stretch of s), before collinear + # walls are joined into one piece: what a spot's wall_id and segment refer to. + wall_spans: list[tuple[str | None, int, float, float]] 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). @@ -182,10 +186,14 @@ 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 str(wid), index + 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: @@ -488,6 +496,8 @@ def _join_straight_walls(pieces: list[Piece]) -> list[Piece]: _outward(along), max(prev.plus_minus, p.plus_minus), max(prev.drift, p.drift), + # A battery spanning the join backs onto the lower of two declared heights. + min((h for h in (prev.height, p.height) if h is not None), default=None), ) else: out.append(p) @@ -617,7 +627,11 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N 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 and never below float noise. + # A wider space is a real gap: a stretch with no wall, which s counts. + meets = min(join_tol, max(prev_err + wall_err, COVERAGE_TOLERANCE_FT)) + if gap > meets: along = ( (pts[0][0] - prev_end[0]) / gap, (pts[0][1] - prev_end[1]) / gap, @@ -658,6 +672,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N _outward(along), wall_err, wall_drift, + wall.get("height_ft"), ) ) s += length @@ -698,7 +713,7 @@ 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 = [(p.wall_id, p.index, p.s0, p.s1) for p in pieces if p.kind == "wall"] pieces = _join_straight_walls(pieces) meter_piece = next(p for p in pieces if p.kind == "wall" and p.s0 - EPS <= 0 <= p.s1 + EPS) diff --git a/server/solver.py b/server/solver.py index 2063f7964..66387aaf0 100644 --- a/server/solver.py +++ b/server/solver.py @@ -334,6 +334,20 @@ def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: ) return c up_to = self.wall_height["backing"] + if piece.height is not None: + # A declared wall height, taken as given (as out_ft heights are), against the battery's. + tall = at_least(piece.height, 0.0, self.H) + if tall != PASS: + c.outcome = tall + c.measured, c.plus_minus, c.threshold = piece.height, 0.0, self.H + c.comparison = "at_least" + if tall == UNSURE: + c.unsure_cause = "margin" + c.reason = ( + f"The wall is {ft(piece.height)} tall, not taller than the battery's " + f"{ft(self.H)}." + ) + return c missing = self.scene.missing("wall", s0, s1, up_to) if missing: c.outcome, c.unsure_cause = UNSURE, "unobserved" @@ -624,7 +638,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 @@ -664,7 +681,7 @@ 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: + 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" @@ -672,7 +689,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: @@ -1275,8 +1292,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], @@ -1354,8 +1370,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), @@ -1443,14 +1458,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: @@ -1459,12 +1487,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: diff --git a/server/tests/test_codex_review.py b/server/tests/test_codex_review.py new file mode 100644 index 000000000..c6339a49b --- /dev/null +++ b/server/tests/test_codex_review.py @@ -0,0 +1,141 @@ +"""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 + +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 "") diff --git a/server/tests/test_policy_and_auth.py b/server/tests/test_policy_and_auth.py index e3dcce315..6e51f4d91 100644 --- a/server/tests/test_policy_and_auth.py +++ b/server/tests/test_policy_and_auth.py @@ -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: From 7133283ef6fb672f4b2a4c8e5e2648e75b13c2ca Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 00:07:42 -0500 Subject: [PATCH 39/52] server: finish the height, segment and gap fixes (caretaker on 903d86f) - Wall height: collinear walls joined into one piece kept the lower height along the whole piece, so a spot wholly on a 9 ft wall failed on its 2 ft neighbour. Heights now come from the uploaded walls under the battery: a lower wall surely behind it fails, one within the wall's error of it leaves it UNSURE. - Segments: collinear points were merged before segments were numbered, so the second uploaded segment reported 0. wall_spans records each segment as uploaded (with its wall's declared height); spots and sweep runs report those numbers. - Gaps: the join floor was the 0.01 ft coverage tolerance, which erased a real 0.005 ft gap between exact walls. It is now float noise (EPS): walls with zero declared error meet only where their ends coincide. No decision or spot changed across the suite's 453 recorded answers or the real scenes; one test answer's reported segment changed to the uploaded numbering. Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/scene.py | 53 +++++++++++++++++------- server/solver.py | 34 +++++++++------- server/tests/test_codex_review.py | 54 +++++++++++++++++++++++++ server/tests/test_review_regressions.py | 3 +- 5 files changed, 117 insertions(+), 31 deletions(-) diff --git a/server/README.md b/server/README.md index 1ab78d46b..b97df6994 100644 --- a/server/README.md +++ b/server/README.md @@ -75,7 +75,7 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON - `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. -- 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); `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. +- 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. - 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. @@ -87,7 +87,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | Check | Reads | Coverage that settles it | | --- | --- | --- | -| `wall_backing` | `walls`, and `height_ft` when given | wall band over [s0, s1], seen higher than the battery (3.29); a wall that declares `height_ft` must be taller than the battery | +| `wall_backing` | `walls`, and `height_ft` when given | wall band over [s0, s1], 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) | diff --git a/server/scene.py b/server/scene.py index 289fe01ea..1bd21a814 100644 --- a/server/scene.py +++ b/server/scene.py @@ -72,8 +72,6 @@ class Piece: # Extra error per foot walked from the meter, for walls placed by AR taps without their own # plus_minus_ft (rules.yaml errors.drift_per_ft). drift: float = 0.0 - # The wall's declared height_ft, when the scene gives one. - height: float | None = None def error_at(self, s: float) -> float: """Position error of this piece's line at s.""" @@ -149,9 +147,10 @@ class Scene: meter_pos: tuple[float, float, float] meter_plus_minus: float meter_piece: Piece - # Each uploaded wall segment's (wall id, segment index, stretch of s), before collinear - # walls are joined into one piece: what a spot's wall_id and segment refer to. - wall_spans: list[tuple[str | None, int, float, float]] + # 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). @@ -189,8 +188,17 @@ def meter_xz(self) -> Point2: 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 str(wid), index + 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: return self.segment_at(s)[0] @@ -440,6 +448,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 @@ -496,8 +519,6 @@ def _join_straight_walls(pieces: list[Piece]) -> list[Piece]: _outward(along), max(prev.plus_minus, p.plus_minus), max(prev.drift, p.drift), - # A battery spanning the join backs onto the lower of two declared heights. - min((h for h in (prev.height, p.height) if h is not None), default=None), ) else: out.append(p) @@ -605,6 +626,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 @@ -624,13 +646,15 @@ 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)) # 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 and never below float noise. - # A wider space is a real gap: a stretch with no wall, which s counts. - meets = min(join_tol, max(prev_err + wall_err, COVERAGE_TOLERANCE_FT)) + # 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, @@ -654,6 +678,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: @@ -672,10 +697,10 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N _outward(along), wall_err, wall_drift, - wall.get("height_ft"), ) ) 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. @@ -713,7 +738,7 @@ 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.index, 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) meter_piece = next(p for p in pieces if p.kind == "wall" and p.s0 - EPS <= 0 <= p.s1 + EPS) diff --git a/server/solver.py b/server/solver.py index 66387aaf0..061743c19 100644 --- a/server/solver.py +++ b/server/solver.py @@ -334,20 +334,26 @@ def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: ) return c up_to = self.wall_height["backing"] - if piece.height is not None: - # A declared wall height, taken as given (as out_ft heights are), against the battery's. - tall = at_least(piece.height, 0.0, self.H) - if tall != PASS: - c.outcome = tall - c.measured, c.plus_minus, c.threshold = piece.height, 0.0, self.H - c.comparison = "at_least" - if tall == UNSURE: - c.unsure_cause = "margin" - c.reason = ( - f"The wall is {ft(piece.height)} tall, not taller than the battery's " - f"{ft(self.H)}." - ) - return c + # 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" + 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 missing = self.scene.missing("wall", s0, s1, up_to) if missing: c.outcome, c.unsure_cause = UNSURE, "unobserved" diff --git a/server/tests/test_codex_review.py b/server/tests/test_codex_review.py index c6339a49b..c8f9f782e 100644 --- a/server/tests/test_codex_review.py +++ b/server/tests/test_codex_review.py @@ -139,3 +139,57 @@ def test_a_partial_private_policy_names_the_checks_still_on_placeholders(tmp_pat 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 == [] 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): From 6200098b247bdb0ad142a639441c3f983b2fbcf7 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 00:40:28 -0500 Subject: [PATCH 40/52] server: safety fixes from the Codex review of 7133283 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - route.confident_reach_ft above route.max_ft is refused when the rules load, and reach_outcome passes a run only when it clears both lines: with confident reach 30 and maximum 20, a 21 ± 2 ft run passed. - A wall view without out_ft reaches headroom height, as the schema says, not infinity: an 8 ft battery's back passed from it while the same view declared at 6.5 went to review. The default counts inclusively, so it still settles every public check. - A private file that sets only some children of a placeholder group (ground.drivable, ground.source) no longer hides the group's remaining placeholder: ground_surface stays in the notice. No decision or spot changed across the suite's 444 recorded answers or the six real answers (public and private). Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/rules.py | 24 +++++++++++++++-- server/scene.py | 12 ++++++++- server/solver.py | 3 ++- server/tests/test_codex_review.py | 45 ++++++++++++++++++++++++++++++- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/server/README.md b/server/README.md index b97df6994..6c8cb6763 100644 --- a/server/README.md +++ b/server/README.md @@ -83,7 +83,7 @@ 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 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 (`headroom.min_ft`), inclusive: it settles a check needing up to that height, and no more. 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 | | --- | --- | --- | diff --git a/server/rules.py b/server/rules.py index 097a06c21..350457e38 100644 --- a/server/rules.py +++ b/server/rules.py @@ -146,6 +146,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 @@ -286,8 +297,17 @@ def _placeholders_left(merged: dict[str, Any], private_keys: frozenset[str]) -> def walk(node: dict[str, Any], path: str) -> None: if node.get("placeholder") is True: - private = any(p == path or p.startswith(f"{path}.") for p in private_keys) - if not private: + # 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(): diff --git a/server/scene.py b/server/scene.py index 1bd21a814..5cb21484d 100644 --- a/server/scene.py +++ b/server/scene.py @@ -161,6 +161,8 @@ class Scene: observed: dict[str, list[tuple[float, float, float | None]]] end_kinds: dict[str, str] reach_ft: float # how far out from the walls the outdoor area is modelled + # 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 ----------------------------------------------------------------------------------- @@ -277,10 +279,17 @@ def observed_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 + 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]]: @@ -802,6 +811,7 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N 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 diff --git a/server/solver.py b/server/solver.py index 061743c19..39a45fe50 100644 --- a/server/solver.py +++ b/server/solver.py @@ -179,7 +179,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 diff --git a/server/tests/test_codex_review.py b/server/tests/test_codex_review.py index c8f9f782e..b408ab70a 100644 --- a/server/tests/test_codex_review.py +++ b/server/tests/test_codex_review.py @@ -7,7 +7,7 @@ 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 +from solver import FAIL, PASS, UNSURE, reach_outcome W = 31 / 12 @@ -193,3 +193,46 @@ 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 "") From 9176125df0c9890c247818d55edc82ea46053cd8 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 01:02:16 -0500 Subject: [PATCH 41/52] server: document and pin the wall out_ft equality rule An omitted wall out_ft counts as seen to headroom height inclusively; an explicit one must be strictly higher than the height a check needs. The README says so and why (omitted-view answers stay unchanged; the finite default closes the 8 ft false pass), and a test pins both at exactly the needed height. No code or schema change. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/tests/test_codex_review.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/server/README.md b/server/README.md index 6c8cb6763..a7d978b38 100644 --- a/server/README.md +++ b/server/README.md @@ -83,7 +83,7 @@ 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 (`headroom.min_ft`), inclusive: it settles a check needing up to that height, and no more. 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 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 (`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 | | --- | --- | --- | diff --git a/server/tests/test_codex_review.py b/server/tests/test_codex_review.py index b408ab70a..9f846dbf4 100644 --- a/server/tests/test_codex_review.py +++ b/server/tests/test_codex_review.py @@ -236,3 +236,15 @@ def test_a_partial_ground_override_keeps_ground_surface_in_the_notice(tmp_path: 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") From 4a9fa44f2cadf8aacace12cce7b3b4335ad61fcf Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 01:59:15 -0500 Subject: [PATCH 42/52] server: two false passes from the Codex review of 9176125 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wall_backing: a spot within the wall's error of its straight segment's end is UNSURE, not PASS (the edge of [6.1, 8.683] may be at 9.18 on a wall ending at 9 ± 0.5), and the wall behind every such position must have been seen. Starts clear of the ends by the error are added so the first flush spot is still found. - The route starts at the meter, not its projection onto the wall: the meter's distance from the wall's line counts toward the run and the reach cutoff, and the polyline begins at the meter (a 14 ft run from a meter 2 ft off the wall was a pass; it is 16, past 15 confident). - Scoreboard S2-3 properties: equality is unsure, and moving an obstacle away never makes the check or the spot worse (tests only). No decision or spot changed across the suite's 456 recorded answers or the real scenes; their routes gain the meters' sub-0.001 ft offsets. Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/solver.py | 32 +++++++-- server/tests/test_final_review.py | 111 ++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 server/tests/test_final_review.py diff --git a/server/README.md b/server/README.md index a7d978b38..d446957f6 100644 --- a/server/README.md +++ b/server/README.md @@ -87,7 +87,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | Check | Reads | Coverage that settles it | | --- | --- | --- | -| `wall_backing` | `walls`, and `height_ft` when given | wall band over [s0, s1], seen higher than the battery (3.29); each wall behind the battery that declares `height_ft` must be taller than the battery | +| `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) | @@ -98,7 +98,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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 − 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 | wall band from the meter to the battery's near edge, seen higher than the cable's run (`route.height_ft`, 1.0) | +| `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`) | wall band from the meter to the battery's near edge, 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). diff --git a/server/solver.py b/server/solver.py index 39a45fe50..e4a0c5e6e 100644 --- a/server/solver.py +++ b/server/solver.py @@ -239,6 +239,11 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: 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"] @@ -334,6 +339,16 @@ 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"] # 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 @@ -355,7 +370,8 @@ def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: f"{ft(height)} tall, not taller than the battery's {ft(self.H)}." ) return c - missing = self.scene.missing("wall", s0, s1, up_to) + # ...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] @@ -821,7 +837,10 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C 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) @@ -922,7 +941,9 @@ 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, ) @@ -1070,7 +1091,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.""" @@ -1086,6 +1107,9 @@ 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. + for e in {piece.plus_minus, piece.error_at(max(abs(lo), abs(hi)))}: + 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). diff --git a/server/tests/test_final_review.py b/server/tests/test_final_review.py new file mode 100644 index 000000000..27ff182b2 --- /dev/null +++ b/server/tests/test_final_review.py @@ -0,0 +1,111 @@ +"""Regression tests for the Codex review of #11 at 9176125, and the scoreboard's S2-3 properties.""" + +import copy + +from helpers import at_start, parsed, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +from solver import FAIL, PASS, UNSURE, 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") From 5682a67f9a56ab0723a28f7122f822c79c70562b Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 02:42:34 -0500 Subject: [PATCH 43/52] server: every request settles in one capture (S4 on 4a9fa44) S4's verifier found ETH3D's gap loop never closing: each exact capture of the ground request came back 0.005 ft deeper. Three causes: - The requested ground depth was the region's distance from the chain's lines, but coverage is credited per wall strip (and past an unexplored end only in front of scanned walls), which near a corner needs more. The depth is now the smallest that covers the region, found by bisection on the same geometry the checks use; a span ending exactly at a convex corner is widened by the tolerance so it takes the wedge. - Ground behind a scanned wall is the house: at an inside corner one wall's strip reached back across the other's line, and that ground no view could ever show. - A facing or headroom request named only unseen stretches, not those seen too shallow, so capturing it left the check unsettled. Also: GEOS 3.13 returned an empty intersection for large polygons sharing boundaries (0 instead of 172 sq ft), so request overlays are snapped to a 1e-9 ft grid, and seams thinner than the coverage tolerance are removed from unseen ground. A property test over corner scenes (inside and outside, every band) checks that capturing exactly what is requested leaves nothing at the spot unseen; it passed 200 examples. The real scenes (app replay upload, ETH3D, sim; public and private) all settle in one round. No decision or spot changed across 311 recorded answers. Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/scene.py | 98 +++++++++++++++++++++++++------ server/solver.py | 55 ++++++++++++++--- server/tests/test_final_review.py | 75 ++++++++++++++++++++++- 4 files changed, 202 insertions(+), 28 deletions(-) diff --git a/server/README.md b/server/README.md index d446957f6..97f5f05e9 100644 --- a/server/README.md +++ b/server/README.md @@ -109,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 is the house and never counts as unseen, even where a neighbouring wall's strip reaches across it at an inside corner. 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 diff --git a/server/scene.py b/server/scene.py index 5cb21484d..b55d51108 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 @@ -302,21 +303,41 @@ 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]]) -> 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.""" + lo, hi = self.pieces[0].s0, self.pieces[-1].s1 + # 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 = self.band_polygon(lo, hi, self.reach_ft).difference(self.house()) + seen = unary_union([self.band_polygon(a, b, out or 0.0) for a, b, out in 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)) + # Remove anything thinner than the tolerance (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 it, so it would leave a check that nothing settles. Shrinking and + # regrowing by half the tolerance does that and moves the rest of the boundary by at + # most that much, the same allowance the seen strips get above. + half = COVERAGE_TOLERANCE_FT / 2 + unseen = unseen.buffer(-half, join_style="mitre").buffer(half, join_style="mitre") + return unary_union([unseen, *self._unexplored_discs(ground)]) + + def house(self) -> Geometry: + """Behind each scanned wall segment, out to the modelled reach: the house itself, not + ground a hazard could hide on.""" + if "house" not in self._cache: + self._cache["house"] = unary_union( + [p.rect(p.s0, p.s1, -self.reach_ft, 0.0) for p in self.walls] + ) + 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.""" @@ -331,12 +352,10 @@ def _unexplored_discs(self) -> list[Geometry]: 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", []) + for a, b, out 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]) + 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: @@ -367,6 +386,42 @@ 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: + unseen = polygonal(self.unobserved_ground_given([*ground, (a, b, depth)])) + return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-9 + + tol = COVERAGE_TOLERANCE_FT + for a, b in ((s_lo, s_hi), (s_lo - tol, s_hi + tol)): + 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(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 @@ -377,7 +432,7 @@ def coverable(self, band: str) -> Geometry: 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 if band == "ground": - self._cache[key] = self.band_polygon(lo, hi, self.reach_ft) + self._cache[key] = self.band_polygon(lo, hi, self.reach_ft).difference(self.house()) else: self._cache[key] = self.wall_line(lo, hi).buffer(1e-6, cap_style="flat") return self._cache[key] @@ -408,6 +463,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): diff --git a/server/solver.py b/server/solver.py index e4a0c5e6e..e06d61d57 100644 --- a/server/solver.py +++ b/server/solver.py @@ -19,6 +19,7 @@ from functools import partial from typing import Any +import shapely from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value @@ -139,6 +140,16 @@ 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 _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 @@ -451,18 +462,26 @@ def _missing( ] out = [] for view, unseen in parts: - region = unseen.intersection(within).intersection(self.scene.coverable(view)) + region = _meet(_meet(unseen, within), self.scene.coverable(view)) # 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)) @@ -704,6 +723,11 @@ 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." + # 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. @@ -722,6 +746,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 @@ -1357,7 +1395,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) diff --git a/server/tests/test_final_review.py b/server/tests/test_final_review.py index 27ff182b2..9f10d2308 100644 --- a/server/tests/test_final_review.py +++ b/server/tests/test_final_review.py @@ -1,13 +1,14 @@ """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, parsed, rect, shared_fixture from hypothesis import given, settings from hypothesis import strategies as st from test_s4_round import PUBLIC, answer -from solver import FAIL, PASS, UNSURE, at_least, evaluate_start +from solver import FAIL, PASS, SOLVE_BUDGET_S, UNSURE, Solver, at_least, evaluate_start W = 31 / 12 @@ -109,3 +110,75 @@ def test_the_wall_behind_every_possible_position_must_be_seen() -> None: 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 + + +@settings(max_examples=30, deadline=None) +@given(raw=cornered()) +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 + s0 = exact_start(raw, result) + assert unseen_at(supplied_exactly(raw, result), s0, result["spot"]["wall_id"]) == [] From 5a4b4f4ece5e184d4410f263b4a93fd739182515 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 02:51:47 -0500 Subject: [PATCH 44/52] server: the caretaker's three findings at 4a9fa44 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route length: its error now adds the error of every other wall segment the cable runs along (at each one's far end), not only the meter's and the battery's wall's. A 14.5 ft run through two walls known to ±1 was a pass; moving their shared corner within that error made it 15.6 ft. Summing may overstate but never understates. The reach cutoff widens to match. - Route coverage: the wall must be seen over the route widened at each end by that end's error (the meter's, the wall's). Unseen wall within the meter's ±0.3 passed, though a door there would block the cable. - Rules setting openings.exempt_bottom_above_ft above headroom.min_ft, the wall height the opening check requires seen, are refused at load; a higher exemption let an unseen window above the view count. The README row now says so. No decision or spot changed across 493 recorded answers or the real scenes. Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/rules.py | 12 +++++ server/solver.py | 32 ++++++++++-- server/tests/test_last_round.py | 88 +++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 server/tests/test_last_round.py diff --git a/server/README.md b/server/README.md index 97f5f05e9..320f00713 100644 --- a/server/README.md +++ b/server/README.md @@ -94,11 +94,11 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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 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 | +| `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, s1 + r], 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`) | wall band from the meter to the battery's near edge, seen higher than the cable's run (`route.height_ft`, 1.0) | +| `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). diff --git a/server/rules.py b/server/rules.py index 350457e38..9b4d0b6b1 100644 --- a/server/rules.py +++ b/server/rules.py @@ -172,6 +172,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: diff --git a/server/solver.py b/server/solver.py index e06d61d57..bc5e6aea7 100644 --- a/server/solver.py +++ b/server/solver.py @@ -823,7 +823,10 @@ 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 run's length is known only to the meter's error, the battery's wall's, and 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. + e = self.scene.meter_plus_minus + piece.plus_minus + self._walls_between(piece, lo, hi) maybe_blockers: list[str] = [] for o in self.route_objects: if path_line is None: @@ -881,7 +884,17 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C + 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 + 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", @@ -1116,12 +1129,25 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: checks += [path, reach] return Candidate(wall_piece, s0, s1, fp, checks, route, worst([x.outcome for x in checks])) + 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 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.scene.meter_plus_minus + 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 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 From 86b2b4d6912317486762b420e6b8972a67e2b83c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 03:22:36 -0500 Subject: [PATCH 45/52] server: a conservative house, and the coverage tolerance counted once - Scene.house() was the union of strips behind each scanned wall; a wall running back parallel behind another had its strip cross the front facade and hide that facade's yard, so a spot passed with no ground seen at all. The house is now those strips intersected with the house side of every scanned wall's line: when in doubt, yard. Where that leaves ground just behind one wall but in front of the next (an inside corner), a ground request's span is widened over every wall whose strip holds part of the region, so capturing it still settles it. - Seen ground grew 0.005 ft each side and seam removal took another 0.005, closing gaps up to 0.02 ft. The growth is now 0.005 - 1e-4 and seams under 2e-4 ft are removed: together, exactly the 0.01 ft tolerance (0.009 closes; 0.011 and 0.019 stay unseen). The finer seam removal also catches a 9e-10 sq ft speck no request could settle. A property test checks that with no ground observed, no check that needs ground passes, across random facades including a wall running back parallel at any error; the caretaker's three-wall scene is a fixed test. The fixed-point property and all six real-scene capture loops still settle in one round. No decision or spot changed across 392 recorded answers; 83 differ only in their requests. Schemas unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/scene.py | 55 ++++++++---- server/tests/test_house_and_tolerance.py | 107 +++++++++++++++++++++++ 3 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 server/tests/test_house_and_tolerance.py diff --git a/server/README.md b/server/README.md index 320f00713..ce0be8108 100644 --- a/server/README.md +++ b/server/README.md @@ -109,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. -Ground behind a scanned wall is the house and never counts as unseen, even where a neighbouring wall's strip reaches across it at an inside corner. 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). +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 diff --git a/server/scene.py b/server/scene.py index b55d51108..5dbde715b 100644 --- a/server/scene.py +++ b/server/scene.py @@ -314,25 +314,28 @@ def unobserved_ground_given(self, ground: list[tuple[float, float, float | None] # across the other's line, and that ground no view in front of a wall can show. outdoor = self.band_polygon(lo, hi, self.reach_ft).difference(self.house()) seen = unary_union([self.band_polygon(a, b, out or 0.0) for a, b, out in 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)) - # Remove anything thinner than the tolerance (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 it, so it would leave a check that nothing settles. Shrinking and - # regrowing by half the tolerance does that and moves the rest of the boundary by at - # most that much, the same allowance the seen strips get above. - half = COVERAGE_TOLERANCE_FT / 2 - unseen = unseen.buffer(-half, join_style="mitre").buffer(half, join_style="mitre") + # 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 = outdoor.difference(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: - """Behind each scanned wall segment, out to the modelled reach: the house itself, not - ground a hazard could hide on.""" + """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: - self._cache["house"] = unary_union( - [p.rect(p.s0, p.s1, -self.reach_ft, 0.0) for p in self.walls] - ) + 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( @@ -354,7 +357,7 @@ def _unexplored_discs( 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", []) if ground is None else ground) ] - ).buffer(COVERAGE_TOLERANCE_FT / 2) + ).buffer(SEEN_GROWTH_FT) known = unary_union([seen_in_front, self.house()]) return [d.difference(known) for d in discs] @@ -405,7 +408,18 @@ def covers(a: float, b: float, depth: float) -> bool: return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-9 tol = COVERAGE_TOLERANCE_FT - for a, b in ((s_lo, s_hi), (s_lo - tol, s_hi + tol)): + # 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 @@ -628,6 +642,13 @@ def _error(item: dict[str, Any], default: float) -> float: return float(item["plus_minus_ft"]) if "plus_minus_ft" in item else default +# 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 diff --git a/server/tests/test_house_and_tolerance.py b/server/tests/test_house_and_tolerance.py new file mode 100644 index 000000000..7b3995b7e --- /dev/null +++ b/server/tests/test_house_and_tolerance.py @@ -0,0 +1,107 @@ +"""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 and, sometimes, a wall running back parallel behind the first, + 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}] + if draw(st.booleans()): + 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) From 22292a8273feb5cab0047c755e46cd0dce60bc85 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 03:37:50 -0500 Subject: [PATCH 46/52] Test that more space never makes a check worse Two property tests on the fixed-point test's corner scenes, per check at a fixed start: moving a ground-standing obstacle straight out from the wall, or adding an observed span to any band, never raises a check's rank (PASS < UNSURE < FAIL) or the start's outcome, and every measured PASS clears its threshold by more than its error. Both pass 200 examples. The existing captured-request property found a new counterexample (the same at 5a4b4f4): a ground request past a limit end comes back after it is captured. It is pinned as a strict expected failure, listed and not fixed. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/tests/test_more_space.py | 104 ++++++++++++++++++++++++++++++++ server/tests/test_s4_round.py | 20 ++++++ 2 files changed, 124 insertions(+) create mode 100644 server/tests/test_more_space.py diff --git a/server/tests/test_more_space.py b/server/tests/test_more_space.py new file mode 100644 index 000000000..baea33a6b --- /dev/null +++ b/server/tests/test_more_space.py @@ -0,0 +1,104 @@ +"""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 + +from helpers import parsed +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)) diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index 560c7fb1a..73d773de9 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -114,6 +114,26 @@ def test_a_captured_request_never_comes_back(raw: dict) -> None: raw = captured(raw, result) +@pytest.mark.xfail( + strict=True, + reason="Open finding (listed, not fixed): a ground request past a limit end can't be settled", +) +def test_a_request_past_a_limit_end_is_settled_by_capturing_it() -> None: + # Found by the property above after 86b2b4d, and the same at 5a4b4f4. The first answer asks + # for ground from s = -4.01, past the left limit end at -0.5; after capturing it, + # [-0.833, -0.5] 2.85 ft out (pool_clearance) is asked for again on every later round. + 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) + for _ in range(3): + result = answer(raw) + assert repeated_requests(raw, result) == [], result["missing_evidence"] + raw = captured(raw, result) + + # --- 2. ground past a limit end is not clear until it is seen ------------------------------------ From a72619933ceb2966c7f1451fcba77f2d8c09ca50 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 04:08:29 -0500 Subject: [PATCH 47/52] Count ground seen past a limit end inside an unexplored end's reach The reach around an unexplored end can extend past the other, limit end. There, ground views were clipped to the scanned walls, so ground seen past the limit end never counted, and the request for it came back after every capture. Views are now clipped only at unexplored ends: the same span coverable() uses, shared as coverable_span(). Ground seen past an unexplored end still counts for nothing. The corner example no longer asks for ground at s [-3.93, -3], which its capture had already seen; drive_clearance now passes there, so the spot moves from s 1.458 to the shorter route at 1.25. The decision is unchanged. CI now runs every property test on fixed examples (a Hypothesis profile with derandomize, no example database), so a check can't pass on one run and fail on the next. Local runs still search at random. A 200-example run of the S2-3 obstacle property found a case where moving a gas meter off the route's start resolves route_length from UNSURE to a correct FAIL. It is the same at 22292a8 and is pinned as a strict expected failure, listed and not fixed. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +-- server/scene.py | 20 +++++++---- server/tests/conftest.py | 10 ++++++ server/tests/test_more_space.py | 54 +++++++++++++++++++++++++++- server/tests/test_policy_and_auth.py | 10 +++--- server/tests/test_s4_round.py | 38 +++++++++++++++----- 6 files changed, 114 insertions(+), 22 deletions(-) diff --git a/server/README.md b/server/README.md index ce0be8108..0a566d918 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,7 @@ 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. - 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. diff --git a/server/scene.py b/server/scene.py index 5dbde715b..2cf0524fc 100644 --- a/server/scene.py +++ b/server/scene.py @@ -342,8 +342,10 @@ 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) @@ -352,9 +354,10 @@ def _unexplored_discs( ] 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) + self.band_polygon(max(a, lo), min(b, hi), out or 0.0) for a, b, out in (self.observed.get("ground", []) if ground is None else ground) ] ).buffer(SEEN_GROWTH_FT) @@ -436,15 +439,20 @@ def covers(a: float, b: float, depth: float) -> bool: # 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).difference(self.house()) else: 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_more_space.py b/server/tests/test_more_space.py index baea33a6b..57e7a9267 100644 --- a/server/tests/test_more_space.py +++ b/server/tests/test_more_space.py @@ -17,7 +17,8 @@ import copy -from helpers import parsed +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 @@ -102,3 +103,54 @@ def test_seeing_more_never_makes_a_check_worse(raw: dict, data) -> None: 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 6e51f4d91..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: diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index 73d773de9..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,26 +115,47 @@ def test_a_captured_request_never_comes_back(raw: dict) -> None: raw = captured(raw, result) -@pytest.mark.xfail( - strict=True, - reason="Open finding (listed, not fixed): a ground request past a limit end can't be settled", -) -def test_a_request_past_a_limit_end_is_settled_by_capturing_it() -> None: - # Found by the property above after 86b2b4d, and the same at 5a4b4f4. The first answer asks - # for ground from s = -4.01, past the left limit end at -0.5; after capturing it, - # [-0.833, -0.5] 2.85 ft out (pool_clearance) is asked for again on every later round. +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 ------------------------------------ From da6d308b46933f4aac6f435a34916a88a4ac052a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 06:50:19 -0500 Subject: [PATCH 48/52] Hold every PASS wherever the declared errors put things A PASS must survive every perturbation of the inputs within their declared errors. A new property test moves every wall end and corner, the meter, and each object and ground outline within its error (eight directions on its error circle), shifts spans and measured values by their errors, keeps the battery at its offset from the meter (where the AR view anchors it), and requires that no passing check becomes FAIL, with the declared errors or read as exact. It also places near-miss objects just past their rules, and steers the search toward the least slack. It found, and these commits fix: - meter_working_space left out the meter wall's error when the battery is on another wall (the caretaker's witness), and measured an overlap from another wall as 0, a tie that never failed. It now adds the errors of the walls under the working space and measures overlap by its depth. - The battery moves with the meter, so its position error now includes the meter's: a battery 0.04 ft short of a wall's end passed while a 0.05 ft meter error could carry it past. - A battery round a corner slides along the walls when the meter or a corner between them moves (0.3 * sqrt(2) ft for a 0.3 ft meter error at a right angle), so checks against spans in s passed a box that could stand over the battery. The slide is part of its error now. - The route counts the meter's error one-sidedly: e * sqrt(1 + k^2) longer, but shorter only as far as its standoff allows, and the check judges that range (golden test 09 unchanged). - Two GEOS failures (a 500 for a valid scene, and one on a real simulator capture) from unsnapped overlays of the band, the house and the seen ground, now snapped to a 1e-9 ft grid, with only their areas passed on. No recorded decision changed. Spots moved where the larger errors turned a check unsure or shifted the sweep's samples. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/scene.py | 19 +- server/solver.py | 155 ++++++++-- server/tests/test_within_errors.py | 450 +++++++++++++++++++++++++++++ 4 files changed, 591 insertions(+), 37 deletions(-) create mode 100644 server/tests/test_within_errors.py diff --git a/server/README.md b/server/README.md index 0a566d918..8f432da0e 100644 --- a/server/README.md +++ b/server/README.md @@ -83,7 +83,7 @@ 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 (`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. +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 or, if larger, how far the battery can slide along the walls. The battery is placed by its offset from the meter, so it moves with the meter against anything placed in plan. Against anything placed in s, a battery round a corner from the meter also slides when the meter or a corner between them moves: each move's error times the difference between the two walls' directions (0 on the meter's own wall, √2 at a right angle). `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 | | --- | --- | --- | @@ -101,7 +101,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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). 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: the meter's error can lengthen it by e·√(1 + k²) (k: the difference between the meter's and the battery's wall directions) but shortens it only as far as the meter's standoff from its wall allows. Ends and corners: diff --git a/server/scene.py b/server/scene.py index 2cf0524fc..126f4a282 100644 --- a/server/scene.py +++ b/server/scene.py @@ -312,11 +312,11 @@ def unobserved_ground_given(self, ground: list[tuple[float, float, float | None] lo, hi = self.pieces[0].s0, self.pieces[-1].s1 # 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 = self.band_polygon(lo, hi, self.reach_ft).difference(self.house()) + outdoor = _minus(self.band_polygon(lo, hi, self.reach_ft), self.house()) seen = unary_union([self.band_polygon(a, b, out or 0.0) for a, b, out 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 = outdoor.difference(seen.buffer(SEEN_GROWTH_FT)) + 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 @@ -454,7 +454,7 @@ def coverable(self, band: str) -> Geometry: if key not in self._cache: lo, hi = self.coverable_span() if band == "ground": - self._cache[key] = self.band_polygon(lo, hi, self.reach_ft).difference(self.house()) + self._cache[key] = _minus(self.band_polygon(lo, hi, self.reach_ft), self.house()) else: self._cache[key] = self.wall_line(lo, hi).buffer(1e-6, cap_style="flat") return self._cache[key] @@ -650,6 +650,19 @@ def _error(item: dict[str, Any], default: float) -> float: return float(item["plus_minus_ft"]) if "plus_minus_ft" in item else default +# 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 diff --git a/server/solver.py b/server/solver.py index bc5e6aea7..76f8cdf08 100644 --- a/server/solver.py +++ b/server/solver.py @@ -31,6 +31,7 @@ Scene, SceneObject, merge_intervals, + polygonal, subtract_intervals, ) from units import format_ft_in @@ -150,6 +151,16 @@ 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 @@ -462,7 +473,12 @@ def _missing( ] out = [] for view, unseen in parts: - region = _meet(_meet(unseen, within), 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)) # 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 = [] @@ -646,9 +662,19 @@ def check_meter_space(self, piece: Piece, fp: Polygon, s0: float, s1: float) -> 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" @@ -823,18 +849,22 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C small_gaps.append(f"a {ft(gap.s1 - gap.s0)} gap between walls") h = r.height_ft.value path_line = self.scene.wall_line(lo, hi) if hi - lo > EPS else None - # The run's length is known only to the meter's error, the battery's wall's, and 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. - e = self.scene.meter_plus_minus + piece.plus_minus + self._walls_between(piece, lo, hi) + # 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: continue - # The object's ends carry its error and the route's ends the meter's and the wall's, - # 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 + # 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 @@ -887,7 +917,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C # 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 + 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", @@ -944,7 +974,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. @@ -961,19 +997,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 = ( @@ -1006,16 +1048,16 @@ 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. + piece = replace(wall_piece, plus_minus=self._error(wall_piece, s0), 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", @@ -1125,7 +1167,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: piece.plus_minus, ), ] - 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])) @@ -1140,6 +1182,23 @@ def _walls_between(self, piece: Piece, lo: float, hi: float) -> float: 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 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 (at most its + error e) moves both: the route's start slides d's component along the meter's wall, the + battery d's along its own, and the meter's standoff from its wall changes by d's + component across it. On the meter's own wall the slides cancel; from a wall turned by + the angle between their directions (a unit-vector difference k) they add up to k|d|. + Longer: at most e * sqrt(1 + k^2). Shorter: the standoff can't go below zero, so by at + most min(standoff, e) + k * e, and never more than the longer bound. With the meter on + its wall's line, a run on that wall is never shorter than measured (golden test 09); + test_within_errors covers a battery round a corner.""" + e = self.scene.meter_plus_minus + m = self.scene.meter_piece + k = math.hypot(piece.along[0] - m.along[0], piece.along[1] - m.along[1]) + longer = e * math.sqrt(1 + k * k) + return longer, min(longer, min(self.meter_offset, e) + k * 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, @@ -1147,7 +1206,7 @@ def reach_limit(self, piece: Piece) -> float: detour_err = sum(2 * o.plus_minus for o in self.route_objects) # 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.scene.meter_plus_minus + piece.plus_minus + detour_err + others + 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 @@ -1172,12 +1231,15 @@ def starts(self, piece: Piece) -> list[float]: 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. - for e in {piece.plus_minus, piece.error_at(max(abs(lo), abs(hi)))}: + # 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. + extra = max(self.scene.meter_plus_minus, self._slide(piece, lo)) + 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))) + 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 @@ -1218,9 +1280,10 @@ def starts(self, piece: Piece) -> list[float]: ) ) 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 @@ -1280,9 +1343,37 @@ def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float, float, f return out def _error(self, piece: Piece, s0: float) -> float: - """The battery's position error for a start at s0: the wall's error at its far edge from - the meter, as evaluate uses.""" - return piece.error_at(max(abs(s0), abs(s0 + self.W))) + """The battery's position error for a start at s0, as every check uses it: the wall's + error at the battery's far edge from the meter, plus the larger of what the rest can do. + 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 + (a wall's end, a ground patch, an object's outline) it moves by up to the meter's error. + Against anything placed along the walls in s it slides (_slide). test_within_errors has + a case of each.""" + wall = piece.error_at(max(abs(s0), abs(s0 + self.W))) + return wall + max(self.scene.meter_plus_minus, 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: diff --git a/server/tests/test_within_errors.py b/server/tests/test_within_errors.py new file mode 100644 index 000000000..ee308f79e --- /dev/null +++ b/server/tests/test_within_errors.py @@ -0,0 +1,450 @@ +"""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 (eight + 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 with its declared errors it is not +FAIL, and in the perturbed scene read as exact (every error zero, so the check reports whether the +rule truly holds there) it is not FAIL either. +""" + +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 test_final_review import cornered +from test_s4_round import PUBLIC + +from scene import SceneError +from solver import FAIL, PASS, evaluate_start, solve + +W = PUBLIC.rules.battery.width_ft.value +DIRECTIONS = [(math.cos(k * math.pi / 4), math.sin(k * math.pi / 4)) for k in range(8)] +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"]), label=f"near {i} where") + if 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 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, 7) + 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 + radius = error_of(wall) + if j == len(pts) - 1 and i + 1 < len(walls) and joined(i + 1): + radius = min(radius, error_of(walls[i + 1])) + pts[j] = moved(pts[j], radius, data.draw(direction, label=f"wall {i} point {j}")) + 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") + 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: + for name, after in (("declared errors", declared), ("exact", truth)): + c = after[cid] + assert c.outcome != FAIL, (cid, name, c.measured, c.plus_minus, c.reason) + + +def slack_of(c) -> float | None: + """How far a check's measurement is from its rule, on the passing side.""" + if c.measured is None or c.threshold is None: + return None + return c.measured - c.threshold if c.comparison == "at_least" else c.threshold - 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" From 0ac96a32882423416fbc4ac8d5a7b7289d8bc3ac Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 07:11:00 -0500 Subject: [PATCH 49/52] Judge spans with the battery's error along the walls, not the meter's da6d308 added the meter's error to every check, but a span on the meter's own wall moves with the meter exactly as the battery does, so the meter's error says nothing about the gap between them. That turned the private example's garage clearance from a clear FAIL into UNSURE (reject became manual_review). The battery now has two errors (Solver._errors): its wall's plus the meter's against anything placed in plan (outlines, ground patches, wall ends), and its wall's plus the slide along the walls against spans (0 on the meter's wall). Clearances pick per object by whether it has a footprint; wall equipment, facing and headroom use the error along the walls; coverage keeps the larger. The sweep also samples the offsets of the smaller error. The garage example is back to reject. No recorded decision changed. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/scene.py | 4 ++ server/solver.py | 106 +++++++++++++++++++++++++++++++---------------- 3 files changed, 75 insertions(+), 37 deletions(-) diff --git a/server/README.md b/server/README.md index 8f432da0e..5ff4b416f 100644 --- a/server/README.md +++ b/server/README.md @@ -83,7 +83,7 @@ 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 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 or, if larger, how far the battery can slide along the walls. The battery is placed by its offset from the meter, so it moves with the meter against anything placed in plan. Against anything placed in s, a battery round a corner from the meter also slides when the meter or a corner between them moves: each move's error times the difference between the two walls' directions (0 on the meter's own wall, √2 at a right angle). `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. +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). `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 | | --- | --- | --- | diff --git a/server/scene.py b/server/scene.py index 126f4a282..b468b2fb0 100644 --- a/server/scene.py +++ b/server/scene.py @@ -117,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: @@ -967,6 +970,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, ) ) diff --git a/server/solver.py b/server/solver.py index 76f8cdf08..1cbede0e5 100644 --- a/server/solver.py +++ b/server/solver.py @@ -518,23 +518,28 @@ 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, ) -> 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: + for name, geom, err, counts, in_plan in items: if counts is False: continue d = fp.distance(geom) - e = err + piece.plus_minus + e = err + (piece.plus_minus if in_plan else along_err) outcome = at_least(d, e, t) cause = "margin" if counts is None and outcome != PASS: @@ -606,8 +611,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( @@ -625,7 +631,7 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: gap = max(o.span[0] - s1, s0 - o.span[1]) # 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 + piece.plus_minus + 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: @@ -1051,7 +1057,8 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: # 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. - piece = replace(wall_piece, plus_minus=self._error(wall_piece, s0), drift=0.0) + 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 = [ @@ -1065,11 +1072,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", @@ -1078,9 +1086,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, ), *( [ @@ -1091,10 +1100,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], + [(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 @@ -1108,11 +1118,12 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: piece, fp, [ - (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True) + (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True, True) for g in self.drives ], "ground", "drivable surface", + along_err=along_err, ), self.check_clearance( "pool_clearance", @@ -1121,9 +1132,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", @@ -1132,12 +1144,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", @@ -1151,7 +1167,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", @@ -1164,7 +1180,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: s1, 0.0, "headroom", - piece.plus_minus, + along_err, ), ] route, path, reach = self.route_for(on_wall, s0, s1) @@ -1233,7 +1249,10 @@ def starts(self, piece: Piece) -> list[float]: # 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. - extra = max(self.scene.meter_plus_minus, self._slide(piece, lo)) + 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 @@ -1243,7 +1262,7 @@ def starts(self, piece: Piece) -> list[float]: # 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) + 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 @@ -1251,9 +1270,12 @@ def starts(self, piece: Piece) -> list[float]: 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 - own = (0.0, e, -e) if drifts else (0.0, e, -e, e + ew, -(e + ew)) + 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(): @@ -1295,7 +1317,7 @@ def starts(self, piece: Piece) -> list[float]: # 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): + for geom, base, fixed, k in self._clearance_edges(along): if drifts and k != 0: points += self._drifting_outline( piece, geom, base, fixed, k, tracks, strip, e_least, e_most @@ -1325,16 +1347,21 @@ 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, float, float]]: + def _clearance_edges(self, 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.""" + 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 + + def fixed(o: SceneObject) -> float: + return o.plus_minus if o.in_plan else o.plus_minus - along + 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 = [(geom, t, err, k) for t, geom, err in items for k in (0.0, 1.0, -1.0) if t > 0] @@ -1343,15 +1370,22 @@ def _clearance_edges(self, piece: Piece) -> list[tuple[Geometry, float, float, f return out def _error(self, piece: Piece, s0: float) -> float: - """The battery's position error for a start at s0, as every check uses it: the wall's - error at the battery's far edge from the meter, plus the larger of what the rest can do. - 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 - (a wall's end, a ground patch, an object's outline) it moves by up to the meter's error. - Against anything placed along the walls in s it slides (_slide). test_within_errors has - a case of each.""" + """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 + max(self.scene.meter_plus_minus, self._slide(piece, s0)) + 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 From 3baa3387a6877961345afa7785f1530c1aeb2b1b Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 07:46:30 -0500 Subject: [PATCH 50/52] Bound the route's meter error exactly, and hold a PASS strictly Moving the meter by d moves the route's start and the battery together: the run changes by sigma d.v + |o + d.u| - o (v: the difference of the two walls' directions, sigma the battery's side, o the standoff, u from the wall to the meter). That is the larger of two linear functions of d, so over the error disc it is at most max(e|sigma v + u|, e|sigma v - u| - 2o), derived and reached (sqrt(5) e round a right angle; 72 points on the circle come within 0.0002 ft). e * sqrt(1 + k^2) had treated the two components as independent, and the caretaker's witness passed a route that a 0.3 ft meter move takes past the confident reach. The property is now strict: a check that PASSes must still PASS in every perturbed scene read as exact, its measurement on the pass side of every threshold (the confident reach too), with 16 directions per error circle and near-reach placements by route length. It found that wall_equipment_above looked for unseen wall only over the battery's nominal stretch; it now widens by the battery's error along the walls. No recorded decision or spot changed; one real scene asks for one more wall view. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/solver.py | 44 ++++++++---- server/tests/test_within_errors.py | 105 +++++++++++++++++++++++++---- 3 files changed, 125 insertions(+), 28 deletions(-) diff --git a/server/README.md b/server/README.md index 5ff4b416f..b495e649b 100644 --- a/server/README.md +++ b/server/README.md @@ -95,13 +95,13 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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; 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, s1 + r], seen higher than headroom height (6.5) | +| `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). 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: the meter's error can lengthen it by e·√(1 + k²) (k: the difference between the meter's and the battery's wall directions) but shortens it only as far as the meter's standoff from its wall allows. +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). 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: diff --git a/server/solver.py b/server/solver.py index 1cbede0e5..eec1c8ccd 100644 --- a/server/solver.py +++ b/server/solver.py @@ -641,7 +641,9 @@ def check_along_wall(self, along_err: float, 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." @@ -1199,21 +1201,35 @@ def _walls_between(self, piece: Piece, lo: float, hi: float) -> float: return total def _meter_on_route(self, piece: Piece) -> tuple[float, float]: - """How much the meter's error 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 (at most its - error e) moves both: the route's start slides d's component along the meter's wall, the - battery d's along its own, and the meter's standoff from its wall changes by d's - component across it. On the meter's own wall the slides cancel; from a wall turned by - the angle between their directions (a unit-vector difference k) they add up to k|d|. - Longer: at most e * sqrt(1 + k^2). Shorter: the standoff can't go below zero, so by at - most min(standoff, e) + k * e, and never more than the longer bound. With the meter on - its wall's line, a run on that wall is never shorter than measured (golden test 09); - test_within_errors covers a battery round a corner.""" + """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 - k = math.hypot(piece.along[0] - m.along[0], piece.along[1] - m.along[1]) - longer = e * math.sqrt(1 + k * k) - return longer, min(longer, min(self.meter_offset, e) + k * e) + 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 diff --git a/server/tests/test_within_errors.py b/server/tests/test_within_errors.py index ee308f79e..faf960372 100644 --- a/server/tests/test_within_errors.py +++ b/server/tests/test_within_errors.py @@ -11,16 +11,18 @@ - 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 (eight + 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 with its declared errors it is not -FAIL, and in the perturbed scene read as exact (every error zero, so the check reports whether the -rule truly holds there) it is not FAIL either. +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 @@ -34,10 +36,12 @@ from test_s4_round import PUBLIC from scene import SceneError -from solver import FAIL, PASS, evaluate_start, solve +from solver import FAIL, PASS, UNSURE, evaluate_start, solve W = PUBLIC.rules.battery.width_ft.value -DIRECTIONS = [(math.cos(k * math.pi / 4), math.sin(k * math.pi / 4)) for k in range(8)] +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]) @@ -180,7 +184,7 @@ 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, 7) + direction = st.none() | st.integers(0, len(DIRECTIONS) - 1) sign = st.sampled_from([-1, 0, 1]) walls, given = out["walls"], raw["walls"] @@ -263,6 +267,17 @@ def test_a_pass_holds_wherever_the_declared_errors_put_things(raw: dict, data) - 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} @@ -282,16 +297,23 @@ def test_a_pass_holds_wherever_the_declared_errors_put_things(raw: dict, data) - if slack: target(-min(slack), label="closest to breaking") for cid in passing: - for name, after in (("declared errors", declared), ("exact", truth)): - c = after[cid] - assert c.outcome != FAIL, (cid, name, c.measured, c.plus_minus, c.reason) + 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 its rule, on the passing side.""" + """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 - return c.measured - c.threshold if c.comparison == "at_least" else c.threshold - c.measured + 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: @@ -448,3 +470,62 @@ def test_ground_requests_ignore_lines_left_where_edges_touch() -> None: }, } 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" From 6c7bea122371711284d5672410b6e1afde22b7bb Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 09:27:34 -0500 Subject: [PATCH 51/52] Fix the final review's side passage, drive, fixed-point and size findings - A side passage behind a gap between walls was neither in front of a wall nor the house, so it never counted as unseen and a spot beside it passed gas, AC and pool. It is now yard: a ground view shows it only when its span lies within the gap (the camera pointed into the passage, both sides of the line, as past a limit end), and a request names exactly the gap's stretch. A property checks that views along the walls never show it; the no-ground property now includes gaps. A subnormal coordinate the property found (GEOS dropped the passage) is read as 0: anything below the 1e-9 ft overlay grid is. - drive_clearance counted seen ground with no recorded surface as no driveway. It is now an unknown attribute, as for ground_surface, and the reason names what settles it. - The fixed-point property failed on a 108 degree corner: a request's depth was accepted with a 9.5e-10 sq ft lens left 1.7e-5 ft inside a clearance circle. Requests now leave at most 1e-12 sq ft. The saved example is an explicit example, so CI runs it. - An 11 KB body of 798 wall segments took 28 s. Scenes over 200 straight segments are refused at parse (0.37 s at 200, 2.7 s at 398, 19 to 29 s at 798). - (Manager, ETH3D) An unseen check whose ground lies past an unexplored end beyond cable reach had no request and no reason. The answer now asks to walk past that end and names the check. Fixture: the pad scene records deck past its ends, where seen ground is otherwise unrecorded. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/scene.py | 70 ++++++++- server/solver.py | 82 ++++++++++- server/tests/test_final_opus_review.py | 178 +++++++++++++++++++++++ server/tests/test_final_review.py | 75 +++++++++- server/tests/test_house_and_tolerance.py | 12 +- server/tests/test_properties.py | 9 +- 7 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 server/tests/test_final_opus_review.py diff --git a/server/README.md b/server/README.md index b495e649b..149d2416a 100644 --- a/server/README.md +++ b/server/README.md @@ -75,7 +75,7 @@ A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON - `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, 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. +- 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. @@ -101,7 +101,7 @@ Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58 | `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). 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. +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: diff --git a/server/scene.py b/server/scene.py index b468b2fb0..5db9f5c13 100644 --- a/server/scene.py +++ b/server/scene.py @@ -250,6 +250,31 @@ 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) -> 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).""" + parts = [self.band_polygon(s_lo, s_hi, out)] + for g in self.gaps: + if s_lo >= g.s0 - GAP_VIEW_SLACK_FT and s_hi <= g.s1 + GAP_VIEW_SLACK_FT and out > EPS: + parts.append(g.rect(max(s_lo, g.s0), min(s_hi, g.s1), -out, 0.0)) + return unary_union(parts) + 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]))) @@ -312,11 +337,10 @@ def unobserved_ground(self) -> Geometry: def unobserved_ground_given(self, ground: list[tuple[float, 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.""" - lo, hi = self.pieces[0].s0, self.pieces[-1].s1 # 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.band_polygon(lo, hi, self.reach_ft), self.house()) - seen = unary_union([self.band_polygon(a, b, out or 0.0) for a, b, out in ground]) + outdoor = _minus(self.outdoor_band(self.reach_ft), self.house()) + seen = unary_union([self.view_polygon(a, b, out or 0.0) for a, b, out in ground]) # 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)) @@ -360,7 +384,7 @@ def _unexplored_discs( lo, hi = self.coverable_span() seen_in_front = unary_union( [ - self.band_polygon(max(a, lo), min(b, hi), out or 0.0) + self.view_polygon(max(a, lo), min(b, hi), out or 0.0) for a, b, out in (self.observed.get("ground", []) if ground is None else ground) ] ).buffer(SEEN_GROWTH_FT) @@ -410,8 +434,12 @@ def view_to_cover( 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)])) - return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-9 + 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, @@ -457,7 +485,7 @@ def coverable(self, band: str) -> Geometry: if key not in self._cache: lo, hi = self.coverable_span() if band == "ground": - self._cache[key] = _minus(self.band_polygon(lo, hi, self.reach_ft), self.house()) + 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] @@ -628,7 +656,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]: @@ -653,6 +689,13 @@ 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" @@ -677,6 +720,10 @@ def _minus(a: Geometry, b: Geometry) -> Geometry: # 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 @@ -856,6 +903,15 @@ def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = N pieces = [replace(p, s0=p.s0 - shift, s1=p.s1 - shift) for p in pieces] 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 diff --git a/server/solver.py b/server/solver.py index eec1c8ccd..a010cde8e 100644 --- a/server/solver.py +++ b/server/solver.py @@ -20,7 +20,7 @@ from typing import Any import shapely -from shapely import Geometry, LineString, Polygon, get_coordinates, unary_union +from shapely import Geometry, LineString, Point, Polygon, get_coordinates, unary_union from rules import LoadedRules, Rules, Value from scene import ( @@ -305,7 +305,7 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: 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 = scene.outdoor_band(scene.reach_ft) # Outdoor ground no patch describes; opened to drop floating point slivers. self._unclassified = outdoor.difference(recorded).buffer(-1e-6).buffer(1e-6) self._buffers: dict[tuple[int, float], Geometry] = {} @@ -479,6 +479,17 @@ def _missing( # 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 = [] @@ -523,6 +534,7 @@ def check_clearance( 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, in plan) where counts None means an unknown attribute decides whether it @@ -536,7 +548,8 @@ def check_clearance( along_err = piece.plus_minus worst_key: tuple[int, float] | None = None for name, geom, err, counts, in_plan in items: - if counts is False: + # 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 if in_plan else along_err) @@ -567,7 +580,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." ) @@ -1120,11 +1133,27 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: piece, fp, [ - (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True, 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( @@ -1571,6 +1600,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) @@ -1822,17 +1859,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: diff --git a/server/tests/test_final_opus_review.py b/server/tests/test_final_opus_review.py new file mode 100644 index 000000000..d90adcaa8 --- /dev/null +++ b/server/tests/test_final_opus_review.py @@ -0,0 +1,178 @@ +"""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, it shows both sides of + # the gap's line (the camera pointed into the passage), and with the passage's surface + # recorded too (seen ground with none may be a driveway) the spot passes. + 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["coverage"]["observed"].append( + {"band": "ground", "span_ft": [10.0, 14.0], "out_ft": passage[0]["out_ft"]} + ) + 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 index 9f10d2308..5cb92d9e4 100644 --- a/server/tests/test_final_review.py +++ b/server/tests/test_final_review.py @@ -3,8 +3,8 @@ import copy import math -from helpers import at_start, parsed, rect, shared_fixture -from hypothesis import given, settings +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 @@ -171,8 +171,38 @@ def cornered(draw: st.DrawFn) -> dict: 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 @@ -180,5 +210,46 @@ def test_capturing_exactly_what_is_requested_settles_it_in_one_round(raw: dict) 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 index 7b3995b7e..5d36cb137 100644 --- a/server/tests/test_house_and_tolerance.py +++ b/server/tests/test_house_and_tolerance.py @@ -71,14 +71,20 @@ def test_only_gaps_within_the_tolerance_close(gap: float, closes: bool) -> None: @st.composite def facades(draw: st.DrawFn) -> dict: - """A facade with a corner and, sometimes, a wall running back parallel behind the first, - at any error; no ground observations at all.""" + """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}] - if draw(st.booleans()): + 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}, 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 { From 1d1e7e1b55237ae82f5af10125ca33818f15cfce Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 09:58:04 -0500 Subject: [PATCH 52/52] Move a hazard on another wall's line with that wall A gas meter with no outline lies on its wall's line at its span. Against a battery on another wall its clearance left that wall's error out: moving the wall's ends within 0.3 ft moved it 0.17 ft, and 3.037 ft passed where the truth could be 2.866 (the caretaker's case at 3baa338). Such a hazard's error now adds, for each wall between it and the battery other than the battery's own, that wall's error times one plus its turn against the battery's wall. The property now moves a shared corner along an exact wall by the other wall's error, and places near-miss hazards on the other wall's line; with the term removed it finds the class. No recorded decision or spot changed. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/solver.py | 32 ++++++++++-- server/tests/test_within_errors.py | 82 ++++++++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/server/README.md b/server/README.md index 149d2416a..5912db53a 100644 --- a/server/README.md +++ b/server/README.md @@ -83,7 +83,7 @@ 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 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). `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. +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 | | --- | --- | --- | diff --git a/server/solver.py b/server/solver.py index a010cde8e..e1bc7b1a7 100644 --- a/server/solver.py +++ b/server/solver.py @@ -552,7 +552,7 @@ def check_clearance( if counts is False or geom.is_empty: continue d = fp.distance(geom) - e = err + (piece.plus_minus if in_plan else along_err) + 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: @@ -1218,6 +1218,26 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: 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.""" @@ -1362,7 +1382,7 @@ def starts(self, piece: Piece) -> list[float]: # 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(along): + 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 @@ -1392,14 +1412,18 @@ 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, along: float) -> list[tuple[Geometry, float, float, float]]: + 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 def fixed(o: SceneObject) -> float: - return o.plus_minus if o.in_plan else o.plus_minus - along + 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, fixed(o)) for o in self.gas), diff --git a/server/tests/test_within_errors.py b/server/tests/test_within_errors.py index faf960372..0490dafe5 100644 --- a/server/tests/test_within_errors.py +++ b/server/tests/test_within_errors.py @@ -32,6 +32,7 @@ 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 @@ -156,8 +157,26 @@ def near_misses(raw: dict, piece, s0: float, data) -> dict: "source": "tape", "plus_minus_ft": e, } - where = data.draw(st.sampled_from(["left", "right", "front"]), label=f"near {i} where") - if where == "front" and kind in ("gas_meter", "ac", "pool", "battery"): + 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)] @@ -180,6 +199,23 @@ def moved(point: list[float], radius: float, k: int | None) -> list[float]: 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.""" @@ -198,10 +234,11 @@ def joined(i: int) -> bool: if j == 0 and joined(i): pts[0] = list(walls[i - 1]["baseline"][-1]) continue - radius = error_of(wall) + k = data.draw(direction, label=f"wall {i} point {j}") if j == len(pts) - 1 and i + 1 < len(walls) and joined(i + 1): - radius = min(radius, error_of(walls[i + 1])) - pts[j] = moved(pts[j], radius, data.draw(direction, label=f"wall {i} point {j}")) + 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"] @@ -529,3 +566,38 @@ def test_the_wall_above_the_battery_is_seen_wherever_the_battery_may_stand() -> 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