diff --git a/backend/server/eval/suite.py b/backend/server/eval/suite.py index a281279c8..dfd80bcb6 100644 --- a/backend/server/eval/suite.py +++ b/backend/server/eval/suite.py @@ -138,7 +138,24 @@ def _pipeline_rows(sites, inputs: Path, repo: Path, work: Path, engine_cfg: Crit site_dir = inputs / "scenes" / site / "gt-laser" target = f"{site}/gt-laser" out_dir = work / site / "criteria" - staged = _stage(site_dir, site, repo, work) + try: + staged = _stage(site_dir, site, repo, work) + except Exception as exc: + traceback.print_exc() + rows.append( + _row( + f"pipeline/{site}", + "pipeline", + target, + "criteria_engine", + None, + f"verdicts for {', '.join(engine_cfg.profiles)}", + "FAIL", + str(site_dir), + f"scene fixture missing: {_err(exc)}", + ) + ) + continue hint = "" if _has_visibility_inputs(staged) else " (laser PLY absent; run make gt)" try: produced = evaluate_scene(staged, out_dir, config=engine_cfg) @@ -180,6 +197,25 @@ def _pipeline_rows(sites, inputs: Path, repo: Path, work: Path, engine_cfg: Crit return rows +def _report_shape_error(report: object) -> str | None: + """Return a stable refusal for a parsed report with unusable containers.""" + if not isinstance(report, dict): + return f"criteria report must be an object, got {type(report).__name__}" + profiles = report.get("profiles") + if profiles is not None and ( + not isinstance(profiles, list) or not all(isinstance(profile, str) for profile in profiles) + ): + return "criteria report profiles must be a list of strings" + decidability = report.get("decidability") + if decidability is not None and not isinstance(decidability, dict): + return "criteria report decidability must be an object" + if isinstance(decidability, dict): + for profile, value in decidability.items(): + if not isinstance(profile, str) or not isinstance(value, dict): + return "criteria report decidability entries must be objects keyed by profile" + return None + + def _report_diff(produced: dict, committed: dict) -> list[str]: diffs = [] for field in REPORT_FIELDS: @@ -208,7 +244,39 @@ def _criteria_rows(sites, inputs: Path, work: Path): ) ) continue - produced = json.loads(report_path.read_text()) + try: + produced = json.loads(report_path.read_text()) + except ValueError as exc: + rows.append( + _row( + f"criteria/{site}/report", + "criteria", + f"{site}/gt-laser", + "criteria_report", + None, + "reproduces committed evidence", + "FAIL", + str(report_path), + f"produced criteria report is unreadable: {_err(exc)}", + ) + ) + continue + shape_error = _report_shape_error(produced) + if shape_error: + rows.append( + _row( + f"criteria/{site}/report", + "criteria", + f"{site}/gt-laser", + "criteria_report", + None, + "reproduces committed evidence", + "FAIL", + str(report_path), + shape_error, + ) + ) + continue shares = { p: (produced.get("decidability") or {}).get(p, {}).get("share") for p in produced.get("profiles") or [] } @@ -228,7 +296,39 @@ def _criteria_rows(sites, inputs: Path, work: Path): ) ) continue - committed = json.loads(committed_path.read_text()) + try: + committed = json.loads(committed_path.read_text()) + except ValueError as exc: + rows.append( + _row( + f"criteria/{site}/report", + "criteria", + f"{site}/gt-laser", + "criteria_report", + shares, + "reproduces committed evidence", + "FAIL", + str(committed_path), + f"committed criteria report is unreadable: {_err(exc)}", + ) + ) + continue + committed_shape_error = _report_shape_error(committed) + if committed_shape_error: + rows.append( + _row( + f"criteria/{site}/report", + "criteria", + f"{site}/gt-laser", + "criteria_report", + shares, + "reproduces committed evidence", + "FAIL", + str(committed_path), + committed_shape_error, + ) + ) + continue diffs = _report_diff(produced, committed) rows.append( _row( @@ -257,8 +357,12 @@ def _criteria_rows(sites, inputs: Path, work: Path): if not produced_path.is_file() or not ref_path.is_file(): diffs.append(f"{profile}: missing produced or reference verdict") continue - got = load_json(produced_path).get("site", {}).get("decision") - want = load_json(ref_path).get("site", {}).get("decision") + try: + got = load_json(produced_path).get("site", {}).get("decision") + want = load_json(ref_path).get("site", {}).get("decision") + except Exception as exc: + diffs.append(f"{profile}: unreadable produced or reference verdict: {_err(exc)}") + continue decisions[profile] = got if got != want: diffs.append(f"{profile}: produced {got!r} vs reference {want!r}") @@ -321,7 +425,24 @@ def _verdict_rows(sites, inputs: Path, work: Path, limits: dict): ) ) continue - results = score_files(verdict_path, [labels_path], out_dir=work / "verdict-scores" / site) + try: + results = score_files(verdict_path, [labels_path], out_dir=work / "verdict-scores" / site) + except Exception as exc: + traceback.print_exc() + rows.append( + _row( + f"verdict/{target}", + "verdicts", + target, + "verdictscore", + None, + "scored against committed labels", + "FAIL", + str(labels_path), + f"scoring failed: {_err(exc)}", + ) + ) + continue if not results: rows.append( _row( @@ -590,10 +711,15 @@ def run_suite(config_path: Path, inputs: Path, out: Path, repo: Path | None = No repo = repo or Path(__file__).resolve().parents[2] config_path, inputs, out = Path(config_path), Path(inputs), Path(out) cfg = yaml.safe_load(config_path.read_text()) or {} - sites = [ - site for site in (cfg.get("sites") or {}) if (inputs / "scenes" / site / "gt-laser" / "scene.json").is_file() - ] + # Every configured site stays in the run: a site whose scene fixture is missing + # is a FAIL row, never a silent omission — an empty or wrong --inputs directory + # must not exit 0 having evaluated nothing. + sites = list(cfg.get("sites") or {}) + # Each run reads only what it produced: verdicts and reports left in the work + # directory by an earlier run would otherwise stand in for this run's results. work = out / "work" + if work.exists(): + shutil.rmtree(work) engine_cfg = _engine_config(cfg) rows: list[dict] = [] rows.extend(_fixture_rows(sites, inputs)) diff --git a/backend/server/eval/tests/test_suite.py b/backend/server/eval/tests/test_suite.py index d3f3bf4d5..122ddca4f 100644 --- a/backend/server/eval/tests/test_suite.py +++ b/backend/server/eval/tests/test_suite.py @@ -11,7 +11,7 @@ from server.eval.adapters.criteria import CriteriaConfig, evaluate_scene from server.eval.coverage import MissingOccluderError -from server.eval.suite import _engine_config, _matches_reference, _reference, run_suite +from server.eval.suite import _criteria_rows, _engine_config, _matches_reference, _reference, run_suite from server.eval.tests.flat_wall import write as write_flat_wall ROOT = Path(__file__).resolve().parents[3] @@ -20,20 +20,24 @@ def _run(config: Path, inputs: Path, out: Path) -> dict: - report = run_suite(config, inputs, out, repo=ROOT) + run_suite(config, inputs, out, repo=ROOT) assert (out / "suite.json").is_file() assert (out / "SUMMARY.md").is_file() - assert json.loads((out / "suite.json").read_text())["format"] == "hss-eval-suite/v0" - return report + # Assertions below inspect the saved receipt, not the runner's return value: + # suite.json is the seam a machine consumes, so a writer change that drops + # scores, reasons or rows must fail here, on the broken and green paths alike. + saved = json.loads((out / "suite.json").read_text()) + assert saved["format"] == "hss-eval-suite/v0" + return saved def _config_for(tmp_path: Path, overrides: dict) -> Path: cfg = yaml.safe_load(CONFIG.read_text()) or {} for key, value in overrides.items(): - if isinstance(value, dict) and isinstance(cfg.get(key), dict): - cfg[key].update(value) - else: - cfg[key] = value + # Replace rather than merge: a synthetic inputs directory carries only the + # sites the test wrote, and a merged site list would name scenes the run + # will (rightly) fail as missing. + cfg[key] = value dest = tmp_path / "suite.yaml" dest.write_text(yaml.safe_dump(cfg)) return dest @@ -183,19 +187,24 @@ def test_committed_fixtures_suite(tmp_path: Path): gt = ROOT / ".fixtures" / "gt" / "eth3d-facade" / "gt-laser" if not (gt / "laser.ply").is_file() or not (gt / "occluders.ply").is_file(): pytest.skip("GT PLYs absent; run make gt for the full-fixture run") - has_recon_assets = bool(list((FIXTURES / "scenes").glob("*/colmap-*/points.ply"))) + recon_dirs = sorted((FIXTURES / "scenes").glob("*/colmap-*")) report = _run(CONFIG, FIXTURES, tmp_path / "out") failed = [row for row in report["rows"] if row["status"] == "FAIL"] - if not has_recon_assets: - # The recon PLYs are gitignored Modal-pipeline outputs. On a checkout - # without them the geometry rows fail closed with the produce hint and - # every other stage must still be green. - assert failed and all( - row["id"].startswith("geometry/") and "scripts/modal_scene.py" in row["note"] for row in failed - ), json.dumps(failed, indent=1, default=str) - assert report["summary"]["pass"] > 0 - return - assert not failed, json.dumps(failed, indent=1, default=str) + produce_hint = [ + row for row in failed if row["id"].startswith("geometry/") and "scripts/modal_scene.py" in row["note"] + ] + other = [row for row in failed if row not in produce_hint] + # The recon PLYs are gitignored Modal-pipeline outputs. A recon cloud whose + # points.ply is absent must produce exactly its produce-hint failure — and + # only that: a restored cloud that fails, or any unrelated failure, is a bug. + # Checking per recon row keeps a partial restore (one cloud present, one + # absent) a pass instead of a broken test. + assert not other, json.dumps(other, indent=1, default=str) + expected_missing = sum(1 for d in recon_dirs if (d / "scene.json").is_file() and not (d / "points.ply").is_file()) + if expected_missing == 0: + assert not produce_hint, json.dumps(produce_hint, indent=1, default=str) + else: + assert len(produce_hint) == expected_missing, json.dumps(produce_hint, indent=1, default=str) assert report["summary"]["pass"] > 0 @@ -253,11 +262,12 @@ def broken(*args, **kwargs): assert all(row["status"] == "FAIL" and "criteria engine failed" in row["note"] for row in by_group["pipeline"]) # skip-equivalent: the public profile has no committed labels, so it is # MEASURED (recorded, no bar) rather than FAIL or silently omitted - assert all( - row["id"].endswith("/public") and row["status"] == "MEASURED" - for row in by_group["verdicts"] - if row["id"].endswith("/public") + configured_sites = set(yaml.safe_load(CONFIG.read_text())["sites"]) + public_ids = {f"verdict/{site}/public" for site in configured_sites} + assert {row["id"] for row in by_group["verdicts"] if row["id"].endswith("/public")} == public_ids, sorted( + row["id"] for row in by_group["verdicts"] ) + assert all(row["status"] == "MEASURED" for row in by_group["verdicts"] if row["id"] in public_ids) assert any(row["status"] == "FAIL" for row in by_group["verdicts"] if not row["id"].endswith("/public")) # same contract on the green path: scored verdict rows keep the score keys monkeypatch.undo() @@ -300,3 +310,146 @@ def broken(*args, **kwargs): assert green["summary"]["fail"] == 0 verdict = next(row for row in green["rows"] if row["id"] == "verdict/flat-wall/C") assert verdict["status"] == "PASS" and verdict["value"]["unsafe_passes"] == 0 + + +def _synthetic_site(tmp_path: Path) -> tuple[Path, Path]: + """A flat-wall run the engine scores green: scene, empty C labels, and the + config naming exactly this site.""" + inputs = tmp_path / "inputs" + scene = write_flat_wall(inputs / "scenes" / "flat-wall" / "gt-laser") + probe = evaluate_scene( + scene, + tmp_path / "probe", + config=CriteriaConfig(profiles=("C",), ground_rule="angle80", c2=False, c3=False), + ) + labels_dir = inputs / "labels" + labels_dir.mkdir(parents=True) + (labels_dir / "flat-wall.C.json").write_text( + json.dumps( + { + "format": "base-labels/v0", + "site": "synthetic-flatwall", + "rules_profile": "C", + "site_decision": probe["C"]["site"]["decision"], + "labels": [], + } + ) + + "\n" + ) + config = _config_for( + tmp_path, + { + "sites": {"flat-wall": {"labels": ["C"]}}, + "suite": {"criteria": {"profiles": ["C"], "ground_rule": "angle80", "c2": False}}, + "verdicts": { + "unsafe_passes_max": 0, + "false_rejections_max": 0, + "coverage_contract_errors_max": 0, + "c5_margin_errors_max": 0, + }, + }, + ) + return inputs, config + + +def test_a_broken_labels_file_fails_its_row_and_the_run_still_reports(tmp_path: Path): + """One malformed labels file is a FAIL verdict row with the scoring error — + suite.json and SUMMARY.md still land, and the remaining rows still score.""" + inputs, config = _synthetic_site(tmp_path) + (inputs / "labels" / "flat-wall.C.json").write_text("{not json\n") + + report = _run(config, inputs, tmp_path / "out") + + by_id = {row["id"]: row for row in report["rows"]} + verdict = by_id["verdict/flat-wall/C"] + assert verdict["status"] == "FAIL" + assert "scoring failed" in verdict["note"] + assert report["summary"]["fail"] >= 1 + assert (tmp_path / "out" / "SUMMARY.md").is_file() + + +def test_malformed_produced_criteria_report_is_a_fail_row(tmp_path: Path): + """A parsed JSON scalar or list must fail its row instead of crashing the suite.""" + inputs = tmp_path / "inputs" + work = tmp_path / "work" + report_path = work / "flat-wall" / "criteria" / "criteria_report.json" + report_path.parent.mkdir(parents=True) + report_path.write_text("null\n") + + rows = _criteria_rows(["flat-wall"], inputs, work) + + assert rows[0]["status"] == "FAIL" + assert "must be an object" in rows[0]["note"] + + +def test_malformed_committed_criteria_report_is_a_fail_row(tmp_path: Path): + inputs = tmp_path / "inputs" + work = tmp_path / "work" + report_path = work / "flat-wall" / "criteria" / "criteria_report.json" + report_path.parent.mkdir(parents=True) + report_path.write_text(json.dumps({"profiles": ["C"], "decidability": {"C": {"share": 1.0}}})) + committed = inputs / "criteria" / "flat-wall" / "gt-laser" + committed.mkdir(parents=True) + (committed / "criteria_report.json").write_text("[]\n") + + rows = _criteria_rows(["flat-wall"], inputs, work) + + assert rows[0]["status"] == "FAIL" + assert "must be an object" in rows[0]["note"] + + +def test_a_broken_committed_criteria_report_fails_its_row(tmp_path: Path): + """The committed criteria report is read per row: a malformed one is a FAIL + row naming the unreadable file, not a crashed run.""" + inputs, config = _synthetic_site(tmp_path) + committed = inputs / "criteria" / "flat-wall" / "gt-laser" + committed.mkdir(parents=True) + (committed / "criteria_report.json").write_text("{not json\n") + + report = _run(config, inputs, tmp_path / "out") + + row = {r["id"]: r for r in report["rows"]}["criteria/flat-wall/report"] + assert row["status"] == "FAIL" + assert "committed criteria report is unreadable" in row["note"] + + +def test_a_configured_site_without_its_scene_fails_the_run(tmp_path: Path): + """A configured site whose scene fixture is absent is a FAIL row on every + stage it reaches: an empty or wrong --inputs directory must not exit 0 + having evaluated nothing.""" + inputs = tmp_path / "inputs" + inputs.mkdir() + config = _config_for(tmp_path, {"sites": {"ghost": {"labels": ["C"]}}}) + + report = _run(config, inputs, tmp_path / "out") + + by_id = {row["id"]: row for row in report["rows"]} + assert by_id["fixtures/ghost"]["status"] == "FAIL" + assert "scene.json" in by_id["fixtures/ghost"]["note"] + assert by_id["pipeline/ghost"]["status"] == "FAIL" + assert "scene fixture missing" in by_id["pipeline/ghost"]["note"] + assert report["summary"]["fail"] >= 1 + assert report["summary"]["pass"] == 0 + + +def test_the_work_directory_is_cleared_between_runs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Each run reads only its own output: after a green run, a broken engine + must leave the criteria rows failing on missing reports, not passing on the + stale ones the previous run left in the work directory.""" + inputs, config = _synthetic_site(tmp_path) + out = tmp_path / "out" + green = _run(config, inputs, out) + assert green["summary"]["fail"] == 0 + stale = out / "work" / "flat-wall" / "criteria" / "criteria_report.json" + assert stale.is_file() + + def broken(*args, **kwargs): + raise ImportError("not_merged:ws-09") + + monkeypatch.setattr("server.eval.suite.evaluate_scene", broken) + second = _run(config, inputs, out) + + by_id = {row["id"]: row for row in second["rows"]} + assert by_id["pipeline/flat-wall"]["status"] == "FAIL" + assert by_id["criteria/flat-wall/report"]["status"] == "FAIL" + assert "produced no criteria report" in by_id["criteria/flat-wall/report"]["note"] diff --git a/backend/server/orchestrator/tests/test_greptile_followup.py b/backend/server/orchestrator/tests/test_greptile_followup.py index 69a975242..f35446e42 100644 --- a/backend/server/orchestrator/tests/test_greptile_followup.py +++ b/backend/server/orchestrator/tests/test_greptile_followup.py @@ -160,7 +160,18 @@ def test_confirmation_updates_the_reads_record(tmp_path: Path) -> None: # The recompose preserves the upstream work as terminal records at attempt 1 # — the re-run reads stage that once threatened the answer never executes. rows = {rec.stage: rec for rec in services.state.stages("run_reads")} - for upstream in ("validate", "poses", "objects", "reads", "scale", "dense", "coverage", "scene", "objects_3d", "placement"): + for upstream in ( + "validate", + "poses", + "objects", + "reads", + "scale", + "dense", + "coverage", + "scene", + "objects_3d", + "placement", + ): assert rows[upstream].status == "done", upstream assert rows[upstream].attempt == 1, upstream assert rows["criteria"].status == "done" diff --git a/backend/server/placement/scene_bridge.py b/backend/server/placement/scene_bridge.py index aada91a63..70acebe3d 100644 --- a/backend/server/placement/scene_bridge.py +++ b/backend/server/placement/scene_bridge.py @@ -107,6 +107,9 @@ UNSUPPORTED = "placement_scene_unsupported" INVALID = "placement_scene_invalid" +# scene.schema.json: coverage.observed allows at most 500 band entries. +_WIRE_MAX_OBSERVED_BANDS = 500 + # 1 ft = 0.3048 m exactly, so lengths convert with one exact factor in one direction: # dividing by 0.3048, the correctly rounded conversion, never multiplying by a rounded # reciprocal. @@ -585,9 +588,19 @@ def end_out(end, side: str) -> dict[str, Any]: observed: list[dict[str, Any]] = [] - def band_out(band, pointer: str) -> dict[str, Any]: + def band_out(band, pointer: str, *, require_ground_depth: bool) -> dict[str, Any]: if band.wall_id is not None and band.wall_id != wall_id: _invalid(f"the band names unknown wall {band.wall_id!r}", f"{pointer}/wall_id") + if require_ground_depth and band.band == "ground" and band.out_m is None: + # scene.schema.json requires out_ft on every ground band ("the ground in + # front of the wall out to out_ft"), while the source contract leaves + # out_m optional — this band cannot be represented, so refuse rather + # than guess a depth the capture never measured. + _unsupported( + "a ground band without a measured depth (out_m) has no wire out_ft; " + "the ground's depth was never captured", + f"{pointer}/out_m", + ) record: dict[str, Any] = { "band": band.band, "span_ft": checks.span_ft(band.s_m, f"{pointer}/s_m", bounded=False), @@ -597,15 +610,22 @@ def band_out(band, pointer: str) -> dict[str, Any]: return record for index, band in enumerate(scene.coverage.bands or []): - observed.append(band_out(band, f"/coverage/bands/{index}")) + observed.append(band_out(band, f"/coverage/bands/{index}", require_ground_depth=True)) for sensitivity_index, sensitivity in enumerate(scene.coverage.sensitivity or []): for band_index, band in enumerate(sensitivity.bands): # Sensitivity variants are validated but are not observations: the wire's # observed list is the ground-truth coverage the device saw, and a what-if # sensitivity band is not that. A sensitivity-only scene still converts to # ends only — no observation is invented. - band_out(band, f"/coverage/sensitivity/{sensitivity_index}/bands/{band_index}") + band_out(band, f"/coverage/sensitivity/{sensitivity_index}/bands/{band_index}", require_ground_depth=False) + if len(observed) > _WIRE_MAX_OBSERVED_BANDS: + _unsupported( + f"{len(observed)} observed bands exceed the wire scene's limit of " + f"{_WIRE_MAX_OBSERVED_BANDS} (scene.schema.json coverage.observed maxItems); " + "thin or merge the capture's bands before conversion", + "/coverage/bands", + ) if observed: out["observed"] = observed return out diff --git a/backend/server/placement/tests/data/capture_cases_v1/manifest.json b/backend/server/placement/tests/data/capture_cases_v1/manifest.json index 2a5f278d5..e53e4ea62 100644 --- a/backend/server/placement/tests/data/capture_cases_v1/manifest.json +++ b/backend/server/placement/tests/data/capture_cases_v1/manifest.json @@ -188,7 +188,7 @@ "kind": "negative_source", "source": { "path": "multiwall-source-unsupported.source.json", - "sha256": "3e13bace8381dd56bdaaa6e5e874cca488c8ea08617eb09c8bf4e19b8c75de05" + "sha256": "a4c52e79da950e0d22e3853cb9218f7e9252765a1b66739c8039f972a771d652" }, "wire": null, "expected_error": { diff --git a/backend/server/placement/tests/data/capture_cases_v1/multiwall-source-unsupported.source.json b/backend/server/placement/tests/data/capture_cases_v1/multiwall-source-unsupported.source.json index 18f8ab413..9b8a28249 100644 --- a/backend/server/placement/tests/data/capture_cases_v1/multiwall-source-unsupported.source.json +++ b/backend/server/placement/tests/data/capture_cases_v1/multiwall-source-unsupported.source.json @@ -11,7 +11,7 @@ "gravity_source": "ground_truth", "site": "burst8-multiwall-unsupported", "notes": [ - "Authored for the burst8 supported-case packet: an L of two house walls, everything else inside the bridge's bounded scope (observed on-wall meter anchor, observed wall_rect hazard, level ground, band coverage). The chain has two walls, so the bridge must refuse it at /walls with placement_scene_unsupported." + "Authored for the burst8 supported-case packet: an L of two house walls — rear turns 90 degrees from side's right end — everything else inside the bridge's bounded scope (observed on-wall meter anchor, observed wall_rect hazard, level ground, band coverage). The chain has two walls, so the bridge must refuse it at /walls with placement_scene_unsupported." ] }, "frame": { @@ -45,12 +45,12 @@ "role": "house", "baseline_m": [ [ - -0.4572, + 1.524, 0.0 ], [ - 0.9144, - 0.0 + 1.524, + -0.9144 ] ], "height_m": 2.7432, @@ -70,9 +70,24 @@ }, "right": { "kind": "limit", - "s_m": 1.524 + "s_m": 2.4384 } - } + }, + "corners": [ + { + "id": "corner-side-rear", + "left_wall": "side", + "right_wall": "rear", + "point_m": [ + 1.524, + 0.0 + ], + "s_m": 1.524, + "turn_deg": 90.0, + "plus_minus_m": 0.09, + "evidence": "observed" + } + ] }, "ground": { "surface": { diff --git a/backend/server/placement/tests/test_scene_bridge.py b/backend/server/placement/tests/test_scene_bridge.py index 21904e1e2..2a517acde 100644 --- a/backend/server/placement/tests/test_scene_bridge.py +++ b/backend/server/placement/tests/test_scene_bridge.py @@ -15,7 +15,7 @@ from typing import Any import pytest -from jsonschema import Draft202012Validator +from jsonschema import Draft202012Validator, ValidationError from server.placement.adapter import load_solver from server.placement.scene_bridge import ( @@ -339,6 +339,23 @@ def test_meter_wall_rect_geometry_places_the_meter_at_its_span_centre_mid_height assert parsed.point_at(0.0)[0] == pytest.approx(0.0, abs=1e-6) +def test_ground_sensitivity_without_depth_is_discarded() -> None: + """Sensitivity bands are validated for span but never emitted to the wire.""" + document = base_scene() + document["coverage"]["sensitivity"] = [ + { + "surface": "ground", + "max_incidence_deg": 75, + "bands": [{"band": "ground", "s_m": [-2.0, 4.0]}], + } + ] + + converted = convert_scene(document) + + assert "observed" in converted["coverage"] + assert all(band["band"] != "ground" for band in converted["coverage"]["observed"]) + + def test_sensitivity_only_coverage_converts_to_ends_only() -> None: """A sensitivity band is a what-if variant, not a device observation: a scene whose coverage carries only sensitivity bands must convert to ends only, with @@ -706,3 +723,53 @@ def test_the_committed_orchestrator_stub_scene_converts_and_parses() -> None: "left": {"kind": "limit"}, "right": {"kind": "limit"}, } + + +def test_a_ground_band_without_a_measured_depth_refuses() -> None: + """The source contract leaves out_m optional, but the wire scene requires out_ft + on every ground band. The bridge must refuse the band at its depth instead of + converting a document the root contract rejects — and never guess a depth.""" + document = base_scene() + document["coverage"]["bands"] = [{"band": "ground", "s_m": [-1.0, 1.0]}] + + with pytest.raises(SceneBridgeError) as excinfo: + convert_scene(document) + + assert excinfo.value.code == UNSUPPORTED + assert excinfo.value.pointer == "/coverage/bands/0/out_m" + + # The premise, checked against the root contract: an observed ground band + # without out_ft is exactly what scene.schema.json refuses. + with pytest.raises(ValidationError): + _WIRE_SCHEMA.validate({"band": "ground", "span_ft": [-1.0 / 0.3048, 1.0 / 0.3048]}) + + # A measured ground band converts and validates as an observation. + document["coverage"]["bands"] = [{"band": "ground", "s_m": [-1.0, 1.0], "out_m": 2.5}] + converted = convert_scene(document) + _WIRE_SCHEMA.validate(converted) + assert converted["coverage"]["observed"] == [ + {"band": "ground", "span_ft": [-1.0 / 0.3048, 1.0 / 0.3048], "out_ft": 2.5 / 0.3048} + ] + + +def test_observed_band_counts_past_the_wire_limit_refuse() -> None: + """scene.schema.json caps coverage.observed at 500 entries: a valid source with + 501 bands must refuse at conversion, not produce a wire scene the placement + server cannot read.""" + document = base_scene() + document["coverage"]["bands"] = [ + {"band": "wall", "s_m": [-1.0 + index * 0.01, 1.0 + index * 0.01]} for index in range(501) + ] + + with pytest.raises(SceneBridgeError) as excinfo: + convert_scene(document) + + assert excinfo.value.code == UNSUPPORTED + assert excinfo.value.pointer == "/coverage/bands" + assert "500" in str(excinfo.value) + + # The limit itself still converts, and the output is a valid wire scene. + document["coverage"]["bands"] = document["coverage"]["bands"][:500] + converted = convert_scene(document) + assert len(converted["coverage"]["observed"]) == 500 + _WIRE_SCHEMA.validate(converted)