From fb9436e48a7377cd321ef66583fe24ea771940d6 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:13:08 -0500 Subject: [PATCH 01/12] server: stop wall, facing and overhead requests at the wall's ends Scene.coverable let every band reach the extension piece past a limit end, so a spot beside a "Something blocks it" end asked for wall past the end (to 26 ft 2 in on a wall that ends at 19 ft 9 in), which the app can never show and drops. Only the ground is coverable past a limit end now. _missing_json also clips the views checks take straight from the 1D bands (the wall above the battery, the gap in front, the space overhead) to the chain's ends. A check whose radius crosses a limit end stays UNSURE for a person. The ground request past a limit end now says what the view looks for ("to check for an AC unit or a pool"), from the checks whose views reach past the end. Fixes #78. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 +- server/scene.py | 16 +++++---- server/solver.py | 44 ++++++++++++++++++++--- server/tests/test_requests.py | 68 +++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 server/tests/test_requests.py diff --git a/server/README.md b/server/README.md index b97df6994..111e43069 100644 --- a/server/README.md +++ b/server/README.md @@ -107,7 +107,7 @@ 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. +- `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. Its request says so, and what the view looks for there. Requests for the wall, the gap in front of it and the space overhead stop at either end mark, since past a limit end there is none of them to show; a check whose radius crosses a limit end can then stay UNSURE for a person. 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). diff --git a/server/scene.py b/server/scene.py index 1bd21a814..d9936f3eb 100644 --- a/server/scene.py +++ b/server/scene.py @@ -360,17 +360,21 @@ def farthest_out(self, region: Geometry) -> float: 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).""" + for the ground also past a limit end, where a pool or a driveway can still lie and a + camera pointed past the end from where the walk stopped sees it. The wall band stops at + both end marks: past a limit end there is no wall to show (issue #78). 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": + 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 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") + line = self.wall_line(self.s_min, self.s_max) + self._cache[key] = line.buffer(1e-6, cap_style="flat") return self._cache[key] def unobserved_wall_lines(self, up_to: float | None = None) -> Geometry: diff --git a/server/solver.py b/server/solver.py index 061743c19..ba9f9f7f6 100644 --- a/server/solver.py +++ b/server/solver.py @@ -1430,6 +1430,15 @@ def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: by_band: dict[str, list[tuple[View, str]]] = {} for chk in c.checks: for view in chk.all_missing(): + if view.band != "ground": + # Only the ground is asked for past an end mark: past a limit end there is no + # wall, gap in front of it or space over it to show, and past an unexplored end + # only walking on settles anything (issue #78). The check stays UNSURE for a + # person if what it needs lies there. + a, b = max(view.a, scene.s_min), min(view.b, scene.s_max) + if b - a < COVERAGE_TOLERANCE_FT: + continue + view = replace(view, a=a, b=b) by_band.setdefault(view.band, []).append((view, chk.id)) out = [] for band, items in by_band.items(): @@ -1446,24 +1455,49 @@ def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: 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) + request["message"] = text + "." + _past_end_hint(scene, a, b, within) out.append(request) 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.""" +# What a view of the ground past a limit end looks for, by the check that asks for it. +_LOOKS_FOR = { + "gas_clearance": "a gas meter or pipe", + "ac_clearance": "an AC unit", + "battery_clearance": "another battery", + "drive_clearance": "a driveway", + "pool_clearance": "a pool", +} + + +def _past_end_hint(scene: Scene, a: float, b: float, views: list[tuple[View, str]]) -> str: + """Only ground past a limit end is asked for past an end (see Scene.coverable and + _missing_json); it is met from where the walk stopped. The hint says what that view looks + for, from the checks whose views reach past the end.""" sides = [ s for s, past in (("left", a < scene.s_min - EPS), ("right", b > scene.s_max + EPS)) if past ] if not sides: return "" + looks_for = list( + dict.fromkeys( + _LOOKS_FOR[i] + for v, i in views + if i in _LOOKS_FOR and (v.a < scene.s_min - EPS or v.b > scene.s_max + EPS) + ) + ) + why = f" to check for {_either(looks_for)}" if looks_for else "" return ( - f" Part of it is past the {' and '.join(sides)} end: point the camera there from the end." + f" Part of it is past the {' and '.join(sides)} end: point the camera there from the " + f"end{why}." ) +def _either(items: list[str]) -> str: + """Alternatives as "a, b or c".""" + return items[0] if len(items) == 1 else f"{', '.join(items[:-1])} or {items[-1]}" + + 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) diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py new file mode 100644 index 000000000..203a02cc1 --- /dev/null +++ b/server/tests/test_requests.py @@ -0,0 +1,68 @@ +"""What missing_evidence asks a homeowner for: nothing but ground past the end of the wall +(issue #78). Synthetic scenes shaped like the build 4.1 field runs; no real home data.""" + +from helpers import golden_rules, observed_band, pads_ground, run, shared_fixture + +# Test settings, not Base policy. +END = 12.0 # the wall's right end, a limit ("Something blocks it") + + +def spot_beside_a_limit_end() -> dict: + """The meter wall ends at a limit 12 ft right of the meter; the only lawn pad runs up to that + end, and the wall and the ground were seen only up to 5 ft right of the meter.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [END, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, END] + raw["ground"] = pads_ground([(9, END)], hi=END + 20) + raw["coverage"]["ends"]["right"] = {"kind": "limit"} + observed_band(raw, "wall", [(-40, 5)]) + observed_band(raw, "ground", [(-40, 5)]) + return raw + + +def band_requests(result: dict, band: str) -> list[dict]: + return [m for m in result["missing_evidence"] if m.get("band") == band] + + +def test_no_wall_request_runs_past_a_limit_end() -> None: + # Before: the gas and opening clearances' 3 ft radius asked for the wall to 3 ft past the + # end, where there is no wall, and the app dropped the whole request. + result = run(spot_beside_a_limit_end()) + assert result["spot"]["span_ft"][1] > END - 1 # the spot stands beside the end + walls = band_requests(result, "wall") + assert walls, result["missing_evidence"] + assert all(m["span_ft"][1] <= END + 1e-6 for m in walls), walls + # The request is clipped at the end, not dropped. + assert max(m["span_ft"][1] for m in walls) >= END - 1e-6 + assert not any("past the" in m["message"] for m in walls) + + +def test_no_facing_or_overhead_request_runs_past_a_limit_end() -> None: + # The gap in front and the space overhead are asked for over the battery's stretch widened + # by the wall's error, which beside the end reaches past it: the spot ends 0.4 ft short of + # the end, and the wall is known to 1 ft. + raw = spot_beside_a_limit_end() + raw["walls"][0]["plus_minus_ft"] = 1.0 + raw["ground"] = pads_ground([(10, END + 5)], hi=END + 20) + observed_band(raw, "facing", [(-40, 5)]) + observed_band(raw, "overhead", [(-40, 5)]) + result = run(raw) + assert result["spot"]["span_ft"][1] > END - 1 + asked = [m for band in ("facing", "overhead") for m in band_requests(result, band)] + assert {m["band"] for m in asked} == {"facing", "overhead"}, result["missing_evidence"] + assert all(m["span_ft"][1] <= END + 1e-6 for m in asked), asked + + +def test_ground_past_a_limit_end_is_still_asked_for_and_says_why() -> None: + result = run(spot_beside_a_limit_end()) + (ground,) = band_requests(result, "ground") + assert ground["span_ft"][1] > END + 1 # an AC unit behind the fence still counts + assert "Part of it is past the right end" in ground["message"] + assert "to check for" in ground["message"] and "an AC unit" in ground["message"] + + +def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 5), (15, 40)]) + ground = band_requests(run(raw, golden_rules()), "ground") + assert ground and not any("past the" in m["message"] for m in ground) From f8e85965755645d6320ec84ce6e21888bc32682b Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:21:15 -0500 Subject: [PATCH 02/12] server: don't let placeholder distances size the view requests _missing_json merged every UNSURE check's unseen views per band and asked for the deepest, so the pool's 10 ft and the driveway's 5 ft, both placeholders with no public source, set the ground request: 14-16 ft out over the whole wall in every 4.1 field run, which nobody could show. A clearance whose distance is a placeholder still in effect now asks for no view (Check.asks_for_views). It still reads the area and stays UNSURE until it is seen, and the summary names it for a person rather than counting it among the checks more views settle. A value the private rules set counts as real even though the public flag merges under it, so the requests come back under those rules. Scoped to check_clearance (pool, driveway). headroom and wall_equipment_above are placeholders too but keep asking: headroom is the only check that asks for the overhead band, which the app answers with a tilt-up, and wall_equipment_above's views lie inside the gas check's. The tests that pinned a pool-sized request now use real pool and driveway values. Part of #75. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 2 + server/solver.py | 29 ++++++++-- server/tests/test_capture_contract.py | 16 +++++- server/tests/test_coderabbit_review.py | 7 ++- server/tests/test_policy_and_auth.py | 11 ++-- server/tests/test_requests.py | 80 +++++++++++++++++++++++++- server/tests/test_s4_round.py | 14 ++++- 7 files changed, 138 insertions(+), 21 deletions(-) diff --git a/server/README.md b/server/README.md index 111e43069..01a1edc0e 100644 --- a/server/README.md +++ b/server/README.md @@ -111,6 +111,8 @@ Ends and corners: 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). +A clearance whose distance is a placeholder (under the public rules `pool_clearance` and `drive_clearance`) asks for no view: its number would set how far out the ground must be shown (issue #75). It still reads the coverage above and stays UNSURE until that is seen, and the summary names it for a person. A value the private rules set counts as real, so under them its requests come back. + ## 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/solver.py b/server/solver.py index ba9f9f7f6..c8a07698f 100644 --- a/server/solver.py +++ b/server/solver.py @@ -85,6 +85,10 @@ class Check: 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[View]] | None = None + # Whether missing_evidence asks a homeowner for its unseen views. A clearance whose distance + # is a placeholder doesn't: that number would set how far out the ground must be shown + # (issue #75). The check still reads the area; a person settles what nobody saw. + asks_for_views: bool = True # 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, ...] = () @@ -235,6 +239,7 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: self.scene = scene r: Rules = loaded.rules self.r = r + self.private_keys = loaded.private_keys self.W = r.battery.width_ft.value self.D = r.battery.depth_ft.value self.H = r.battery.height_ft.value @@ -283,6 +288,11 @@ def __init__(self, scene: Scene, loaded: LoadedRules) -> None: self._buffers: dict[tuple[int, float], Geometry] = {} self._ground_error = max((g.plus_minus for g in scene.ground), default=0.0) + def _placeholder(self, rule_key: str, rule: Value) -> bool: + """Whether a rule's value is a public placeholder still in effect. A value the private + file sets is real whether or not the file clears the flag it merges over.""" + return rule.placeholder and not _is_private((rule_key,), self.private_keys) + # --- geometry helpers ---------------------------------------------------------------------- def footprint(self, piece: Piece, s0: float) -> Polygon: @@ -477,6 +487,7 @@ def check_clearance( reads the wall band.""" t = rule.value c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") + c.asks_for_views = not self._placeholder(rule_key, rule) worst_key: tuple[int, float] | None = None for name, geom, err, counts in items: if counts is False: @@ -1429,6 +1440,8 @@ 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[View, str]]] = {} for chk in c.checks: + if not chk.asks_for_views: + continue for view in chk.all_missing(): if view.band != "ground": # Only the ground is asked for past an end mark: past a limit end there is no @@ -1637,14 +1650,18 @@ def past_end_requests() -> list[dict[str, Any]]: reasons.append(unexplored_reason) missing += past_end_requests() spot_at = where((best.s0 + best.s1) / 2) - unseen = [c for c in best.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + unseen = [ + c + for c in best.checks + if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.asks_for_views + ] if unseen: - # Only unseen checks are settled by more views; the rest are named for a person, as in - # "A person needs to check the best spot" (issue #45, #50's wording). + # Only unseen checks are settled by the views asked for; the rest, including unseen + # ones on a placeholder distance (issue #75), are named for a person, as in "A person + # needs to check the best spot" (issue #45, #50's wording). + asked = {c.id for c in unseen} rest = [ - c.label.lower() - for c in best.checks - if c.outcome == UNSURE and c.unsure_cause != "unobserved" + c.label.lower() for c in best.checks if c.outcome == UNSURE and c.id not in asked ] summary = ( f"More views are needed around the best spot, {spot_at}: " diff --git a/server/tests/test_capture_contract.py b/server/tests/test_capture_contract.py index e7ca3453b..abc9eddf0 100644 --- a/server/tests/test_capture_contract.py +++ b/server/tests/test_capture_contract.py @@ -7,7 +7,7 @@ import jsonschema from helpers import D, at_start, observed_band, parsed, shared_fixture -from test_s4_round import PUBLIC, answer +from test_s4_round import PUBLIC, REAL_POOL_AND_DRIVE, answer from scene import COVERAGE_TOLERANCE_FT from solver import PASS, UNSURE, evaluate_start @@ -100,18 +100,28 @@ def shallow_ground() -> dict: def test_a_ground_request_names_how_far_out() -> None: - result = answer(shallow_ground()) + result = answer(shallow_ground(), REAL_POOL_AND_DRIVE) 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) + # Under the public rules the pool's 10 ft is a placeholder, and the gas and AC clearances' + # 3 ft size the request instead (issue #75). + result = answer(shallow_ground()) + request = next(m for m in result["missing_evidence"] if m.get("band") == "ground") + assert D + 3.0 <= request["out_ft"] < D + 5.0 def test_supplying_the_ground_request_settles_it() -> None: raw = shallow_ground() - after = answer(supplied(raw, answer(raw))) + after = answer(supplied(raw, answer(raw, REAL_POOL_AND_DRIVE)), REAL_POOL_AND_DRIVE) unseen = [c["id"] for c in after["checks"] if c.get("unsure_cause") == "unobserved"] assert unseen == [] + # Under the public rules only the checks on a placeholder distance, which ask for nothing, + # stay unseen. + after = answer(supplied(raw, answer(raw))) + unseen = [c["id"] for c in after["checks"] if c.get("unsure_cause") == "unobserved"] + assert unseen == ["drive_clearance", "pool_clearance"] # --- a corner the homeowner marks ----------------------------------------------------------------- diff --git a/server/tests/test_coderabbit_review.py b/server/tests/test_coderabbit_review.py index 3592d10e3..6ee6a3450 100644 --- a/server/tests/test_coderabbit_review.py +++ b/server/tests/test_coderabbit_review.py @@ -100,7 +100,12 @@ def test_capturing_what_missing_evidence_asks_for_settles_coverage(raw: dict) -> 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"] + # Checks on a placeholder distance ask for nothing (issue #75); a person settles those. + unseen = [ + c.id + for c in after.checks + if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.asks_for_views + ] assert unseen == [], (unseen, result["missing_evidence"]) diff --git a/server/tests/test_policy_and_auth.py b/server/tests/test_policy_and_auth.py index 6e51f4d91..95d0ff5da 100644 --- a/server/tests/test_policy_and_auth.py +++ b/server/tests/test_policy_and_auth.py @@ -48,12 +48,11 @@ def test_each_example_gets_its_decision_under_the_demo_policy(name: str, decisio 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_unsure_example_asks_for_more() -> None: + # Only the placeholder pool and driveway distances leave ground unseen around its spot, and + # those ask for no view (issue #75); walking on past the corner is still asked for. + 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_requests.py b/server/tests/test_requests.py index 203a02cc1..6d7432d78 100644 --- a/server/tests/test_requests.py +++ b/server/tests/test_requests.py @@ -1,7 +1,21 @@ """What missing_evidence asks a homeowner for: nothing but ground past the end of the wall -(issue #78). Synthetic scenes shaped like the build 4.1 field runs; no real home data.""" +(issue #78), and no view whose size only a placeholder distance sets (issue #75). Synthetic scenes +shaped like the build 4.1 field runs; no real home data.""" -from helpers import golden_rules, observed_band, pads_ground, run, shared_fixture +import pytest +from helpers import ( + GOLDEN_RULES, + D, + check, + golden_rules, + observed_band, + pads_ground, + run, + shared_fixture, +) + +from rules import deep_merge, public_rules_dict, rules_from_dict +from solver import UNSURE # Test settings, not Base policy. END = 12.0 # the wall's right end, a limit ("Something blocks it") @@ -59,6 +73,8 @@ def test_ground_past_a_limit_end_is_still_asked_for_and_says_why() -> None: assert ground["span_ft"][1] > END + 1 # an AC unit behind the fence still counts assert "Part of it is past the right end" in ground["message"] assert "to check for" in ground["message"] and "an AC unit" in ground["message"] + # The pool's distance is a placeholder in these rules, so it doesn't ask (issue #75). + assert "pool" not in ground["message"] def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: @@ -66,3 +82,63 @@ def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: observed_band(raw, "ground", [(-40, 5), (15, 40)]) ground = band_requests(run(raw, golden_rules()), "ground") assert ground and not any("past the" in m["message"] for m in ground) + + +# --- #75: placeholder distances don't size the requests ------------------------------------------ + + +def ground_seen_out_to(out: float) -> dict: + """The shared fixture with the ground in front of the whole wall seen only `out` ft out.""" + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 40)], out=out) + return raw + + +def test_a_band_only_placeholder_checks_need_raises_no_request() -> None: + # Ground seen 6 ft out covers the 3 ft gas and AC clearances in front of the 22 in deep + # battery, but not the placeholder driveway (5 ft) and pool (10 ft) ones. Before: "Show the + # ground ... at least 11 ft 10 in out from the wall", sized by the pool's placeholder alone. + result = run(ground_seen_out_to(6)) + assert [m for m in result["missing_evidence"] if m["kind"] == "band"] == [] + for check_id in ("drive_clearance", "pool_clearance"): + placeholder = check(result, check_id) + assert (placeholder["outcome"], placeholder["unsure_cause"]) == (UNSURE, "unobserved") + assert placeholder["rule"]["placeholder"] is True + unsure = next(r for r in result["reasons"] if r["code"] == "unsure_checks") + assert {"drive_clearance", "pool_clearance"} <= set(unsure["checks"]) + # Nothing is asked for, so the summary sends them to a person instead of asking for views. + assert result["summary"].startswith("A person needs to check the best spot"), result["summary"] + assert "distance from a pool" in result["summary"] + + +def test_a_real_rules_depth_is_kept() -> None: + # Ground seen 4 ft out: the gas and AC clearances still ask for theirs, 3 ft past the + # battery's front, and nothing deeper. + result = run(ground_seen_out_to(4)) + (ground,) = band_requests(result, "ground") + assert D + 3 - 1e-6 <= ground["out_ft"] <= D + 3 + 0.05, ground + assert {"ac_clearance", "gas_clearance"} <= set(ground["checks"]) + assert not {"drive_clearance", "pool_clearance"} & set(ground["checks"]), ground + + +@pytest.mark.parametrize("private", [False, True], ids=["flag-cleared", "private-file"]) +def test_a_real_pool_rule_brings_its_request_back(private: bool) -> None: + # A pool value that is real: one whose rules clear the flag, or one a private file sets, + # which is real although the public flag merges under it. + pool: dict = {"value": 10.0, "source": "test"} + if not private: + pool["placeholder"] = False + merged = deep_merge( + deep_merge(public_rules_dict(), GOLDEN_RULES), {"clearances": {"pool_ft": pool}} + ) + loaded = ( + rules_from_dict(merged, ("public", "private"), frozenset({"clearances.pool_ft"})) + if private + else rules_from_dict(merged) + ) + assert loaded.rules.clearances.pool_ft.placeholder is private + result = run(ground_seen_out_to(6), loaded) + (ground,) = band_requests(result, "ground") + assert ground["checks"] == ["pool_clearance"] + assert ground["out_ft"] >= D + 10 - 1e-6 + assert result["summary"].startswith("More views are needed"), result["summary"] diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index 560c7fb1a..6b8a5384f 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -8,10 +8,18 @@ from hypothesis import strategies as st import solver -from rules import LoadedRules, public_rules_dict, rules_from_dict +from rules import LoadedRules, deep_merge, public_rules_dict, rules_from_dict from solver import FAIL, PASS, UNSURE, at_least, evaluate_start, solve PUBLIC = rules_from_dict(public_rules_dict()) +# The public rules with real (not placeholder) pool and driveway distances, which then ask for the +# ground they read (issue #75). Test settings, not Base policy. +REAL_POOL_AND_DRIVE = rules_from_dict( + deep_merge( + public_rules_dict(), + {"clearances": {k: {"placeholder": False} for k in ("pool_ft", "drive_ft")}}, + ) +) # Ground captured in answer to a request is seen this far out: past every clearance's reach. FAR_FT = 40.0 @@ -131,7 +139,7 @@ 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) + result = answer(raw, REAL_POOL_AND_DRIVE) 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") @@ -140,7 +148,7 @@ def test_pool_clearance_needs_the_ground_past_a_limit_end() -> None: def test_showing_the_ground_past_a_limit_end_settles_it() -> None: raw = limit_end_near_the_spot() - after = answer(captured(raw, answer(raw))) + after = answer(captured(raw, answer(raw, REAL_POOL_AND_DRIVE)), REAL_POOL_AND_DRIVE) pool = next(c for c in after["checks"] if c["id"] == "pool_clearance") assert pool["outcome"] == PASS From a22c2d45351c202cff91defe9638197b5e87eb52 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:33:10 -0500 Subject: [PATCH 03/12] server: name things for the homeowner, not by id, and drop the notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasons were built from the ids in `subject`: "objects[1] ac is 3 ft 0 in (± 6 ft 0 in) from the battery", "facing[1] ... may or may not be over the battery", "ground[0] drive", and the route's blockers. The unseen reason hardcoded "a {noun}" ("a AC unit"), and solve() appended the demo-rules notice to the summary the app shows. - `subject` stays the id (siteplan keys geometry by it). Reasons name the thing by what it is and roughly where: "the AC unit about 13 ft right of the meter", "what faces the wall about 6 ft right of the meter", "the stretch with no wall about 3 ft right of the meter", "the driveway". check_clearance's items carry the description. - _a() picks the article ("an AC unit", "an electrical box"). - Check labels keep their initialisms inside a sentence ("distance from AC units"), and the reject summary names checks by label, not id. - The summary no longer ends with policy.notice; the notice stays in policy.notice for the team. test_homeowner_text scans every answer and every weighed position's reasons over the examples, scenes that reach each kind of reason, and random cluttered scenes: no id, no underscore, no "a AC", no notice in the summary. Fixes #74. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/solver.py | 169 ++++++++++++++++------- server/tests/test_field_run_2.py | 2 +- server/tests/test_homeowner_text.py | 195 +++++++++++++++++++++++++++ server/tests/test_policy_and_auth.py | 5 +- server/tests/test_s4_round.py | 4 +- 6 files changed, 329 insertions(+), 50 deletions(-) create mode 100644 server/tests/test_homeowner_text.py diff --git a/server/README.md b/server/README.md index 01a1edc0e..f2783825a 100644 --- a/server/README.md +++ b/server/README.md @@ -127,8 +127,10 @@ A clearance whose distance is a placeholder (under the public rules `pool_cleara | `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's `subject` is the id of what decided it (`objects[1] ac`, `ground[0] drive`, `facing[2]`), for code. Its `reason`, `summary` and every request's `message` are shown to the homeowner and name things by what they are and roughly where: "the AC unit about 14 ft right of the meter". + 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 -`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`. +`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 in `policy.notice`: "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." `summary` is for the homeowner and leaves the notice out (issue #74). `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/solver.py b/server/solver.py index c8a07698f..6f897a6be 100644 --- a/server/solver.py +++ b/server/solver.py @@ -207,6 +207,50 @@ def where(s: float) -> str: return f"{format_ft_in(abs(s))} {'left' if s < 0 else 'right'} of the meter" +# Homeowner text names things by what they are and roughly where, never by the ids in `subject` +# ("objects[1] ac"; issue #74). Object and ground types read with underscores as spaces, except: +_NOUNS = { + "ac": "AC unit", + "elec_box": "electrical box", + "drive": "driveway", +} + + +def _noun(kind: str) -> str: + return _NOUNS.get(kind, kind.replace("_", " ")) + + +def _a(noun: str) -> str: + """The noun with its indefinite article: "an AC unit", "a gas meter". An initialism takes + "an" when its first letter is said with a vowel sound.""" + first = noun.split()[0] + vowel = first[0] in "AEFHILMNORSX" if first.isupper() else first[0].lower() in "aeiou" + return f"{'an' if vowel else 'a'} {noun}" + + +def _about(s: float) -> str: + """Roughly where along the walls s is, to tell things apart: "about 14 ft right of the + meter". Positions carry error, so whole feet.""" + feet = int(abs(s) + 0.5) + if feet == 0: + return "next to the meter" + return f"about {feet} ft {'left' if s < 0 else 'right'} of the meter" + + +def _describe(o: SceneObject) -> str: + """An object as a homeowner finds it: "the AC unit about 14 ft right of the meter".""" + return f"the {_noun(o.type)} {_about((o.span[0] + o.span[1]) / 2)}" + + +def _sentence(text: str) -> str: + return text[:1].upper() + text[1:] + + +def _phrase(label: str) -> str: + """A check's label inside a sentence: "distance from AC units", keeping the initialism.""" + return label[:1].lower() + label[1:] + + @dataclass class Route: outcome: str @@ -325,6 +369,13 @@ def _covered(self, fp: Polygon, unobserved: Geometry, radius: float) -> bool: return False return True + @staticmethod + def _items( + objs: list[SceneObject], counts: Callable[[SceneObject], bool | None] = lambda o: True + ) -> list[tuple[str, str, Geometry, float, bool | None]]: + """check_clearance's items for scene objects.""" + return [(o.label, _describe(o), o.geom, o.plus_minus, counts(o)) for o in objs] + # --- checks -------------------------------------------------------------------------------- def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: @@ -394,7 +445,9 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: 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." + c.reason = ( + f"The footprint stands on {_a(_noun(g.type))}, which is not an allowed surface." + ) return c if not self._covered(fp, self.unobserved_ground, ew): c.outcome, c.unsure_cause = UNSURE, "unobserved" @@ -476,20 +529,22 @@ def check_clearance( rule: Value, piece: Piece, fp: Polygon, - items: list[tuple[str, Geometry, float, bool | None]], + items: list[tuple[str, 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. - `wall_height` is how high up the wall the face must have been seen, for a check that - reads the wall band.""" + """Minimum plan distance from the footprint to each item. `items` holds (subject, + description, geometry, error, counts): the subject is the id the answer reports, the + description how its reason names the item, and 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") c.asks_for_views = not self._placeholder(rule_key, rule) worst_key: tuple[int, float] | None = None - for name, geom, err, counts in items: + what = "" + for name, description, geom, err, counts in items: if counts is False: continue d = fp.distance(geom) @@ -503,9 +558,10 @@ def check_clearance( 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 + what = _sentence(description) if c.outcome == FAIL: c.reason = ( - f"{c.subject} is {ft(c.measured or 0)} (± {ft(c.plus_minus or 0)}) from the " + f"{what} 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 @@ -522,12 +578,12 @@ def check_clearance( 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 " + f"{what} 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"{what} 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." ) c.missing_later = missing_later @@ -536,7 +592,7 @@ def check_clearance( c.outcome, c.unsure_cause = UNSURE, "unobserved" c.missing_later = missing_later c.reason = ( - f"Not everything within {ft(t)} of the battery was seen, so a {noun} could " + f"Not everything within {ft(t)} of the battery was seen, so {_a(noun)} could " "hide there." ) return c @@ -580,6 +636,7 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: comparison="at_least", ) worst_key: tuple[int, float] | None = None + what = "" for o in self.equipment: 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 @@ -590,14 +647,15 @@ def check_along_wall(self, piece: Piece, s0: float, s1: float) -> Check: 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 + what = _sentence(_describe(o)) if c.outcome == FAIL: - c.reason = f"{c.subject} is on the wall directly above the battery." + c.reason = f"{what} 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) if c.outcome == UNSURE: c.unsure_cause = "margin" - c.reason = f"{c.subject} ends within measurement error of the battery's edge." + c.reason = f"{what} 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." @@ -663,6 +721,7 @@ def check_measured( # The lowest value surely under the battery, and whether the deciding entry only might be. surely: tuple[float, float] | None = None only_maybe = False + what = "" 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 @@ -686,12 +745,16 @@ def check_measured( 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}]" + at = _about((m.span[0] + m.span[1]) / 2) + what = ( + f"what is overhead {at}" if band == "overhead" else f"what faces the wall {at}" + ) 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 and only_maybe: - self._report_range(c, surely, wall_error, noun) + self._report_range(c, what, 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." @@ -726,7 +789,7 @@ def _seen_clear(self, band: str, entries: list[Measured], s0: float, s1: float) @staticmethod def _report_range( - c: Check, surely: tuple[float, float] | None, wall_error: float, noun: str + c: Check, what: str, 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 @@ -736,8 +799,8 @@ def _report_range( 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" + f"{_sentence(what)} ({ft(low)}) may or may not be over the battery: its end is " + f"within the 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. @@ -756,10 +819,11 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C 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) + # Each crossing's "what" names it in the reasons; the answer reports only its subject. crossings: list[dict[str, Any]] = [] detours: list[dict[str, Any]] = [] effects: list[str] = [] - unknown: list[str] = [] + unknown: list[dict[str, Any]] = [] small_gaps: list[str] = [] for gap in self.scene.gaps: if min(hi, gap.s1) - max(lo, gap.s0) > EPS: @@ -769,6 +833,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C crossings.append( { "subject": "stretch with no wall", + "what": f"the stretch with no wall {_about((gap.s0 + gap.s1) / 2)}", "span_ft": [gap.s0, gap.s1], "effect": "fail" if clear else "review", } @@ -780,7 +845,7 @@ 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] = [] + maybe_blockers: list[dict[str, Any]] = [] for o in self.route_objects: if path_line is None: continue @@ -798,17 +863,18 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C standoff = o.geom.distance(path_line) - o.plus_minus - piece.plus_minus if effect in ("detour", "allow") and standoff > _MEASURE_EPS: continue + crossing = {"subject": o.label, "what": _describe(o), "span_ft": list(o.span)} 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"}) + maybe_blockers.append(crossing) + crossings.append({**crossing, "effect": "review"}) continue - crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": effect}) + crossings.append({**crossing, "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) + unknown.append(crossing) 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. @@ -848,21 +914,22 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C ) 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)}." + blockers = [x for x in crossings if x["effect"] == "fail"] + path.subject = blockers[0]["subject"] + path.reason = f"The cable would have to cross {', '.join(x['what'] for x in blockers)}." elif maybe_blockers: path.outcome, path.unsure_cause = UNSURE, "margin" - path.subject = maybe_blockers[0] + path.subject = maybe_blockers[0]["subject"] path.reason = ( - f"{path.subject} ends within measurement error of the cable's route, so it may be " - "in the way." + f"{_sentence(maybe_blockers[0]['what'])} 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") + review = next(x for x in crossings if x["effect"] == "review") + path.subject = review["subject"] path.reason = ( - f"The cable would route past {path.subject}, which the policy sends to a person." + f"The cable would route past {review['what']}, which the policy sends to a person." ) elif small_gaps: path.outcome, path.unsure_cause = UNSURE, "margin" @@ -873,9 +940,10 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C ) elif unknown: path.outcome, path.unsure_cause = UNSURE, "unknown_attribute" - path.subject = unknown[0] + path.subject = unknown[0]["subject"] path.reason = ( - f"The height of {unknown[0]} was not recorded, so the detour around it is unknown." + f"The height of {unknown[0]['what']} was not recorded, so the detour around it is " + "unknown." ) elif missing: path.outcome, path.unsure_cause = UNSURE, "unobserved" @@ -961,7 +1029,7 @@ 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], + self._items(self.gas), # Gas meters hang on the wall as well as standing on the ground. "ground+wall", "gas meter or pipe", @@ -974,7 +1042,7 @@ 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], + self._items(self.ac), "ground", "AC unit", ), @@ -987,7 +1055,7 @@ 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], + self._items(self.batteries), "ground+wall", "battery", self.wall_height["above"], @@ -1003,12 +1071,20 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.drive_ft, piece, fp, + # A patch is an area, whose middle says little about where it comes near the + # battery, so it is named without a place. [ - (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True) + ( + f"ground[{g.index}] {g.type}", + f"the {_noun(g.type)}", + g.polygon, + g.plus_minus, + True, + ) for g in self.drives ], "ground", - "drivable surface", + "driveway", ), self.check_clearance( "pool_clearance", @@ -1017,7 +1093,7 @@ 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], + self._items(self.pool), "ground", "pool", ), @@ -1028,7 +1104,7 @@ 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], + self._items(self.openings, self._opening_counts), "wall", "door or window", self.wall_height["openings"], @@ -1627,7 +1703,7 @@ def past_end_requests() -> list[dict[str, Any]]: 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] + labels = [_phrase(c.label) for c in best.checks if c.outcome == UNSURE] reasons.append( { "code": "unsure_checks", @@ -1661,7 +1737,7 @@ def past_end_requests() -> list[dict[str, Any]]: # needs to check the best spot" (issue #45, #50's wording). asked = {c.id for c in unseen} rest = [ - c.label.lower() for c in best.checks if c.outcome == UNSURE and c.id not in asked + _phrase(c.label) for c in best.checks if c.outcome == UNSURE and c.id not in asked ] summary = ( f"More views are needed around the best spot, {spot_at}: " @@ -1711,9 +1787,11 @@ def past_end_requests() -> list[dict[str, Any]]: reasons.append(policy_reason) decision = "reject" if can_reject else "manual_review" if decision == "reject": + # Named by their labels, not their ids (issue #74). + names = {c.id: _phrase(c.label) for c in best.checks} if best else {} summary = ( "No spot within reach works: every spot fails " - + (", ".join(t.replace("_", " ") for t in top[:3]) or "the checks") + + ("; ".join(names.get(t, t.replace("_", " ")) for t in top[:3]) or "the checks") + "." ) elif open_ends: @@ -1727,8 +1805,9 @@ def past_end_requests() -> list[dict[str, Any]]: return { "schema_version": SCHEMA_VERSION, "decision": decision, - # 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, + # The summary is shown to the homeowner; whose rules decided it is in policy.notice, for + # the team (issue #74). + "summary": summary, "reasons": reasons, "policy": { "id": r.policy.id, diff --git a/server/tests/test_field_run_2.py b/server/tests/test_field_run_2.py index 91d98967c..98295019d 100644 --- a/server/tests/test_field_run_2.py +++ b/server/tests/test_field_run_2.py @@ -80,7 +80,7 @@ def test_the_summary_counts_unseen_checks_and_names_the_rest() -> None: ] result = answer(raw) others = [ - c["label"].lower() + c["label"][:1].lower() + c["label"][1:] # "distance from AC units" for c in result["checks"] if c["outcome"] == UNSURE and c["unsure_cause"] != "unobserved" ] diff --git a/server/tests/test_homeowner_text.py b/server/tests/test_homeowner_text.py new file mode 100644 index 000000000..fbaac5945 --- /dev/null +++ b/server/tests/test_homeowner_text.py @@ -0,0 +1,195 @@ +"""The text a homeowner reads names things by what they are and roughly where, never by the ids in +`subject`, and the summary leaves the demo-rules notice to `policy.notice` (issue #74). Every +scene here is synthetic.""" + +import json +import re +from pathlib import Path + +import pytest +from helpers import at_start, pads_ground, rect, run, shared_fixture +from hypothesis import given, settings +from test_codex_review import two_walls +from test_field_run_2 import window_past_the_end +from test_main_review import door +from test_properties import FAST, build, cluttered +from test_s4_round import PUBLIC, answer + +import solver +from rules import public_rules_dict, rules_from_dict +from scene import parse_scene +from solver import SOLVE_BUDGET_S, Solver, solve + +SERVER = Path(__file__).resolve().parents[1] +IDS = ("objects[", "ground[", "facing[", "overheads[") +# An indefinite article that doesn't fit the next word: "a AC unit", "a electrical box". +WRONG_ARTICLE = re.compile(r"\ba (AC|[aeio])") + + +def homeowner_text(result: dict) -> list[str]: + """Every string the app shows the homeowner from an answer.""" + return [ + result["summary"], + *(r["message"] for r in result["reasons"]), + *(c["reason"] for c in result["checks"]), + *(m["message"] for m in result["missing_evidence"]), + *(o["message"] for o in result["objects_not_used"]), + ] + + +def assert_readable(texts: list[str]) -> None: + for text in texts: + assert not any(i in text for i in IDS), text + assert "_" not in text, text # no check id ("route_length") or type ("gas_meter") either + assert not WRONG_ARTICLE.search(text), text + + +def assert_answer_readable(result: dict) -> None: + assert_readable(homeowner_text(result)) + notice = result["policy"]["notice"] + assert not notice or notice not in result["summary"], result["summary"] + + +# --- scenes that reach each kind of reason ------------------------------------------------------ + + +def ac_too_close_to_call() -> dict: + """Run 2's case: an AC unit 3.1 ft from the only spot, within the wall's 0.2 ft error.""" + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.2 + a = 6 + 31 / 12 + 3.1 + raw["objects"] = [ + { + "type": "ac", + "wall_id": "w1", + "span_ft": [a, a + 2], + "source": "tape", + "plus_minus_ft": 0, + "footprint": rect(a, a + 2, 0, 2), + } + ] + return raw + + +def ground_seen_close_in() -> dict: + """Run 3's case: the ground seen only 2 ft out, so an AC unit could hide nearby.""" + raw = shared_fixture() + for o in raw["coverage"]["observed"]: + if o["band"] == "ground": + o["out_ft"] = 2.0 + return raw + + +def vent_of_unknown_height() -> dict: + """A vent across the cable's route whose top was not recorded.""" + raw = shared_fixture() + raw["objects"] = [ + {"type": "vent", "wall_id": "w1", "span_ft": [2, 3], "source": "tape", "plus_minus_ft": 0} + ] + return raw + + +def on_the_driveway() -> dict: + """Driveway everywhere but a pad too narrow for the battery, which then stands on it.""" + raw = shared_fixture() + raw["ground"] = [ + {**g, "type": "drive"} if g["type"] == "deck" else g for g in pads_ground([(6, 7)]) + ] + return raw + + +SCENES = { + "ac too close to call": ac_too_close_to_call, + "ground seen close in": ground_seen_close_in, + "vent of unknown height": vent_of_unknown_height, + "on the driveway": on_the_driveway, + "door across the route": lambda: door([1.0, 4.0]), + "door that may reach the route": lambda: door([-3.4, -0.4]), + "gap between walls": lambda: two_walls(0.0), + "small gap between walls": lambda: two_walls(0.4), + "window past a limit end": lambda: window_past_the_end("limit"), + "window past an unexplored end": lambda: window_past_the_end("unexplored"), +} + + +@pytest.mark.parametrize("name", sorted(SCENES)) +def test_no_answer_shows_an_id_or_a_wrong_article(name: str) -> None: + raw = SCENES[name]() + assert_answer_readable(answer(raw)) + # Every position the solver weighed, not only the one it reports. + for c in Solver(parse_scene(raw, PUBLIC.rules), PUBLIC).candidates(SOLVE_BUDGET_S): + assert_readable([chk.reason for chk in c.checks]) + + +@pytest.mark.parametrize( + "path", + [*sorted((SERVER / "examples").glob("*.json")), SERVER / "tests/fixtures/example-scene.json"], + ids=lambda p: p.name, +) +def test_the_examples_read_cleanly(path: Path) -> None: + loaded = rules_from_dict(public_rules_dict()) + result = solve(parse_scene(json.loads(path.read_text()), loaded.rules), loaded) + assert_answer_readable(result) + + +@settings(max_examples=40, deadline=None, database=None) +@given(c=cluttered()) +def test_random_cluttered_scenes_read_cleanly(c: dict) -> None: + raw = build(c) + assert_answer_readable(run(raw, FAST)) + for cand in Solver(parse_scene(raw, FAST.rules), FAST).candidates(SOLVE_BUDGET_S): + assert_readable([chk.reason for chk in cand.checks]) + + +# --- the wording ------------------------------------------------------------------------------- + + +def test_an_object_is_named_by_what_and_where() -> None: + # Before: "objects[0] ac is 3 ft 1 in (± 0 ft 2 in) from the battery against a 3 ft 0 in + # rule: too close to call." + ac = at_start(ac_too_close_to_call(), 6.0, "ac_clearance", PUBLIC) + assert ac.subject == "objects[0] ac" # still the id, for code + assert ac.reason == ( + "The AC unit about 13 ft right of the meter is 3 ft 1 in (± 0 ft 2 in) from the battery " + "against a 3 ft 0 in rule: too close to call." + ) + + +def test_an_unseen_ac_unit_takes_an() -> None: + # Before: "... so a AC unit could hide there." + result = answer(ground_seen_close_in()) + ac = next(c for c in result["checks"] if c["id"] == "ac_clearance") + assert ac["reason"].endswith("so an AC unit could hide there.") + + +def test_the_summary_leaves_the_notice_to_the_policy() -> None: + result = answer(ground_seen_close_in()) + assert result["policy"]["notice"] + assert result["policy"]["notice"] not in result["summary"] + assert "not Base's" not in result["summary"] + + +@pytest.mark.parametrize( + ("noun", "expected"), + [ + ("AC unit", "an AC unit"), + ("gas meter or pipe", "a gas meter or pipe"), + ("electrical box", "an electrical box"), + ("door or window", "a door or window"), + ("battery", "a battery"), + ], +) +def test_the_article_fits_the_noun(noun: str, expected: str) -> None: + assert solver._a(noun) == expected + + +@pytest.mark.parametrize( + ("s", "expected"), + [ + (0.3, "next to the meter"), + (13.6, "about 14 ft right of the meter"), + (-2.5, "about 3 ft left of the meter"), + ], +) +def test_where_a_thing_is_reads_in_whole_feet(s: float, expected: str) -> None: + assert solver._about(s) == expected diff --git a/server/tests/test_policy_and_auth.py b/server/tests/test_policy_and_auth.py index 95d0ff5da..02f6eea57 100644 --- a/server/tests/test_policy_and_auth.py +++ b/server/tests/test_policy_and_auth.py @@ -43,9 +43,10 @@ def test_each_example_gets_its_decision_under_the_demo_policy(name: str, decisio result = decide(name) assert result["decision"] == decision assert result["policy"]["id"] == "demo" - # Every answer says whose rules decided it. + # Every answer says whose rules decided it, for the team; the homeowner's summary doesn't + # (issue #74). assert "not Base's" in result["policy"]["notice"] - assert "not Base's" in result["summary"] + assert "not Base's" not in result["summary"] def test_the_unsure_example_asks_for_more() -> None: diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index 6b8a5384f..41923ebac 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -185,7 +185,9 @@ def test_a_band_check_reports_numbers_that_give_its_outcome() -> None: 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 + assert facing.subject == "facing[1]" + # The reason names it for the homeowner, not by its id (issue #74). + assert facing.reason.startswith("What faces the wall about 6 ft right of the meter") @settings(max_examples=40, deadline=None) From c8d3c9d5d4575e8e52ef86052fdb9c48987fbf94 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 01:51:26 -0500 Subject: [PATCH 04/12] server: ask for more views only for checks a request names After the end clipping (#78), a check whose radius crosses a limit end stays unseen once every emitted request is met, so the summary kept saying "More views are needed" with nothing left to show. Only checks named in an emitted band request now count as needing views; every other unsure check is named for a person. Also from the review: ground types that are materials read without an article ("stands on gravel"), set-aside marks use the homeowner noun, and the past-end hint names "a gas meter" so it reads with one "or". Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 25 ++++++++++++++++++------- server/tests/test_homeowner_text.py | 17 +++++++++++++++++ server/tests/test_requests.py | 26 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/server/solver.py b/server/solver.py index 6f897a6be..2de8ab479 100644 --- a/server/solver.py +++ b/server/solver.py @@ -228,6 +228,15 @@ def _a(noun: str) -> str: return f"{'an' if vowel else 'a'} {noun}" +# Ground types that are materials rather than things, so they read without an article. +_MASS_NOUNS = frozenset({"concrete", "gravel", "mulch"}) + + +def _surface(kind: str) -> str: + """A ground type as what the battery stands on: "a driveway", "a deck", but "gravel".""" + return _noun(kind) if kind in _MASS_NOUNS else _a(_noun(kind)) + + def _about(s: float) -> str: """Roughly where along the walls s is, to tell things apart: "about 14 ft right of the meter". Positions carry error, so whole feet.""" @@ -446,7 +455,7 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: 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 {_a(_noun(g.type))}, which is not an allowed surface." + f"The footprint stands on {_surface(g.type)}, which is not an allowed surface." ) return c if not self._covered(fp, self.unobserved_ground, ew): @@ -1551,7 +1560,7 @@ def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: # What a view of the ground past a limit end looks for, by the check that asks for it. _LOOKS_FOR = { - "gas_clearance": "a gas meter or pipe", + "gas_clearance": "a gas meter", "ac_clearance": "an AC unit", "battery_clearance": "another battery", "drive_clearance": "a driveway", @@ -1726,15 +1735,17 @@ def past_end_requests() -> list[dict[str, Any]]: reasons.append(unexplored_reason) missing += past_end_requests() spot_at = where((best.s0 + best.s1) / 2) + # Only a check that an emitted view request names is settled by more views. The rest are + # named for a person, as in "A person needs to check the best spot" (issue #45, #50's + # wording): unseen checks on a placeholder distance (issue #75), and unseen checks whose + # remaining views lie past an end mark, which no request asks for (issue #78). + requested = {i for m in missing if m["kind"] == "band" for i in m["checks"]} unseen = [ c for c in best.checks - if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.asks_for_views + if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.id in requested ] if unseen: - # Only unseen checks are settled by the views asked for; the rest, including unseen - # ones on a placeholder distance (issue #75), are named for a person, as in "A person - # needs to check the best spot" (issue #45, #50's wording). asked = {c.id for c in unseen} rest = [ _phrase(c.label) for c in best.checks if c.outcome == UNSURE and c.id not in asked @@ -1827,7 +1838,7 @@ def past_end_requests() -> list[dict[str, Any]]: "object": f"objects[{index}] {kind}", "side": side, "message": ( - f"The {kind.replace('_', ' ')} marked {where(s)} is past the {side} end of " + f"The {_noun(kind)} marked {where(s)} is past the {side} end of " "the scan, where the wall may turn, so it was not used." ), } diff --git a/server/tests/test_homeowner_text.py b/server/tests/test_homeowner_text.py index fbaac5945..e48e8f914 100644 --- a/server/tests/test_homeowner_text.py +++ b/server/tests/test_homeowner_text.py @@ -193,3 +193,20 @@ def test_the_article_fits_the_noun(noun: str, expected: str) -> None: ) def test_where_a_thing_is_reads_in_whole_feet(s: float, expected: str) -> None: assert solver._about(s) == expected + + +@pytest.mark.parametrize( + ("kind", "expected"), + [("drive", "a driveway"), ("deck", "a deck"), ("lawn", "a lawn"), ("gravel", "gravel")], +) +def test_a_surface_takes_an_article_only_if_it_is_a_thing(kind: str, expected: str) -> None: + # Before: "stands on a gravel" under rules that leave gravel out of ground.allowed. + assert solver._surface(kind) == expected + + +def test_a_mark_set_aside_is_named_by_what_it_is() -> None: + # Before: "The elec box marked 17 ft 0 in right of the meter ...". + raw = window_past_the_end("unexplored") + raw["objects"][0]["type"] = "elec_box" + (aside,) = answer(raw)["objects_not_used"] + assert aside["message"].startswith("The electrical box marked "), aside["message"] diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py index 6d7432d78..2fbd279cd 100644 --- a/server/tests/test_requests.py +++ b/server/tests/test_requests.py @@ -73,6 +73,8 @@ def test_ground_past_a_limit_end_is_still_asked_for_and_says_why() -> None: assert ground["span_ft"][1] > END + 1 # an AC unit behind the fence still counts assert "Part of it is past the right end" in ground["message"] assert "to check for" in ground["message"] and "an AC unit" in ground["message"] + # One "or" between the alternatives; before: "a gas meter or pipe or an AC unit". + assert ground["message"].count(" or ") == 1, ground["message"] # The pool's distance is a placeholder in these rules, so it doesn't ask (issue #75). assert "pool" not in ground["message"] @@ -84,6 +86,30 @@ def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: assert ground and not any("past the" in m["message"] for m in ground) +def test_once_every_request_is_met_the_rest_goes_to_a_person() -> None: + # Round 2 of the gap loop: the homeowner shows exactly what round 1 asked for. The gas and + # opening clearances' radius still crosses the limit end, where no request reaches, so before + # the summary read "More views are needed ... 2 checks depend on areas the scan did not see" + # with nothing left to show. + raw = spot_beside_a_limit_end() + first = run(raw) + asked = [m for m in first["missing_evidence"] if m["kind"] == "band"] + assert asked, first["missing_evidence"] + raw["coverage"]["observed"] += [ + { + "band": m["band"], + "span_ft": m["span_ft"], + **({"out_ft": m["out_ft"]} if "out_ft" in m else {}), + } + for m in asked + ] + second = run(raw) + assert second["missing_evidence"] == [], second["missing_evidence"] + assert second["summary"].startswith("A person needs to check"), second["summary"] + still_unseen = [c for c in second["checks"] if c.get("unsure_cause") == "unobserved"] + assert still_unseen # left for a person, not asked for + + # --- #75: placeholder distances don't size the requests ------------------------------------------ From 7f5ddc43e777142fa21defaf70a9c7e0e219ff85 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 06:11:57 -0500 Subject: [PATCH 05/12] server: a ground request's depth leaves no speck inside a check's radius view_to_cover bisects to the depth at which the unseen area left falls to its bound, so a speck just under the bound always remains. At 1e-9 sq ft one sat where the gas and AC clearances' 3 ft arc meets the view's edge, inside their radius: capturing exactly the request left both unseen, with nothing more to ask for. test_coderabbit_review's property found it after the merge, once the placeholder pool and driveway stopped sizing the request (#75) and the request shrank to the gas and AC reach. The bound is now 1e-12 sq ft (the request there grows from 3.362958 to 3.363014 ft). The scene is pinned in test_requests. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 5 ++++- server/tests/test_requests.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/server/scene.py b/server/scene.py index ddeff1c19..9dcaa02cd 100644 --- a/server/scene.py +++ b/server/scene.py @@ -406,9 +406,12 @@ def view_to_cover( area = polygonal(region) + # The bisection stops where the unseen area left crosses this bound, so what is left is a + # speck just under it. A check reads any unseen point strictly inside its radius, and at + # 1e-9 sq ft a speck where an arc meets the view's edge was still inside it. 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 + 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, diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py index 101285ba0..0f8d00bc4 100644 --- a/server/tests/test_requests.py +++ b/server/tests/test_requests.py @@ -6,6 +6,7 @@ from helpers import ( GOLDEN_RULES, D, + at_start, check, golden_rules, observed_band, @@ -111,6 +112,37 @@ def test_once_every_request_is_met_the_rest_goes_to_a_person() -> None: assert still_unseen # left for a person, not asked for +def test_a_small_ground_request_settles_its_checks() -> None: + # Found by test_coderabbit_review's property once placeholder distances stopped sizing the + # ground request (#75): the request was the depth at which the unseen area left fell to + # 1e-9 sq ft, and that last speck, where the 3 ft arc meets the view's edge, lay inside the + # radius, so the gas and AC clearances stayed unseen with nothing left to ask for. + raw = shared_fixture() + raw["objects"] = [ + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [0.0, 1.0], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tape", + "plus_minus_ft": 0.05, + "footprint": [[0.0, 0], [1.0, 0], [1.0, 1], [0.0, 1]], + } + ] + observed_band(raw, "ground", [(-40, 11.15625), (12, 40)], out=5) + rules = golden_rules(clearances={"opening_ft": {"value": 1.0}}) + first = run(raw, rules) + (ground,) = band_requests(first, "ground") + assert {"ac_clearance", "gas_clearance"} <= set(ground["checks"]), ground + raw["coverage"]["observed"].append( + {"band": "ground", "span_ft": ground["span_ft"], "out_ft": ground["out_ft"]} + ) + for check_id in ground["checks"]: + after = at_start(raw, first["spot"]["span_ft"][0], check_id, rules) + assert after.unsure_cause != "unobserved", (check_id, after.reason) + + # --- #75: placeholder distances don't size the requests ------------------------------------------ From f6f6089b3844a16cd8c4e3f3122f53af2b1ebb3a Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 06:16:09 -0500 Subject: [PATCH 06/12] server: a request names only checks that capturing it settles After #78 a request for the wall, the gap in front of it or the space overhead stopped at a limit end but still named the check whose radius crossed the end. That stretch still counts as unseen, so capturing exactly the request left the check UNSURE. The app reads a request's `checks` as the checks it settles (ResultReading.evidenceIndex(settling:) on beta), and result.schema.json promises that supplying a request settles it. Beside a limit end the gas and opening clearances were named on the wall request, and facing_gap and headroom on theirs, and all four stayed unseen after the capture (Codex review of c8d3c9d). settled_by_views() now says whether views can settle a check: not one on a placeholder distance (#75), nor one whose view of the wall, gap or overhead reaches past a limit end by more than the coverage tolerance. Such a check asks for none of its views, so they neither size nor name a request, and the summary names it for a person from the first answer. Scene.coverable keeps the base's wall stretch past a limit end so that view shows up to be tested; views past an unexplored end are still clipped (walking on settles those). test_requests pins it beside a limit end for the wall and for facing and overhead: those checks aren't named, the wall request stays inside the end, and capturing every request leaves no named check unseen. The one-"or" hint is tested on _either directly, since the gas clearance no longer asks there. The one-round properties in test_final_review and test_coderabbit_review count checks by settled_by_views. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 +- server/scene.py | 14 ++- server/solver.py | 30 +++++-- server/tests/test_coderabbit_review.py | 10 ++- server/tests/test_final_review.py | 25 ++++-- server/tests/test_requests.py | 118 +++++++++++++++++-------- 6 files changed, 139 insertions(+), 62 deletions(-) diff --git a/server/README.md b/server/README.md index bef250a5b..7e68d229d 100644 --- a/server/README.md +++ b/server/README.md @@ -107,9 +107,9 @@ 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. Its request says so, and what the view looks for there. Requests for the wall, the gap in front of it and the space overhead stop at either end mark, since past a limit end there is none of them to show; a check whose radius crosses a limit end can then stay UNSURE for a person. +- `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. Its request says so, and what the view looks for there. Requests for the wall, the gap in front of it and the space overhead stop at either end mark. Past a limit end there is none of them to show, yet that stretch still counts as unseen, so a check that needs it there asks for no view at all and stays UNSURE for a person. -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). +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). Supplying every band request also settles every check they name in `checks` (a check can need more than one band); a check no view can settle is named by none. A clearance whose distance is a placeholder (under the public rules `pool_clearance` and `drive_clearance`) asks for no view: its number would set how far out the ground must be shown (issue #75). It still reads the coverage above and stays UNSURE until that is seen, and the summary names it for a person. A value the private rules set counts as real, so under them its requests come back. diff --git a/server/scene.py b/server/scene.py index 9dcaa02cd..0b9616ac2 100644 --- a/server/scene.py +++ b/server/scene.py @@ -451,19 +451,17 @@ def coverable_span(self) -> tuple[float, float]: def coverable(self, band: str) -> Geometry: """Where observing `band` can settle what is unseen: in front of the scanned walls, and - for the ground also past a limit end, where a pool or a driveway can still lie and a - camera pointed past the end from where the walk stopped sees it. The wall band stops at - both end marks: past a limit end there is no wall to show (issue #78). Past an - unexplored end the walls may turn any way, so no view settles it; only walking on does - (a past_end request).""" + 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). Only the ground is asked for past a limit + end: a check whose view of the wall reaches past one asks for none (issue #78, see + solver._missing_json), so the wall band keeps that stretch here to tell.""" key = f"coverable-{band}" if key not in self._cache: + lo, hi = self.coverable_span() if band == "ground": - lo, hi = self.coverable_span() self._cache[key] = self.band_polygon(lo, hi, self.reach_ft).difference(self.house()) else: - line = self.wall_line(self.s_min, self.s_max) - self._cache[key] = line.buffer(1e-6, cap_style="flat") + self._cache[key] = self.wall_line(lo, hi).buffer(1e-6, cap_style="flat") return self._cache[key] def unobserved_wall_lines(self, up_to: float | None = None) -> Geometry: diff --git a/server/solver.py b/server/solver.py index a99d107fb..09f761550 100644 --- a/server/solver.py +++ b/server/solver.py @@ -1612,17 +1612,37 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: } +def settled_by_views(chk: Check, scene: Scene) -> bool: + """Whether views can settle the check, so that requests ask for and name it. Not one on a + placeholder distance (issue #75), nor one whose view of the wall, the gap in front of it or + the space over it reaches past an end the walk marked as a limit, by more than the coverage + tolerance: there is no wall there to show (issue #78), but the stretch still counts as + unseen for the check. solve() names those for a person.""" + if not chk.asks_for_views: + return False + left, right = scene.end_kinds.get("left"), scene.end_kinds.get("right") + return not any( + v.band != "ground" + and ( + (left == "limit" and v.a < scene.s_min - COVERAGE_TOLERANCE_FT) + or (right == "limit" and v.b > scene.s_max + COVERAGE_TOLERANCE_FT) + ) + for v in chk.all_missing() + ) + + def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: by_band: dict[str, list[tuple[View, str]]] = {} for chk in c.checks: - if not chk.asks_for_views: + # A request names only checks that capturing it settles (result.schema.json), and a + # check it can't settle asks for none of its views, so none of them size a request. + if not settled_by_views(chk, scene): continue for view in chk.all_missing(): if view.band != "ground": - # Only the ground is asked for past an end mark: past a limit end there is no - # wall, gap in front of it or space over it to show, and past an unexplored end - # only walking on settles anything (issue #78). The check stays UNSURE for a - # person if what it needs lies there. + # Only the ground is asked for past an end mark: past an unexplored end only + # walking on settles the wall (a past_end request), and a view past a limit end + # by less than the tolerance is rounding. a, b = max(view.a, scene.s_min), min(view.b, scene.s_max) if b - a < COVERAGE_TOLERANCE_FT: continue diff --git a/server/tests/test_coderabbit_review.py b/server/tests/test_coderabbit_review.py index 6ee6a3450..9fb71a32f 100644 --- a/server/tests/test_coderabbit_review.py +++ b/server/tests/test_coderabbit_review.py @@ -10,7 +10,7 @@ from rules import load_rules from scene import SceneError -from solver import SOLVE_BUDGET_S, UNSURE, Solver, evaluate_start +from solver import SOLVE_BUDGET_S, UNSURE, Solver, evaluate_start, settled_by_views # --- missing_evidence names every unseen band ------------------------------------------------ @@ -99,12 +99,14 @@ def test_capturing_what_missing_evidence_asks_for_settles_coverage(raw: dict) -> (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) - # Checks on a placeholder distance ask for nothing (issue #75); a person settles those. + scene = parsed(captured, rules) + after = evaluate_start(scene, rules, exact) + # Checks views can't settle ask for nothing, and a person settles those: on a placeholder + # distance (issue #75) or needing the wall past a limit end (issue #78). unseen = [ c.id for c in after.checks - if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.asks_for_views + if c.outcome == UNSURE and c.unsure_cause == "unobserved" and settled_by_views(c, scene) ] assert unseen == [], (unseen, result["missing_evidence"]) diff --git a/server/tests/test_final_review.py b/server/tests/test_final_review.py index 579c51db3..6753561b4 100644 --- a/server/tests/test_final_review.py +++ b/server/tests/test_final_review.py @@ -8,7 +8,16 @@ from hypothesis import strategies as st from test_s4_round import PUBLIC, answer -from solver import FAIL, PASS, SOLVE_BUDGET_S, UNSURE, Solver, at_least, evaluate_start +from solver import ( + FAIL, + PASS, + SOLVE_BUDGET_S, + UNSURE, + Solver, + at_least, + evaluate_start, + settled_by_views, +) W = 31 / 12 @@ -127,10 +136,16 @@ def supplied_exactly(raw: dict, result: dict) -> dict: def unseen_at(raw: dict, s0: float, wall_id: str) -> list[str]: - """Checks still unseen that ask for views: those on a placeholder distance ask for none and - are left to a person (issue #75).""" - candidate = evaluate_start(parsed(raw, PUBLIC), PUBLIC, s0, wall_id) - return [c.id for c in candidate.checks if c.unsure_cause == "unobserved" and c.asks_for_views] + """Checks still unseen that views can settle. The rest ask for none and are left to a + person: those on a placeholder distance (issue #75) or needing the wall past a limit end + (issue #78).""" + scene = parsed(raw, PUBLIC) + candidate = evaluate_start(scene, PUBLIC, s0, wall_id) + return [ + c.id + for c in candidate.checks + if c.unsure_cause == "unobserved" and settled_by_views(c, scene) + ] def exact_start(raw: dict, result: dict) -> float: diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py index 0f8d00bc4..4e59a0296 100644 --- a/server/tests/test_requests.py +++ b/server/tests/test_requests.py @@ -2,6 +2,8 @@ (issue #78), and no view whose size only a placeholder distance sets (issue #75). Synthetic scenes shaped like the build 4.1 field runs; no real home data.""" +import copy + import pytest from helpers import ( GOLDEN_RULES, @@ -17,7 +19,7 @@ from rules import deep_merge, public_rules_dict, rules_from_dict from scene import COVERAGE_TOLERANCE_FT -from solver import UNSURE +from solver import _LOOKS_FOR, UNSURE, _either # Test settings, not Base policy. END = 12.0 # the wall's right end, a limit ("Something blocks it") @@ -40,32 +42,74 @@ def band_requests(result: dict, band: str) -> list[dict]: return [m for m in result["missing_evidence"] if m.get("band") == band] +def named(result: dict) -> set[str]: + """The checks the band requests name: those capturing them settles.""" + return {i for m in result["missing_evidence"] if m["kind"] == "band" for i in m["checks"]} + + +def unseen(result: dict) -> set[str]: + return {c["id"] for c in result["checks"] if c.get("unsure_cause") == "unobserved"} + + +def captured_exactly(raw: dict, result: dict) -> dict: + """The next round of the gap loop: the homeowner shows exactly what the answer asked for.""" + out = copy.deepcopy(raw) + out["coverage"]["observed"] += [ + { + "band": m["band"], + "span_ft": m["span_ft"], + **({"out_ft": m["out_ft"]} if "out_ft" in m else {}), + } + for m in result["missing_evidence"] + if m["kind"] == "band" + ] + return out + + +def facing_and_overhead_past_the_end() -> dict: + """The gap in front and the space overhead are needed over the battery's stretch widened by + the wall's error, which beside the end reaches past it: the spot ends 0.4 ft short of the + end, and the wall is known to 1 ft.""" + raw = spot_beside_a_limit_end() + raw["walls"][0]["plus_minus_ft"] = 1.0 + raw["ground"] = pads_ground([(10, END + 5)], hi=END + 20) + observed_band(raw, "facing", [(-40, 5)]) + observed_band(raw, "overhead", [(-40, 5)]) + return raw + + def test_no_wall_request_runs_past_a_limit_end() -> None: # Before: the gas and opening clearances' 3 ft radius asked for the wall to 3 ft past the # end, where there is no wall, and the app dropped the whole request. result = run(spot_beside_a_limit_end()) assert result["spot"]["span_ft"][1] > END - 1 # the spot stands beside the end walls = band_requests(result, "wall") + # The wall the other checks need beside the battery is still asked for. assert walls, result["missing_evidence"] assert all(m["span_ft"][1] <= END + 1e-6 for m in walls), walls - # The request is clipped at the end, not dropped. - assert max(m["span_ft"][1] for m in walls) >= END - 1e-6 assert not any("past the" in m["message"] for m in walls) +def test_a_check_that_needs_the_wall_past_a_limit_end_is_left_to_a_person() -> None: + # The gas and opening clearances need the wall past the end, which still counts as unseen, + # so no request can settle them. Before, a request clipped at the end still named them, + # and capturing it left both unseen (the Codex review of c8d3c9d). + result = run(spot_beside_a_limit_end()) + assert {"gas_clearance", "opening_clearance"} <= unseen(result) + assert not {"gas_clearance", "opening_clearance"} & named(result), result["missing_evidence"] + assert result["summary"].startswith("More views are needed"), result["summary"] + assert "a person also needs to check" in result["summary"] + assert "distance from gas equipment" in result["summary"] + + def test_no_facing_or_overhead_request_runs_past_a_limit_end() -> None: - # The gap in front and the space overhead are asked for over the battery's stretch widened - # by the wall's error, which beside the end reaches past it: the spot ends 0.4 ft short of - # the end, and the wall is known to 1 ft. - raw = spot_beside_a_limit_end() - raw["walls"][0]["plus_minus_ft"] = 1.0 - raw["ground"] = pads_ground([(10, END + 5)], hi=END + 20) - observed_band(raw, "facing", [(-40, 5)]) - observed_band(raw, "overhead", [(-40, 5)]) - result = run(raw) + result = run(facing_and_overhead_past_the_end()) assert result["spot"]["span_ft"][1] > END - 1 - asked = [m for band in ("facing", "overhead") for m in band_requests(result, band)] - assert {m["band"] for m in asked} == {"facing", "overhead"}, result["missing_evidence"] + # Their stretch runs past the end, so they are left to a person, not asked for. + assert {"facing_gap", "headroom"} <= unseen(result) + assert not {"facing_gap", "headroom"} & named(result), result["missing_evidence"] + assert band_requests(result, "facing") == band_requests(result, "overhead") == [] + asked = [m for m in result["missing_evidence"] if m["kind"] == "band" and m["band"] != "ground"] assert all(m["span_ft"][1] <= END + 1e-6 for m in asked), asked @@ -74,11 +118,16 @@ def test_ground_past_a_limit_end_is_still_asked_for_and_says_why() -> None: (ground,) = band_requests(result, "ground") assert ground["span_ft"][1] > END + 1 # an AC unit behind the fence still counts assert "Part of it is past the right end" in ground["message"] - assert "to check for" in ground["message"] and "an AC unit" in ground["message"] - # One "or" between the alternatives; before: "a gas meter or pipe or an AC unit". - assert ground["message"].count(" or ") == 1, ground["message"] - # The pool's distance is a placeholder in these rules, so it doesn't ask (issue #75). - assert "pool" not in ground["message"] + assert "to check for an AC unit" in ground["message"] + # The gas clearance needs the wall past the end too, so it asks for nothing, and the pool's + # distance is a placeholder in these rules (issue #75). + assert "gas" not in ground["message"] and "pool" not in ground["message"] + + +def test_the_past_end_hint_joins_its_alternatives_with_one_or() -> None: + # Before: "a gas meter or pipe or an AC unit". + looks_for = [_LOOKS_FOR[i] for i in ("gas_clearance", "ac_clearance", "pool_clearance")] + assert _either(looks_for) == "a gas meter, an AC unit or a pool" def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: @@ -88,28 +137,21 @@ def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: assert ground and not any("past the" in m["message"] for m in ground) -def test_once_every_request_is_met_the_rest_goes_to_a_person() -> None: - # Round 2 of the gap loop: the homeowner shows exactly what round 1 asked for. The gas and - # opening clearances' radius still crosses the limit end, where no request reaches, so before - # the summary read "More views are needed ... 2 checks depend on areas the scan did not see" - # with nothing left to show. - raw = spot_beside_a_limit_end() +@pytest.mark.parametrize( + "scene", [spot_beside_a_limit_end, facing_and_overhead_past_the_end], ids=["wall", "bands"] +) +def test_capturing_the_requests_settles_every_check_they_name(scene) -> None: + # Round 2 of the gap loop beside a limit end. What the checks left to a person need lies + # past the end, so before the summary read "More views are needed ... 2 checks depend on + # areas the scan did not see" with nothing left to show. + raw = scene() first = run(raw) - asked = [m for m in first["missing_evidence"] if m["kind"] == "band"] - assert asked, first["missing_evidence"] - raw["coverage"]["observed"] += [ - { - "band": m["band"], - "span_ft": m["span_ft"], - **({"out_ft": m["out_ft"]} if "out_ft" in m else {}), - } - for m in asked - ] - second = run(raw) + assert named(first), first["missing_evidence"] + second = run(captured_exactly(raw, first)) + assert not named(first) & unseen(second), second["checks"] assert second["missing_evidence"] == [], second["missing_evidence"] assert second["summary"].startswith("A person needs to check"), second["summary"] - still_unseen = [c for c in second["checks"] if c.get("unsure_cause") == "unobserved"] - assert still_unseen # left for a person, not asked for + assert unseen(second) # left for a person, not asked for def test_a_small_ground_request_settles_its_checks() -> None: From 2f9326353c00539391b6e1e9adec3303a66d033d Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 06:47:49 -0500 Subject: [PATCH 07/12] server: a ground request's depth search stops when the check is settled 7f5ddc4 bounded the unseen area left at 1e-12 sq ft instead of 1e-9. That moved the speck rather than removing it: a verifier's sweep still found a sliver 4.25e-7 ft inside a radius, against the checks' 1e-6 ft tolerance. view_to_cover now takes the check's own test. _missing passes one that asks Solver._covered whether anything the check reads there, that a view can show, is left strictly within its radius. The bisection then stops exactly where the check is settled. The area bound is gone. Requests come out up to SEEN_GROWTH_FT + SEAM_FT (0.005 ft) shallower than the depth a check needs, as seen ground grows by that. The depth bounds in test_requests and test_capture_contract allow that and no more (before: the full 0.01 ft tolerance, and exact D + 10 for the pool). The scene pinned in 7f5ddc4 still passes, and a sweep of 303 gap positions around it leaves no named check unseen after the capture. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 21 ++++++++++++--------- server/solver.py | 11 ++++++++++- server/tests/test_capture_contract.py | 11 +++++++---- server/tests/test_requests.py | 11 +++++++---- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/server/scene.py b/server/scene.py index 0b9616ac2..57f939c2e 100644 --- a/server/scene.py +++ b/server/scene.py @@ -15,6 +15,7 @@ import itertools import json import math +from collections.abc import Callable from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any @@ -393,11 +394,17 @@ 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 + self, + region: Geometry, + s_lo: float, + s_hi: float, + settled: Callable[[Geometry], bool], ) -> 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 + """The ground view (s_lo, s_hi, out_ft) with the smallest depth after which `settled` + holds for the ground left unseen, found by bisection on exactly the geometry the checks + use, so capturing it settles the check in one round. `settled` is the check's own test + of `region` (what it needs seen): a bound on the unseen area left would stop the search + with a speck inside the check's radius. 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 @@ -406,12 +413,8 @@ def view_to_cover( area = polygonal(region) - # The bisection stops where the unseen area left crosses this bound, so what is left is a - # speck just under it. A check reads any unseen point strictly inside its radius, and at - # 1e-9 sq ft a speck where an arc meets the view's edge was still inside it. 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-12 + return settled(self.unobserved_ground_given([*ground, (a, b, depth)])) tol = COVERAGE_TOLERANCE_FT # A region's nearest wall need not be the one it lies in front of (at an inside corner, diff --git a/server/solver.py b/server/solver.py index 09f761550..6b5ba4327 100644 --- a/server/solver.py +++ b/server/solver.py @@ -552,7 +552,16 @@ def _missing( if extents: a, b = min(a for a, _ in extents), max(b for _, b in extents) if view == "ground": - a, b, depth = self.scene.view_to_cover(region, a, b) + coverable = self.scene.coverable("ground") + + # The check's own test: nothing it reads here, that a view can show, is + # left strictly within its radius. + def settled( + after: Geometry, unseen: Geometry = unseen, coverable: Geometry = coverable + ) -> bool: + return self._covered(fp, _meet(_meet(after, unseen), coverable), radius) + + a, b, depth = self.scene.view_to_cover(region, a, b, settled) depth = _up(depth) else: depth = None if up_to is None else _above(up_to) diff --git a/server/tests/test_capture_contract.py b/server/tests/test_capture_contract.py index 12936c3cb..5357242c0 100644 --- a/server/tests/test_capture_contract.py +++ b/server/tests/test_capture_contract.py @@ -9,13 +9,16 @@ from helpers import D, at_start, observed_band, parsed, shared_fixture from test_s4_round import PUBLIC, REAL_POOL_AND_DRIVE, answer -from scene import COVERAGE_TOLERANCE_FT +from scene import COVERAGE_TOLERANCE_FT, SEAM_FT, SEEN_GROWTH_FT 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 +# Seen ground grows this far each side, so a ground request asks for this much less than the +# depth a check needs (scene.SEEN_GROWTH_FT and SEAM_FT). +GROWTH = SEEN_GROWTH_FT + SEAM_FT HEADROOM_NEED = 6.5 # headroom.min_ft @@ -103,14 +106,14 @@ def test_a_ground_request_names_how_far_out() -> None: result = answer(shallow_ground(), REAL_POOL_AND_DRIVE) 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 + assert request["out_ft"] >= D + 10.0 - GROWTH jsonschema.validate(result, RESULT_SCHEMA) # Under the public rules the pool's 10 ft is a placeholder, and the gas and AC clearances' # 3 ft size the request instead (issue #75). The request is the smallest depth that covers - # the region, which the seen ground's tolerance can make a little less than D + 3. + # the region, which the seen ground's growth makes a little less than D + 3. result = answer(shallow_ground()) request = next(m for m in result["missing_evidence"] if m.get("band") == "ground") - assert D + 3.0 - COVERAGE_TOLERANCE_FT <= request["out_ft"] < D + 5.0 + assert D + 3.0 - GROWTH <= request["out_ft"] < D + 5.0 def test_supplying_the_ground_request_settles_it() -> None: diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py index 4e59a0296..63481a9cd 100644 --- a/server/tests/test_requests.py +++ b/server/tests/test_requests.py @@ -18,11 +18,14 @@ ) from rules import deep_merge, public_rules_dict, rules_from_dict -from scene import COVERAGE_TOLERANCE_FT +from scene import SEAM_FT, SEEN_GROWTH_FT from solver import _LOOKS_FOR, UNSURE, _either # Test settings, not Base policy. END = 12.0 # the wall's right end, a limit ("Something blocks it") +# Seen ground grows this far each side, so a ground request asks for this much less than the +# depth a check needs (scene.SEEN_GROWTH_FT and SEAM_FT). +GROWTH = SEEN_GROWTH_FT + SEAM_FT def spot_beside_a_limit_end() -> dict: @@ -214,11 +217,11 @@ def test_a_band_only_placeholder_checks_need_raises_no_request() -> None: def test_a_real_rules_depth_is_kept() -> None: # Ground seen 4 ft out: the gas and AC clearances still ask for theirs, 3 ft past the - # battery's front (less the seen ground's tolerance, as the request is the smallest depth + # battery's front (less the seen ground's growth, as the request is the smallest depth # that covers the region), and nothing deeper. result = run(ground_seen_out_to(4)) (ground,) = band_requests(result, "ground") - assert D + 3 - COVERAGE_TOLERANCE_FT <= ground["out_ft"] <= D + 3 + 0.05, ground + assert D + 3 - GROWTH <= ground["out_ft"] <= D + 3 + 0.05, ground assert {"ac_clearance", "gas_clearance"} <= set(ground["checks"]) assert not {"drive_clearance", "pool_clearance"} & set(ground["checks"]), ground @@ -242,5 +245,5 @@ def test_a_real_pool_rule_brings_its_request_back(private: bool) -> None: result = run(ground_seen_out_to(6), loaded) (ground,) = band_requests(result, "ground") assert ground["checks"] == ["pool_clearance"] - assert ground["out_ft"] >= D + 10 - 1e-6 + assert ground["out_ft"] >= D + 10 - GROWTH assert result["summary"].startswith("More views are needed"), result["summary"] From 52136831e805e28e68d8f0b37d0fbe1f44f041cd Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 06:56:31 -0500 Subject: [PATCH 08/12] server: past an unexplored end, walking on settles what the band requests leave Beside an unexplored end, round 1 named the AC, gas and opening clearances on band requests. After capturing exactly those, all three were still unseen: part of what they read lies past the end, where only walking on (the past_end request) shows it. Round 2 had only the past_end request, and the summary sent the three to a person ("A person needs to check..."), because c8d3c9d counted only checks named on band requests. On a726199 it read "More views are needed... 5 checks depend". - walk_on_settles() finds the unexplored ends past which a check's unseen area lies. That is a wall, gap or overhead view reaching past the end, or ground or wall within the radius that Scene.coverable leaves out (Solver._past_unexplored, deferred like missing_later). - A past_end request in a manual review now lists those checks in `checks`, which the schema already allows. solve() counts a check as needing more views when any request names it. So the contract holds for band requests together with walking on, and the app can find the request that settles a check the same way for both kinds. - _missing skips a part whose unseen area within the check's exact radius is empty. With the depth search stopping at the check's own test (2f93263), the sliver between the radius and the polygon that circumscribes it gave a ground request 1e-6 ft deep in round 2. - Check.all_missing() is cached, since the summary now reads it again. test_capturing_the_requests_settles_every_check_they_name gains the unexplored variant: round 2 has only the past_end request, it names exactly the three checks still unseen, and the summary counts them as needing more views. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 139 +++++++++++++++++++++++++--------- server/tests/test_requests.py | 41 ++++++++-- 2 files changed, 138 insertions(+), 42 deletions(-) diff --git a/server/solver.py b/server/solver.py index 6b5ba4327..8e83fc201 100644 --- a/server/solver.py +++ b/server/solver.py @@ -90,12 +90,18 @@ class Check: # is a placeholder doesn't: that number would set how far out the ground must be shown # (issue #75). The check still reads the area; a person settles what nobody saw. asks_for_views: bool = True + # The unexplored ends past which part of the area the check reads lies unseen, deferred + # like missing_later. No view can show that part; walking on can (see walk_on_settles). + past_ends_later: Callable[[], set[str]] | 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, ...] = () + _all_missing: list[View] | None = field(default=None, repr=False, compare=False) def all_missing(self) -> list[View]: - return self.missing + (self.missing_later() if self.missing_later else []) + if self._all_missing is None: + self._all_missing = self.missing + (self.missing_later() if self.missing_later else []) + return self._all_missing def rule_keys(self) -> tuple[str, ...]: return (self.rule_key, *self.cites) @@ -489,6 +495,7 @@ def check_ground(self, piece: Piece, fp: Polygon) -> Check: 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.past_ends_later = partial(self._past_unexplored, ["ground"], fp, ew, None) 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 @@ -525,16 +532,14 @@ def _missing( 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(up_to)), - ("ground", self.scene.unexplored_area()), - ] out = [] - for view, unseen in parts: - region = _meet(_meet(unseen, within), self.scene.coverable(view)) + for view, unseen in self._unseen_parts(band, up_to): + coverable = self.scene.coverable(view) + if self._covered(fp, _meet(unseen, coverable), radius): + # What a view could show here is all outside the radius (between it and the + # polygon `within` circumscribes it with): nothing to ask for. + continue + region = _meet(_meet(unseen, within), coverable) # 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 = [] @@ -552,8 +557,6 @@ def _missing( if extents: a, b = min(a for a, _ in extents), max(b for _, b in extents) if view == "ground": - coverable = self.scene.coverable("ground") - # The check's own test: nothing it reads here, that a view can show, is # left strictly within its radius. def settled( @@ -568,6 +571,38 @@ def settled( out.append(View(view, a, b, depth)) return out + def _unseen_parts(self, band: str, up_to: float | None) -> list[tuple[str, Geometry]]: + """What a check reading `band` sees as unseen, by the band a view of it would show. 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.""" + if band == "ground": + return [("ground", self.unobserved_ground)] + return [ + ("wall", self.scene.unobserved_wall_lines(up_to)), + ("ground", self.scene.unexplored_area()), + ] + + def _past_unexplored( + self, bands: list[str], fp: Polygon, radius: float, up_to: float | None + ) -> set[str]: + """The unexplored ends past which something unseen lies within `radius` of the + footprint, where no view can show it (outside Scene.coverable): walking on past the end + can.""" + scene = self.scene + within = _within(fp, radius) + sides: set[str] = set() + for band in bands: + for view, unseen in self._unseen_parts(band, up_to): + rest = shapely.difference( + _meet(unseen, within), scene.coverable(view), grid_size=_OVERLAY_GRID + ) + for part in getattr(rest, "geoms", [rest]): + if part.is_empty or fp.distance(part) >= radius - _MEASURE_EPS: + continue + lo, hi = scene.s_extent(part) or (0.0, 0.0) + sides |= _sides_past(scene, lo, hi, "unexplored") + return sides + def _missing_bands( self, bands: list[str], fp: Polygon, radius: float, up_to: float | None ) -> list[View]: @@ -629,6 +664,9 @@ def check_clearance( missing_later = ( partial(self._missing_bands, unseen, fp, radius, wall_height) if unseen else None ) + c.past_ends_later = ( + partial(self._past_unexplored, unseen, fp, radius, wall_height) if unseen else None + ) if c.outcome == UNSURE: if c.unsure_cause == "unknown_attribute": c.reason = ( @@ -1621,6 +1659,18 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: } +def _sides_past(scene: Scene, lo: float, hi: float, kind: str) -> set[str]: + """The ends of this kind that the stretch [lo, hi] reaches past by more than the coverage + tolerance.""" + left, right = scene.end_kinds.get("left"), scene.end_kinds.get("right") + sides = set() + if left == kind and lo < scene.s_min - COVERAGE_TOLERANCE_FT: + sides.add("left") + if right == kind and hi > scene.s_max + COVERAGE_TOLERANCE_FT: + sides.add("right") + return sides + + def settled_by_views(chk: Check, scene: Scene) -> bool: """Whether views can settle the check, so that requests ask for and name it. Not one on a placeholder distance (issue #75), nor one whose view of the wall, the gap in front of it or @@ -1629,17 +1679,25 @@ def settled_by_views(chk: Check, scene: Scene) -> bool: unseen for the check. solve() names those for a person.""" if not chk.asks_for_views: return False - left, right = scene.end_kinds.get("left"), scene.end_kinds.get("right") return not any( - v.band != "ground" - and ( - (left == "limit" and v.a < scene.s_min - COVERAGE_TOLERANCE_FT) - or (right == "limit" and v.b > scene.s_max + COVERAGE_TOLERANCE_FT) - ) - for v in chk.all_missing() + v.band != "ground" and _sides_past(scene, v.a, v.b, "limit") for v in chk.all_missing() ) +def walk_on_settles(chk: Check, scene: Scene) -> set[str]: + """The unexplored ends past which part of what the check reads lies unseen. No view can + show it until the walk goes on past that end (a past_end request), after which the next + answer asks for it: views of the wall, the gap or the space overhead that reach past the + end, and ground or wall there that Scene.coverable leaves out.""" + sides = set() + for v in chk.all_missing(): + if v.band != "ground": + sides |= _sides_past(scene, v.a, v.b, "unexplored") + if chk.past_ends_later: + sides |= chk.past_ends_later() + return sides + + def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: by_band: dict[str, list[tuple[View, str]]] = {} for chk in c.checks: @@ -1786,19 +1844,31 @@ def solve(scene: Scene, loaded: LoadedRules, budget_s: float = SOLVE_BUDGET_S) - } 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 [ - { + def past_end_requests(best: Candidate | None = None) -> list[dict[str, Any]]: + out = [] + for side in open_ends: + request: dict[str, Any] = { "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 - ] + if best is not None: + # The checks at the spot whose unseen area lies past this end: walking on shows + # it, and the next answer asks for the views. + request["checks"] = sorted( + c.id + for c in best.checks + if c.outcome == UNSURE + and c.unsure_cause == "unobserved" + and settled_by_views(c, scene) + and side in walk_on_settles(c, scene) + ) + request["message"] = ( + f"Keep walking past the {side} end of the scan ({where(ends[side]['s_ft'])}): " + "a spot within reach may be there." + ) + out.append(request) + return out reasons: list[dict[str, Any]] = [] missing: list[dict[str, Any]] = [] @@ -1852,13 +1922,14 @@ def past_end_requests() -> list[dict[str, Any]]: ) if open_ends: reasons.append(unexplored_reason) - missing += past_end_requests() + missing += past_end_requests(best) spot_at = where((best.s0 + best.s1) / 2) - # Only a check that an emitted view request names is settled by more views. The rest are - # named for a person, as in "A person needs to check the best spot" (issue #45, #50's - # wording): unseen checks on a placeholder distance (issue #75), and unseen checks whose - # remaining views lie past an end mark, which no request asks for (issue #78). - requested = {i for m in missing if m["kind"] == "band" for i in m["checks"]} + # Only a check a request names is settled by more views: a band request, or a past_end + # request when its unseen area lies past that end. The rest are named for a person, as + # in "A person needs to check the best spot" (issue #45, #50's wording): unseen checks + # on a placeholder distance (issue #75), and unseen checks that need the wall past a + # limit end (issue #78). + requested = {i for m in missing for i in m.get("checks", [])} unseen = [ c for c in best.checks diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py index 63481a9cd..426d6e0a7 100644 --- a/server/tests/test_requests.py +++ b/server/tests/test_requests.py @@ -140,21 +140,46 @@ def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: assert ground and not any("past the" in m["message"] for m in ground) +def spot_beside_an_unexplored_end() -> dict: + """spot_beside_a_limit_end() with the walk stopped where the wall goes on.""" + raw = spot_beside_a_limit_end() + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + return raw + + @pytest.mark.parametrize( - "scene", [spot_beside_a_limit_end, facing_and_overhead_past_the_end], ids=["wall", "bands"] + "scene", + [spot_beside_a_limit_end, facing_and_overhead_past_the_end, spot_beside_an_unexplored_end], + ids=["wall", "bands", "unexplored"], ) def test_capturing_the_requests_settles_every_check_they_name(scene) -> None: - # Round 2 of the gap loop beside a limit end. What the checks left to a person need lies - # past the end, so before the summary read "More views are needed ... 2 checks depend on - # areas the scan did not see" with nothing left to show. + # Round 2 of the gap loop beside an end: the homeowner showed exactly what round 1's band + # requests asked for. raw = scene() first = run(raw) assert named(first), first["missing_evidence"] second = run(captured_exactly(raw, first)) - assert not named(first) & unseen(second), second["checks"] - assert second["missing_evidence"] == [], second["missing_evidence"] - assert second["summary"].startswith("A person needs to check"), second["summary"] - assert unseen(second) # left for a person, not asked for + left = named(first) & unseen(second) + if raw["coverage"]["ends"]["right"]["kind"] == "limit": + # What the checks left to a person need lies past the limit end, so before the summary + # read "More views are needed ... 2 checks depend on areas the scan did not see" with + # nothing left to show. + assert not left, second["checks"] + assert second["missing_evidence"] == [], second["missing_evidence"] + assert second["summary"].startswith("A person needs to check"), second["summary"] + assert unseen(second) # left for a person, not asked for + else: + # The band requests settle the checks they name together with the past_end request: + # what those checks still lack lies past the unexplored end, and walking on shows it. + # Before, this summary sent them to a person, as a past_end request named no checks. + (walk_on,) = second["missing_evidence"] + assert walk_on["kind"] == "past_end", walk_on + assert left == {"ac_clearance", "gas_clearance", "opening_clearance"}, left + assert set(walk_on["checks"]) == left, walk_on + assert second["summary"].startswith("More views are needed"), second["summary"] + assert f"{len(left)} checks depend" in second["summary"], second["summary"] + for label in ("gas equipment", "AC units", "doors and windows"): + assert label not in second["summary"], second["summary"] def test_a_small_ground_request_settles_its_checks() -> None: From 23a139c7abcbc4211bfc6ef42a94f99c695c6b29 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 06:56:31 -0500 Subject: [PATCH 09/12] server: say that views settle an unobserved check only when a request names it result.schema.json described `unobserved` as "more photos fix it". That is no longer true. Placeholder distances (#75) and checks that need the wall past a limit end (#78) are unobserved but named by no request, and the summary sends them to a person. The unsure_cause description now says that more views settle an unobserved check only when a missing_evidence request names it, and that a check no request names needs a person. The missing_evidence description and the README say that every band request, together with walking on for any past_end request, settles every check the requests name. No enum value or key is added. The app vendors result.schema.json and checks its hash (JSONSchemaValidatorTests on beta). Its copy needs refreshing once this lands on t3/server. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 ++-- server/schemas/result.schema.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/README.md b/server/README.md index 7e68d229d..4cf9c9232 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. Its request says so, and what the view looks for there. Requests for the wall, the gap in front of it and the space overhead stop at either end mark. Past a limit end there is none of them to show, yet that stretch still counts as unseen, so a check that needs it there asks for no view at all and stays UNSURE for a person. -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). Supplying every band request also settles every check they name in `checks` (a check can need more than one band); a check no view can settle is named by none. +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). Supplying every band request, together with walking on for any `past_end` request, settles every check the requests name in `checks` (a check can need more than one band). A `past_end` request names the checks whose unseen area lies past its end; once the walk goes on, the next answer asks for their views. A check no request names can't be settled by views, whatever its `unsure_cause`: a person judges it. A clearance whose distance is a placeholder (under the public rules `pool_clearance` and `drive_clearance`) asks for no view: its number would set how far out the ground must be shown (issue #75). It still reads the coverage above and stays UNSURE until that is seen, and the summary names it for a person. A value the private rules set counts as real, so under them its requests come back. @@ -129,7 +129,7 @@ A clearance whose distance is a placeholder (under the public rules `pool_cleara A check's `subject` is the id of what decided it (`objects[1] ac`, `ground[0] drive`, `facing[2]`), for code. Its `reason`, `summary` and every request's `message` are shown to the homeowner and name things by what they are and roughly where: "the AC unit about 14 ft right of the meter". -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. +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. `unsure_cause: unobserved` says the deciding area was not seen, not that photos will settle it: views settle an unobserved check only when a request in `missing_evidence` names it in `checks`. An unobserved check no request names (a placeholder distance, or the wall past a limit end) needs a person, as the summary says. 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/schemas/result.schema.json b/server/schemas/result.schema.json index e7ae5a67b..b68486bd1 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -59,7 +59,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. 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.", + "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. Supplying every band request, together with walking on for any past_end request, settles every check the requests name in checks.", "type": "array", "items": { "type": "object", @@ -155,7 +155,7 @@ "outcome": {"$ref": "#/$defs/outcome"}, "unsure_cause": { "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)." + "description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen. More views settle it only when a missing_evidence request names the check in its checks (a past_end request names those whose unseen area lies past that end, and the next answer asks for their views); a check no request names needs a person, for example a clearance whose distance is a placeholder or one that needs the wall past a limit end. 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 ad7d5eb9ff1588d4f172f2ff01e86ac98fb82bb0 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 07:10:09 -0500 Subject: [PATCH 10/12] server: keep a ground request's depth test to areas test_s4_round's property found a scene where the check's test from 2f93263 raised "Overlay input is mixed-dimension". Overlaying the unseen ground after a capture can leave line fragments, which the next overlay refuses. The test and Solver._past_unexplored now keep only the areas (polygonal), as view_to_cover already did. The scene is pinned as an @example on test_a_captured_request_never_comes_back, and it fails without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/scene.py | 2 +- server/solver.py | 13 +++++++++---- server/tests/test_s4_round.py | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/server/scene.py b/server/scene.py index 57f939c2e..f19094e02 100644 --- a/server/scene.py +++ b/server/scene.py @@ -414,7 +414,7 @@ def view_to_cover( area = polygonal(region) def covers(a: float, b: float, depth: float) -> bool: - return settled(self.unobserved_ground_given([*ground, (a, b, depth)])) + return settled(polygonal(self.unobserved_ground_given([*ground, (a, b, depth)]))) tol = COVERAGE_TOLERANCE_FT # A region's nearest wall need not be the one it lies in front of (at an inside corner, diff --git a/server/solver.py b/server/solver.py index 8e83fc201..219beb7e5 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 @@ -562,7 +563,10 @@ def _missing( def settled( after: Geometry, unseen: Geometry = unseen, coverable: Geometry = coverable ) -> bool: - return self._covered(fp, _meet(_meet(after, unseen), coverable), radius) + # Only areas: an overlay can leave line fragments, which the next one + # refuses as mixed-dimension input. + left = polygonal(_meet(after, polygonal(unseen))) + return self._covered(fp, _meet(left, coverable), radius) a, b, depth = self.scene.view_to_cover(region, a, b, settled) depth = _up(depth) @@ -593,9 +597,10 @@ def _past_unexplored( sides: set[str] = set() for band in bands: for view, unseen in self._unseen_parts(band, up_to): - rest = shapely.difference( - _meet(unseen, within), scene.coverable(view), grid_size=_OVERLAY_GRID - ) + near = _meet(unseen, within) + if view == "ground": + near = polygonal(near) + rest = shapely.difference(near, scene.coverable(view), grid_size=_OVERLAY_GRID) for part in getattr(rest, "geoms", [rest]): if part.is_empty or fp.distance(part) >= radius - _MEASURE_EPS: continue diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py index 9904124a0..210edf480 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -4,7 +4,7 @@ import pytest from helpers import at_start, observed_band, parsed, shared_fixture -from hypothesis import given, settings +from hypothesis import example, given, settings from hypothesis import strategies as st from shapely.geometry import Point @@ -114,8 +114,24 @@ def scenes_with_ends(draw: st.DrawFn) -> dict: return raw +def unseen_ground_with_line_fragments() -> dict: + """Found by the property below once a ground request's depth search took the check's own + test (#122): overlaying the unseen ground after a capture left line fragments, which the + next overlay refused as mixed-dimension input.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-1.0, 0], [12.0, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-1.0, 12.0] + raw["coverage"]["ends"] = {"left": {"kind": "limit"}, "right": {"kind": "unexplored"}} + observed_band(raw, "wall", [(-1.0, 12.0)]) + observed_band(raw, "ground", [(-1.0, 0.0), (4.053550230014572, 12.0)], 5.0) + observed_band(raw, "overhead", [(-1.0, 12.0)]) + observed_band(raw, "facing", [(-1.0, 12.0)]) + return raw + + @settings(max_examples=40, deadline=None) @given(raw=scenes_with_ends()) +@example(raw=unseen_ground_with_line_fragments()) def test_a_captured_request_never_comes_back(raw: dict) -> None: for _ in range(3): result = answer(raw) From 4014cb885af22414347b0babe89152fb87b10b24 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 07:58:01 -0500 Subject: [PATCH 11/12] server: past an unexplored end beyond reach, no request names what lies there An unexplored end out of cable reach raises no past_end request, but settled_by_views didn't know that. With the wall ending 22 ft right of the meter and the lawn pad just before it, round 1 named the AC, gas and opening clearances on band requests ("7 checks depend"). After capturing exactly those, all three stayed unobserved with nothing naming them, and round 2 sent them to a person. A verifier hit this in 7 of 400 random scenes. - open_ends(solver) gives the unexplored ends within reach, the ones past_end requests ask to walk on past; solve() uses it throughout. - settled_by_views(chk, scene, reachable) is false when part of what the check reads lies past an unexplored end not in `reachable`. Such a check asks for no views and goes to a person from round 1. walk_on_settles is cached per check. - The unobserved_area reason is built after the past_end requests, so a round whose only request is past_end still lists the checks it names, as the summary does. test_capturing_the_requests_settles_every_check_they_name gains a beyond-reach variant, which fails on ee882e4, and the unexplored variant checks the reason. The one-round properties pass the reachable ends in. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/solver.py | 73 ++++++++++++++++---------- server/tests/test_coderabbit_review.py | 14 ++++- server/tests/test_final_review.py | 6 ++- server/tests/test_requests.py | 33 ++++++++++-- 4 files changed, 90 insertions(+), 36 deletions(-) diff --git a/server/solver.py b/server/solver.py index 6ba898063..1c3d1cb60 100644 --- a/server/solver.py +++ b/server/solver.py @@ -98,6 +98,7 @@ class Check: # them, the citation is withheld. cites: tuple[str, ...] = () _all_missing: list[View] | None = field(default=None, repr=False, compare=False) + _past_ends: set[str] | None = field(default=None, repr=False, compare=False) def all_missing(self) -> list[View]: if self._all_missing is None: @@ -1798,17 +1799,32 @@ def _sides_past(scene: Scene, lo: float, hi: float, kind: str) -> set[str]: return sides -def settled_by_views(chk: Check, scene: Scene) -> bool: +def open_ends(solver: Solver) -> set[str]: + """The unexplored ends within cable reach: a spot may stand past them, so a past_end request + asks to walk on. Past an unexplored end beyond reach no request asks for anything.""" + scene = solver.scene + return { + side + for side, s_end, piece in ( + ("left", scene.s_min, scene.walls[0]), + ("right", scene.s_max, scene.walls[-1]), + ) + if scene.end_kinds[side] == "unexplored" and abs(s_end) <= solver.reach_limit(piece) + } + + +def settled_by_views(chk: Check, scene: Scene, reachable: set[str]) -> bool: """Whether views can settle the check, so that requests ask for and name it. Not one on a placeholder distance (issue #75), nor one whose view of the wall, the gap in front of it or the space over it reaches past an end the walk marked as a limit, by more than the coverage tolerance: there is no wall there to show (issue #78), but the stretch still counts as - unseen for the check. solve() names those for a person.""" + unseen for the check. Nor one with unseen area past an unexplored end outside `reachable` + (open_ends): no past_end request asks to walk on there. solve() names those for a person.""" if not chk.asks_for_views: return False - return not any( - v.band != "ground" and _sides_past(scene, v.a, v.b, "limit") for v in chk.all_missing() - ) + if any(v.band != "ground" and _sides_past(scene, v.a, v.b, "limit") for v in chk.all_missing()): + return False + return walk_on_settles(chk, scene) <= reachable def walk_on_settles(chk: Check, scene: Scene) -> set[str]: @@ -1816,21 +1832,23 @@ def walk_on_settles(chk: Check, scene: Scene) -> set[str]: show it until the walk goes on past that end (a past_end request), after which the next answer asks for it: views of the wall, the gap or the space overhead that reach past the end, and ground or wall there that Scene.coverable leaves out.""" - sides = set() - for v in chk.all_missing(): - if v.band != "ground": - sides |= _sides_past(scene, v.a, v.b, "unexplored") - if chk.past_ends_later: - sides |= chk.past_ends_later() - return sides - - -def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: + if chk._past_ends is None: + sides = set() + for v in chk.all_missing(): + if v.band != "ground": + sides |= _sides_past(scene, v.a, v.b, "unexplored") + if chk.past_ends_later: + sides |= chk.past_ends_later() + chk._past_ends = sides + return chk._past_ends + + +def _missing_json(c: Candidate, scene: Scene, reachable: set[str]) -> list[dict[str, Any]]: by_band: dict[str, list[tuple[View, str]]] = {} for chk in c.checks: # A request names only checks that capturing it settles (result.schema.json), and a # check it can't settle asks for none of its views, so none of them size a request. - if not settled_by_views(chk, scene): + if not settled_by_views(chk, scene, reachable): continue for view in chk.all_missing(): if view.band != "ground": @@ -1969,11 +1987,12 @@ def solve(scene: Scene, loaded: LoadedRules, budget_s: float = SOLVE_BUDGET_S) - "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"]] + # The unexplored ends not beyond_reach. + reachable = open_ends(solver) def past_end_requests(best: Candidate | None = None) -> list[dict[str, Any]]: out = [] - for side in open_ends: + for side in sorted(reachable): request: dict[str, Any] = { "kind": "past_end", "side": side, @@ -1987,7 +2006,7 @@ def past_end_requests(best: Candidate | None = None) -> list[dict[str, Any]]: for c in best.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved" - and settled_by_views(c, scene) + and settled_by_views(c, scene, reachable) and side in walk_on_settles(c, scene) ) request["message"] = ( @@ -2038,18 +2057,18 @@ def past_end_requests(best: Candidate | None = None) -> list[dict[str, Any]]: + ".", } ) - missing = _missing_json(best, scene) - if missing: + missing = _missing_json(best, scene, reachable) + past_end_requests(best) + named = sorted({i for m in missing for i in m.get("checks", [])}) + if named: reasons.append( { "code": "unobserved_area", - "checks": sorted({i for m in missing for i in m["checks"]}), + "checks": named, "message": "Part of the area the checks need was not seen.", } ) - if open_ends: + if reachable: reasons.append(unexplored_reason) - missing += past_end_requests(best) spot_at = where((best.s0 + best.s1) / 2) # Only a check a request names is settled by more views: a band request, or a past_end # request when its unseen area lies past that end. The rest are named for a person, as @@ -2107,8 +2126,8 @@ def past_end_requests(best: Candidate | None = None) -> list[dict[str, Any]]: "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: + can_reject = auto and r.policy.allow_reject and not reachable + if reachable: reasons.append(unexplored_reason) missing = past_end_requests() if not auto: @@ -2122,7 +2141,7 @@ def past_end_requests(best: Candidate | None = None) -> list[dict[str, Any]]: + ("; ".join(names.get(t, t.replace("_", " ")) for t in top[:3]) or "the checks") + "." ) - elif open_ends: + elif reachable: summary = ( "No spot on the scanned walls works; walk further to look for one within reach." ) diff --git a/server/tests/test_coderabbit_review.py b/server/tests/test_coderabbit_review.py index 9fb71a32f..2a574737f 100644 --- a/server/tests/test_coderabbit_review.py +++ b/server/tests/test_coderabbit_review.py @@ -10,7 +10,14 @@ from rules import load_rules from scene import SceneError -from solver import SOLVE_BUDGET_S, UNSURE, Solver, evaluate_start, settled_by_views +from solver import ( + SOLVE_BUDGET_S, + UNSURE, + Solver, + evaluate_start, + open_ends, + settled_by_views, +) # --- missing_evidence names every unseen band ------------------------------------------------ @@ -103,10 +110,13 @@ def test_capturing_what_missing_evidence_asks_for_settles_coverage(raw: dict) -> after = evaluate_start(scene, rules, exact) # Checks views can't settle ask for nothing, and a person settles those: on a placeholder # distance (issue #75) or needing the wall past a limit end (issue #78). + reachable = open_ends(Solver(scene, rules)) unseen = [ c.id for c in after.checks - if c.outcome == UNSURE and c.unsure_cause == "unobserved" and settled_by_views(c, scene) + if c.outcome == UNSURE + and c.unsure_cause == "unobserved" + and settled_by_views(c, scene, reachable) ] assert unseen == [], (unseen, result["missing_evidence"]) diff --git a/server/tests/test_final_review.py b/server/tests/test_final_review.py index 6753561b4..496259536 100644 --- a/server/tests/test_final_review.py +++ b/server/tests/test_final_review.py @@ -16,6 +16,7 @@ Solver, at_least, evaluate_start, + open_ends, settled_by_views, ) @@ -138,13 +139,14 @@ def supplied_exactly(raw: dict, result: dict) -> dict: def unseen_at(raw: dict, s0: float, wall_id: str) -> list[str]: """Checks still unseen that views can settle. The rest ask for none and are left to a person: those on a placeholder distance (issue #75) or needing the wall past a limit end - (issue #78).""" + (issue #78) or past an unexplored end beyond reach.""" scene = parsed(raw, PUBLIC) candidate = evaluate_start(scene, PUBLIC, s0, wall_id) + reachable = open_ends(Solver(scene, PUBLIC)) return [ c.id for c in candidate.checks - if c.unsure_cause == "unobserved" and settled_by_views(c, scene) + if c.unsure_cause == "unobserved" and settled_by_views(c, scene, reachable) ] diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py index 426d6e0a7..ae6e6bac7 100644 --- a/server/tests/test_requests.py +++ b/server/tests/test_requests.py @@ -140,17 +140,31 @@ def test_a_request_inside_the_ends_has_no_past_end_hint() -> None: assert ground and not any("past the" in m["message"] for m in ground) -def spot_beside_an_unexplored_end() -> dict: - """spot_beside_a_limit_end() with the walk stopped where the wall goes on.""" +def spot_beside_an_unexplored_end(end: float = END) -> dict: + """spot_beside_a_limit_end() with the walk stopped where the wall goes on, `end` ft right of + the meter, and the lawn pad the last 3 ft before it.""" raw = spot_beside_a_limit_end() + raw["walls"][0]["baseline"] = [[-40, 0], [end, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, end] + raw["ground"] = pads_ground([(end - 3, end)], hi=end + 20) raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} return raw +def spot_beside_an_unexplored_end_beyond_reach() -> dict: + """The unexplored end 22 ft right of the meter, past cable reach: no past_end request.""" + return spot_beside_an_unexplored_end(22.0) + + @pytest.mark.parametrize( "scene", - [spot_beside_a_limit_end, facing_and_overhead_past_the_end, spot_beside_an_unexplored_end], - ids=["wall", "bands", "unexplored"], + [ + spot_beside_a_limit_end, + facing_and_overhead_past_the_end, + spot_beside_an_unexplored_end, + spot_beside_an_unexplored_end_beyond_reach, + ], + ids=["wall", "bands", "unexplored", "beyond-reach"], ) def test_capturing_the_requests_settles_every_check_they_name(scene) -> None: # Round 2 of the gap loop beside an end: the homeowner showed exactly what round 1's band @@ -160,7 +174,13 @@ def test_capturing_the_requests_settles_every_check_they_name(scene) -> None: assert named(first), first["missing_evidence"] second = run(captured_exactly(raw, first)) left = named(first) & unseen(second) - if raw["coverage"]["ends"]["right"]["kind"] == "limit": + walk_on = [m for m in first["missing_evidence"] if m["kind"] == "past_end"] + if not walk_on: + # Past a limit end, or an unexplored one beyond reach, no request can show what the + # checks left to a person need. Before, the summary read "More views are needed ... 2 + # checks depend on areas the scan did not see" with nothing left to show, and beyond + # reach round 1 named the AC, gas and opening clearances, which round 2 left unseen. + assert first["ends"]["right"]["kind"] == "limit" or first["ends"]["right"]["beyond_reach"] # What the checks left to a person need lies past the limit end, so before the summary # read "More views are needed ... 2 checks depend on areas the scan did not see" with # nothing left to show. @@ -176,6 +196,9 @@ def test_capturing_the_requests_settles_every_check_they_name(scene) -> None: assert walk_on["kind"] == "past_end", walk_on assert left == {"ac_clearance", "gas_clearance", "opening_clearance"}, left assert set(walk_on["checks"]) == left, walk_on + # The reason agrees with the summary; before, only band requests raised it. + (area,) = [r for r in second["reasons"] if r["code"] == "unobserved_area"] + assert set(area["checks"]) == left, area assert second["summary"].startswith("More views are needed"), second["summary"] assert f"{len(left)} checks depend" in second["summary"], second["summary"] for label in ("gas equipment", "AC units", "doors and windows"): From dc375aff6cc28c332b22987c39c65e4326652691 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 07:58:01 -0500 Subject: [PATCH 12/12] server: a past_end request names checks the next answer asks views for The missing_evidence description said walking on for a past_end request "settles" the checks it names. It doesn't: it lets the next answer ask for their views. It now says that, matching the README and the unsure_cause description, and both of those also list an unexplored end beyond reach among the cases no request names. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/README.md | 4 ++-- server/schemas/result.schema.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/README.md b/server/README.md index 6adb94d0f..d98d61c17 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. Its request says so, and what the view looks for there. Requests for the wall, the gap in front of it and the space overhead stop at either end mark. Past a limit end there is none of them to show, yet that stretch still counts as unseen, so a check that needs it there asks for no view at all and stays UNSURE for a person. -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). Supplying every band request, together with walking on for any `past_end` request, settles every check the requests name in `checks` (a check can need more than one band). A `past_end` request names the checks whose unseen area lies past its end; once the walk goes on, the next answer asks for their views. A check no request names can't be settled by views, whatever its `unsure_cause`: a person judges it. +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). Supplying every band request settles every check the band requests name in `checks` (a check can need more than one band). A `past_end` request names the checks whose unseen area lies past its end; once the walk goes on, the next answer asks for their views. A check no request names can't be settled by views, whatever its `unsure_cause`: a person judges it. A clearance whose distance is a placeholder (under the public rules `pool_clearance` and `drive_clearance`) asks for no view: its number would set how far out the ground must be shown (issue #75). It still reads the coverage above and stays UNSURE until that is seen, and the summary names it for a person. A value the private rules set counts as real, so under them its requests come back. @@ -129,7 +129,7 @@ A clearance whose distance is a placeholder (under the public rules `pool_cleara A check's `subject` is the id of what decided it (`objects[1] ac`, `ground[0] drive`, `facing[2]`), for code. Its `reason`, `summary` and every request's `message` are shown to the homeowner and name things by what they are and roughly where: "the AC unit about 14 ft right of the meter". -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. `unsure_cause: unobserved` says the deciding area was not seen, not that photos will settle it: views settle an unobserved check only when a request in `missing_evidence` names it in `checks`. An unobserved check no request names (a placeholder distance, or the wall past a limit end) needs a person, as the summary says. 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. +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. `unsure_cause: unobserved` says the deciding area was not seen, not that photos will settle it: views settle an unobserved check only when a request in `missing_evidence` names it in `checks`. An unobserved check no request names (a placeholder distance, the wall past a limit end, or anything past an unexplored end beyond cable reach, where no `past_end` request asks to walk on) needs a person, as the summary says. 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/schemas/result.schema.json b/server/schemas/result.schema.json index b68486bd1..e6a86b500 100644 --- a/server/schemas/result.schema.json +++ b/server/schemas/result.schema.json @@ -59,7 +59,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. 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. Supplying every band request, together with walking on for any past_end request, settles every check the requests name in checks.", + "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. Supplying every band request settles every check the band requests name in checks. A past_end request names the checks whose unseen area lies past its end; once the walk goes on, the next answer asks for their views.", "type": "array", "items": { "type": "object", @@ -155,7 +155,7 @@ "outcome": {"$ref": "#/$defs/outcome"}, "unsure_cause": { "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 views settle it only when a missing_evidence request names the check in its checks (a past_end request names those whose unseen area lies past that end, and the next answer asks for their views); a check no request names needs a person, for example a clearance whose distance is a placeholder or one that needs the wall past a limit end. 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)." + "description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen. More views settle it only when a missing_evidence request names the check in its checks (a past_end request names those whose unseen area lies past that end, and the next answer asks for their views); a check no request names needs a person, for example a clearance whose distance is a placeholder, or one that needs the wall past a limit end or anything past an unexplored end beyond cable reach. 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."},