diff --git a/server/README.md b/server/README.md index b495e649b..35dfbe464 100644 --- a/server/README.md +++ b/server/README.md @@ -107,9 +107,11 @@ 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. 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 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. ## Reading a result @@ -125,8 +127,10 @@ Ground behind a scanned wall that is also on the house side of every scanned wal | `facing_gap`, `headroom` | Enough room in front and above | | `route_path`, `route_length` | The cable runs along continuous wall, and isn't too long | -A check passes only when its margin exceeds its error and fails only when it misses by more than its error; anything else is UNSURE with an `unsure_cause`. `missing_evidence` lists the views that would settle an unseen area; showing exactly what a `band` request names settles it, and a `past_end` request asks to walk on. When a measurement only might lie in front of or over the battery (its end is within the wall's position error of the battery's edge), `measured_ft ± plus_minus_ft` spans the values the check could have, so the numbers give the same UNSURE. A reject needs every spot within cable reach to fail and both ends of the walk known. `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'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, 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 -`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/scene.py b/server/scene.py index b468b2fb0..afab9729a 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 @@ -396,11 +397,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 @@ -410,8 +417,7 @@ def view_to_cover( area = polygonal(region) def covers(a: float, b: float, depth: float) -> bool: - unseen = polygonal(self.unobserved_ground_given([*ground, (a, b, depth)])) - return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-9 + 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, @@ -452,7 +458,9 @@ 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 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).""" + 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() diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json index e7ae5a67b..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.", + "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 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 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."}, diff --git a/server/solver.py b/server/solver.py index eec1c8ccd..c589adbea 100644 --- a/server/solver.py +++ b/server/solver.py @@ -87,12 +87,23 @@ 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 + # 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) + _past_ends: set[str] | 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) @@ -226,6 +237,59 @@ 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}" + + +# 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.""" + 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 @@ -258,6 +322,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 @@ -311,6 +376,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: @@ -343,6 +413,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, bool]]: + """check_clearance's items for scene objects.""" + return [(o.label, _describe(o), o.geom, o.plus_minus, counts(o), o.in_plan) for o in objs] + # --- checks -------------------------------------------------------------------------------- def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: @@ -423,11 +500,14 @@ 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 {_surface(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" 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 @@ -464,21 +544,19 @@ 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: + 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(unseen, within) if view == "ground": # Clipping can leave a line where edges touch (no ground to show), which the next # overlay refuses beside an area (the real sim scene after the snapped overlays). region = polygonal(region) - region = _meet(region, self.scene.coverable(view)) + region = _meet(region, 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 = [] @@ -496,13 +574,56 @@ 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) + # 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: + # 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) else: depth = None if up_to is None else _above(up_to) 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): + 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 + 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]: @@ -518,24 +639,27 @@ def check_clearance( rule: Value, piece: Piece, fp: Polygon, - items: list[tuple[str, Geometry, float, bool | None, bool]], + items: list[tuple[str, str, Geometry, float, bool | None, bool]], band: str, noun: str, wall_height: float | None = None, along_err: float | None = None, ) -> Check: - """Minimum plan distance from the footprint to each item. `items` holds (label, geometry, - error, counts, in plan) where counts None means an unknown attribute decides whether it - applies, and in plan says whether the item was placed in plan (the battery moves against - it by its full position error) or along the walls (by its error along them). - `wall_height` is how high up the wall the face must have been seen, for a check that - reads the wall band.""" + """Minimum plan distance from the footprint to each item. `items` holds (subject, + description, geometry, error, counts, in plan): the subject is the id the answer + reports, the description how its reason names the item, counts None means an unknown + attribute decides whether it applies, and in plan says whether the item was placed in + plan (the battery moves against it by its full position error) or along the walls (by + its error along them). `wall_height` is how high up the wall the face must have been + seen, for a check that reads the wall band.""" t = rule.value c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") + c.asks_for_views = not self._placeholder(rule_key, rule) if along_err is None: along_err = piece.plus_minus worst_key: tuple[int, float] | None = None - for name, geom, err, counts, in_plan in items: + what = "" + for name, description, geom, err, counts, in_plan in items: if counts is False: continue d = fp.distance(geom) @@ -549,9 +673,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 @@ -565,15 +690,18 @@ 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 = ( - 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 @@ -582,7 +710,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 @@ -627,6 +755,7 @@ def check_along_wall(self, along_err: float, 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 @@ -637,8 +766,9 @@ def check_along_wall(self, along_err: float, 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"] # Wherever the battery may stand along the wall (the strict property's case: a meter @@ -646,7 +776,7 @@ def check_along_wall(self, along_err: float, s0: float, s1: float) -> Check: missing = self.scene.missing("wall", s0 - t - along_err, s1 + t + along_err, up_to) if c.outcome == UNSURE: c.unsure_cause = "margin" - c.reason = f"{c.subject} ends within measurement error of the battery's edge." + 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." @@ -722,6 +852,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 @@ -745,12 +876,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." @@ -804,7 +939,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 @@ -814,8 +949,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. @@ -834,10 +969,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: @@ -847,6 +983,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", } @@ -865,7 +1002,7 @@ def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, C # The battery's end of the route slides along the walls when the meter or a corner moves # (_slide); spans in s near it are judged with that too. slide = self._slide(piece, s0) - maybe_blockers: list[str] = [] + maybe_blockers: list[dict[str, Any]] = [] for o in self.route_objects: if path_line is None: continue @@ -883,17 +1020,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. @@ -946,21 +1084,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" @@ -971,9 +1110,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" @@ -1074,7 +1214,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.gas_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True, o.in_plan) 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", @@ -1088,7 +1228,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.ac_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.ac], + self._items(self.ac), "ground", "AC unit", along_err=along_err, @@ -1102,7 +1242,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.battery_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.batteries], + self._items(self.batteries), "ground+wall", "battery", self.wall_height["above"], @@ -1119,12 +1259,21 @@ 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, True) + ( + f"ground[{g.index}] {g.type}", + f"the {_noun(g.type)}", + g.polygon, + g.plus_minus, + True, + True, + ) for g in self.drives ], "ground", - "drivable surface", + "driveway", along_err=along_err, ), self.check_clearance( @@ -1134,7 +1283,7 @@ def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: c.pool_ft, piece, fp, - [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.pool], + self._items(self.pool), "ground", "pool", along_err=along_err, @@ -1146,10 +1295,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), o.in_plan) - for o in self.openings - ], + self._items(self.openings, self._opening_counts), "wall", "door or window", self.wall_height["openings"], @@ -1657,10 +1803,78 @@ def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: } -def _missing_json(c: Candidate, scene: Scene) -> list[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 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. 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 + 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]: + """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.""" + 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, reachable): + continue for view in chk.all_missing(): + if view.band != "ground": + # 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 + view = replace(view, a=a, b=b) by_band.setdefault(view.band, []).append((view, chk.id)) out = [] for band, items in by_band.items(): @@ -1677,24 +1891,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", + "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) @@ -1764,21 +2003,34 @@ 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() -> list[dict[str, Any]]: - return [ - { + def past_end_requests(best: Candidate | None = None) -> list[dict[str, Any]]: + out = [] + for side in sorted(reachable): + 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, reachable) + 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]] = [] @@ -1811,7 +2063,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", @@ -1821,27 +2073,34 @@ def past_end_requests() -> 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() spot_at = where((best.s0 + best.s1) / 2) - unseen = [c for c in best.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + # 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 + if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.id in requested + ] 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). + 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" + _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}: " @@ -1883,20 +2142,22 @@ def past_end_requests() -> 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: 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: + elif reachable: summary = ( "No spot on the scanned walls works; walk further to look for one within reach." ) @@ -1907,8 +2168,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, @@ -1928,7 +2190,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_capture_contract.py b/server/tests/test_capture_contract.py index e7ca3453b..5357242c0 100644 --- a/server/tests/test_capture_contract.py +++ b/server/tests/test_capture_contract.py @@ -7,15 +7,18 @@ 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 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 @@ -100,18 +103,29 @@ 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 + 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 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 - GROWTH <= 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..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 +from solver import ( + SOLVE_BUDGET_S, + UNSURE, + Solver, + evaluate_start, + open_ends, + settled_by_views, +) # --- missing_evidence names every unseen band ------------------------------------------------ @@ -99,8 +106,18 @@ 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) - unseen = [c.id for c in after.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + 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). + 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, reachable) + ] assert unseen == [], (unseen, result["missing_evidence"]) 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_final_review.py b/server/tests/test_final_review.py index 9f10d2308..496259536 100644 --- a/server/tests/test_final_review.py +++ b/server/tests/test_final_review.py @@ -8,7 +8,17 @@ 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, + open_ends, + settled_by_views, +) W = 31 / 12 @@ -127,8 +137,17 @@ def supplied_exactly(raw: dict, result: dict) -> dict: def unseen_at(raw: dict, s0: float, wall_id: str) -> list[str]: - candidate = evaluate_start(parsed(raw, PUBLIC), PUBLIC, s0, wall_id) - return [c.id for c in candidate.checks if c.unsure_cause == "unobserved"] + """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) 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, reachable) + ] def exact_start(raw: dict, result: dict) -> float: diff --git a/server/tests/test_homeowner_text.py b/server/tests/test_homeowner_text.py new file mode 100644 index 000000000..e48e8f914 --- /dev/null +++ b/server/tests/test_homeowner_text.py @@ -0,0 +1,212 @@ +"""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 + + +@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_policy_and_auth.py b/server/tests/test_policy_and_auth.py index 0d4acb201..008bc629b 100644 --- a/server/tests/test_policy_and_auth.py +++ b/server/tests/test_policy_and_auth.py @@ -43,15 +43,16 @@ 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_views() -> None: +def test_the_unsure_example_asks_for_more() -> None: # The walk stopped at the unexplored right end, so the view it lacks is past that end. It # used to ask for ground at s [-3.93, -3] as well, which its capture had already seen past - # the left limit end. + # the left limit end. The placeholder pool and driveway distances ask for no view (issue #75). missing = decide("review-corner-not-walked.json")["missing_evidence"] assert [m["kind"] for m in missing] == ["past_end"] diff --git a/server/tests/test_requests.py b/server/tests/test_requests.py new file mode 100644 index 000000000..ae6e6bac7 --- /dev/null +++ b/server/tests/test_requests.py @@ -0,0 +1,297 @@ +"""What missing_evidence asks a homeowner for: nothing but ground past the end of the wall +(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, + D, + at_start, + check, + golden_rules, + observed_band, + pads_ground, + run, + shared_fixture, +) + +from rules import deep_merge, public_rules_dict, rules_from_dict +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: + """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 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 + 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: + result = run(facing_and_overhead_past_the_end()) + assert result["spot"]["span_ft"][1] > END - 1 + # 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 + + +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 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: + 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) + + +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, + 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 + # requests asked for. + raw = scene() + first = run(raw) + assert named(first), first["missing_evidence"] + second = run(captured_exactly(raw, first)) + left = named(first) & unseen(second) + 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. + 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 + # 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"): + assert label not in second["summary"], second["summary"] + + +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 ------------------------------------------ + + +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 (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 - 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 + + +@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 - GROWTH + 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 dce7a06ce..210edf480 100644 --- a/server/tests/test_s4_round.py +++ b/server/tests/test_s4_round.py @@ -4,15 +4,23 @@ 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 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 @@ -106,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) @@ -173,7 +197,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") @@ -182,7 +206,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 @@ -219,7 +243,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)