feat(polygon): qualify GOOSE ground providers
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import pytest
|
||||
|
||||
from k1link.datasets import goose_qualification
|
||||
from k1link.datasets.goose_qualification import (
|
||||
DegradationProfile,
|
||||
qualify_goose_ground,
|
||||
)
|
||||
from k1link.ground_segmentation import GroundSegmentation
|
||||
from k1link.simulation import QualificationRunStore, RunState
|
||||
|
||||
|
||||
class _NeverGround:
|
||||
@property
|
||||
def identity(self) -> Mapping[str, object]:
|
||||
return {"provider_id": "test-current", "revision": "1"}
|
||||
|
||||
def segment(self, xyzi: npt.NDArray[np.float32]) -> GroundSegmentation:
|
||||
count = xyzi.shape[0]
|
||||
return GroundSegmentation(
|
||||
ground_mask=np.zeros(count, dtype=np.bool_),
|
||||
assigned_mask=np.ones(count, dtype=np.bool_),
|
||||
latency_ms=2.0,
|
||||
)
|
||||
|
||||
|
||||
class _NegativeZGround:
|
||||
@property
|
||||
def identity(self) -> Mapping[str, object]:
|
||||
return {"provider_id": "test-patchwork", "revision": "1"}
|
||||
|
||||
def segment(self, xyzi: npt.NDArray[np.float32]) -> GroundSegmentation:
|
||||
count = xyzi.shape[0]
|
||||
return GroundSegmentation(
|
||||
ground_mask=np.asarray(xyzi[:, 2] < 0, dtype=np.bool_),
|
||||
assigned_mask=np.ones(count, dtype=np.bool_),
|
||||
latency_ms=3.0,
|
||||
)
|
||||
|
||||
|
||||
def _frame_bytes(offset: float) -> tuple[bytes, bytes]:
|
||||
points = (
|
||||
(-1.0 + offset, 0.0, -2.0, 0.1),
|
||||
(0.0 + offset, 0.0, -2.0, 0.2),
|
||||
(1.0 + offset, 0.0, 2.0, 0.3),
|
||||
(2.0 + offset, 0.0, 2.0, 0.4),
|
||||
)
|
||||
labels = (1, 3, 2, 2)
|
||||
return (
|
||||
b"".join(struct.pack("<ffff", *point) for point in points),
|
||||
b"".join(struct.pack("<I", label) for label in labels),
|
||||
)
|
||||
|
||||
|
||||
def _dataset(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "datasets"
|
||||
archive_sha = "a" * 64
|
||||
archive = root / "goose-3d/v2025-08-22/archives/goose_3d_val.zip"
|
||||
archive.parent.mkdir(parents=True)
|
||||
with zipfile.ZipFile(archive, "w") as target:
|
||||
for index in range(2):
|
||||
frame_id = f"2022-01-01_flight__0001_{index:019d}"
|
||||
points, labels = _frame_bytes(index * 0.1)
|
||||
target.writestr(f"goose/lidar/val/{frame_id}_vls128.bin", points)
|
||||
target.writestr(f"goose/labels/val/{frame_id}_goose.label", labels)
|
||||
state = root / "state/goose-3d-v2025-08-22.json"
|
||||
state.parent.mkdir(parents=True)
|
||||
state.write_text(json.dumps({"archive": {"sha256": archive_sha}}), encoding="utf-8")
|
||||
install = root / "goose-3d/v2025-08-22/installs" / archive_sha
|
||||
install.mkdir(parents=True)
|
||||
(install / "goose_label_mapping.csv").write_text(
|
||||
"class_name,label_key,hex\nasphalt,1,#111111\nrock,2,#222222\nsoil,3,#333333\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def test_full_split_qualification_is_sealed_and_resumable(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
root = _dataset(tmp_path)
|
||||
runs_root = tmp_path / "runs"
|
||||
monkeypatch.setattr(goose_qualification, "_is_worker_dataset_root", lambda _: True)
|
||||
|
||||
report = qualify_goose_ground(
|
||||
root,
|
||||
runs_root,
|
||||
mission_core_commit="1" * 40,
|
||||
parallel_workers=1,
|
||||
expected_frame_count=2,
|
||||
current_segmenter=_NeverGround(),
|
||||
patchwork_segmenter=_NegativeZGround(),
|
||||
degradations=(DegradationProfile("range-20m", "maximum-range-m", 20.0),),
|
||||
)
|
||||
|
||||
assert report["frame_count"] == 2
|
||||
assert report["decision"]["status"] == "shadow-candidate"
|
||||
assert report["aggregates"]["current"]["micro"]["ground_iou"] == 0
|
||||
assert report["aggregates"]["patchworkpp"]["micro"]["ground_iou"] == 1
|
||||
assert len(report["frames"]) == 2
|
||||
run = QualificationRunStore(runs_root, read_only=True).load(report["run_id"])
|
||||
assert run.state is RunState.COMPLETED
|
||||
assert {artifact.kind for artifact in run.artifacts} == {
|
||||
"goose-ground-qualification-report",
|
||||
"goose-ground-qualification-failure-preview",
|
||||
}
|
||||
|
||||
resumed = qualify_goose_ground(
|
||||
root,
|
||||
runs_root,
|
||||
mission_core_commit="1" * 40,
|
||||
parallel_workers=1,
|
||||
expected_frame_count=2,
|
||||
current_segmenter=_NeverGround(),
|
||||
patchwork_segmenter=_NegativeZGround(),
|
||||
degradations=(DegradationProfile("range-20m", "maximum-range-m", 20.0),),
|
||||
)
|
||||
assert resumed["identity_sha256"] == report["identity_sha256"]
|
||||
+143
-3
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -191,6 +193,146 @@ def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
|
||||
assert "целостности" in failure.value.detail
|
||||
|
||||
|
||||
def _qualification_run(root: Path) -> tuple[QualificationRun, str]:
|
||||
store = QualificationRunStore(root)
|
||||
admitted = store.create(_run("goose-ground-test"))
|
||||
starting = store.transition(
|
||||
admitted.run_id,
|
||||
RunState.STARTING,
|
||||
expected_revision=0,
|
||||
observed_at_utc="2026-07-24T15:35:01Z",
|
||||
host_monotonic_ns=1,
|
||||
)
|
||||
store.transition(
|
||||
admitted.run_id,
|
||||
RunState.RUNNING,
|
||||
expected_revision=starting.revision,
|
||||
observed_at_utc="2026-07-24T15:35:02Z",
|
||||
host_monotonic_ns=2,
|
||||
)
|
||||
evidence = root / admitted.run_id / "evidence"
|
||||
failures = evidence / "failures"
|
||||
failures.mkdir(parents=True)
|
||||
frame_id = "2022-07-22_flight__0071_0001"
|
||||
report = {
|
||||
"schema_version": "missioncore.goose-ground-qualification-report/v1",
|
||||
"identity_sha256": SHA_A,
|
||||
"source_id": "goose-3d/v2025-08-22",
|
||||
"split": "validation",
|
||||
"frame_count": 961,
|
||||
"aggregates": {"current": {}, "patchworkpp": {}},
|
||||
"degradations": {},
|
||||
"checks": [],
|
||||
"worst_frames": [{"frame_id": frame_id}],
|
||||
"decision": {"status": "shadow-candidate", "passed": True},
|
||||
"safety": {"navigation_or_safety_accepted": False},
|
||||
"frames": [{"private": "not-published"}],
|
||||
}
|
||||
preview = {
|
||||
"schema_version": "missioncore.goose-ground-qualification-failure-preview/v1",
|
||||
"source_id": "goose-3d/v2025-08-22",
|
||||
"frame_id": frame_id,
|
||||
"source_point_count": 2,
|
||||
"point_count": 2,
|
||||
"sampling": "deterministic-even-index",
|
||||
"points_xyz_m": [[0, 0, 0], [1, 1, 1]],
|
||||
"ground_truth_ground": [1, 0],
|
||||
"evaluated": [1, 1],
|
||||
"current_ground": [0, 0],
|
||||
"patchwork_ground": [1, 0],
|
||||
"current_disagreement": [1, 0],
|
||||
"patchwork_disagreement": [0, 0],
|
||||
"safety": {"navigation_or_safety_accepted": False},
|
||||
}
|
||||
for artifact_id, kind, path, value in (
|
||||
(
|
||||
"qualification-report",
|
||||
"goose-ground-qualification-report",
|
||||
evidence / "qualification.json",
|
||||
report,
|
||||
),
|
||||
(
|
||||
"failure-preview-01",
|
||||
"goose-ground-qualification-failure-preview",
|
||||
failures / f"{frame_id}.json",
|
||||
preview,
|
||||
),
|
||||
):
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
store.register_artifact(
|
||||
admitted.run_id,
|
||||
QualificationArtifact(
|
||||
artifact_id=artifact_id,
|
||||
kind=kind,
|
||||
relative_path=path.relative_to(root / admitted.run_id).as_posix(),
|
||||
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
byte_length=path.stat().st_size,
|
||||
source_of_record=True,
|
||||
),
|
||||
)
|
||||
current = store.load(admitted.run_id)
|
||||
stopping = store.transition(
|
||||
admitted.run_id,
|
||||
RunState.STOPPING,
|
||||
expected_revision=current.revision,
|
||||
observed_at_utc="2026-07-24T15:35:04Z",
|
||||
host_monotonic_ns=4,
|
||||
)
|
||||
completed = store.transition(
|
||||
admitted.run_id,
|
||||
RunState.COMPLETED,
|
||||
expected_revision=stopping.revision,
|
||||
observed_at_utc="2026-07-24T15:35:05Z",
|
||||
host_monotonic_ns=5,
|
||||
reason="qualification-evidence-sealed",
|
||||
)
|
||||
return completed, frame_id
|
||||
|
||||
|
||||
def test_polygon_api_publishes_verified_ground_qualification(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "runs"
|
||||
run, frame_id = _qualification_run(root)
|
||||
router = build_polygon_router(root_provider=lambda: root)
|
||||
|
||||
qualification = _endpoint(
|
||||
router,
|
||||
"/api/v1/polygon/runs/{run_id}/qualification",
|
||||
"GET",
|
||||
)(run_id=run.run_id)
|
||||
preview = _endpoint(
|
||||
router,
|
||||
"/api/v1/polygon/runs/{run_id}/qualification/failures/{frame_id}",
|
||||
"GET",
|
||||
)(run_id=run.run_id, frame_id=frame_id)
|
||||
|
||||
assert qualification["schema_version"] == "missioncore.polygon-ground-qualification/v1"
|
||||
assert qualification["frame_count"] == 961
|
||||
assert "frames" not in qualification
|
||||
assert preview["schema_version"] == "missioncore.polygon-ground-failure-preview/v1"
|
||||
assert preview["frame_id"] == frame_id
|
||||
assert str(tmp_path) not in repr({"qualification": qualification, "preview": preview})
|
||||
|
||||
|
||||
def test_polygon_api_rejects_tampered_ground_qualification(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "runs"
|
||||
run, _ = _qualification_run(root)
|
||||
(root / run.run_id / "evidence/qualification.json").write_text("{}", encoding="utf-8")
|
||||
router = build_polygon_router(root_provider=lambda: root)
|
||||
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
_endpoint(
|
||||
router,
|
||||
"/api/v1/polygon/runs/{run_id}/qualification",
|
||||
"GET",
|
||||
)(run_id=run.run_id)
|
||||
assert failure.value.status_code == 500
|
||||
assert "SHA-256" in failure.value.detail or "файла" in failure.value.detail
|
||||
|
||||
|
||||
class _FakeWorkerGateway:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
@@ -286,9 +428,7 @@ class _FakeWorkerGateway:
|
||||
"control_available": True,
|
||||
"active_run_id": self.active_run_id,
|
||||
"run_state": "running" if self.active_run_id else None,
|
||||
"active_provider_ids": (
|
||||
["px4-gazebo-stock-rover"] if self.active_run_id else []
|
||||
),
|
||||
"active_provider_ids": (["px4-gazebo-stock-rover"] if self.active_run_id else []),
|
||||
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
||||
"isolation": {
|
||||
"network": "loopback-only-netns",
|
||||
|
||||
Reference in New Issue
Block a user