feat(perception): qualify E35 degradation recovery
This commit is contained in:
@@ -26,7 +26,7 @@ def _endpoint(router: APIRouter, path: str) -> object:
|
||||
def test_advanced_catalogs_are_empty_when_not_configured() -> None:
|
||||
router = build_advanced_laboratory_router()
|
||||
|
||||
for name in ("e31", "e32", "e33", "e34"):
|
||||
for name in ("e31", "e32", "e33", "e34", "e35"):
|
||||
route = _endpoint(router, f"/api/v1/laboratory/{name}/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
assert catalog == {
|
||||
@@ -46,20 +46,23 @@ def test_advanced_catalogs_fail_closed_on_incomplete_results(
|
||||
e32 = tmp_path / "e32"
|
||||
e33 = tmp_path / "e33"
|
||||
e34 = tmp_path / "e34"
|
||||
for root in (e31, e32, e33, e34):
|
||||
e35 = tmp_path / "e35"
|
||||
for root in (e31, e32, e33, e34, e35):
|
||||
root.mkdir()
|
||||
(e31 / f"e31-source-qualification-{'1' * 64}").mkdir()
|
||||
(e32 / f"e32-track-geometry-{'2' * 64}").mkdir()
|
||||
(e33 / f"e33-worker-shadow-{'3' * 64}").mkdir()
|
||||
(e34 / f"e34-temporal-occupied-{'4' * 64}").mkdir()
|
||||
(e35 / f"e35-degradation-recovery-{'5' * 64}").mkdir()
|
||||
router = build_advanced_laboratory_router(
|
||||
e31_root_provider=lambda: e31,
|
||||
e32_root_provider=lambda: e32,
|
||||
e33_root_provider=lambda: e33,
|
||||
e34_root_provider=lambda: e34,
|
||||
e35_root_provider=lambda: e35,
|
||||
)
|
||||
|
||||
for name in ("e31", "e32", "e33", "e34"):
|
||||
for name in ("e31", "e32", "e33", "e34", "e35"):
|
||||
route = _endpoint(router, f"/api/v1/laboratory/{name}/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
assert catalog["configured"] is True
|
||||
@@ -209,3 +212,103 @@ def test_e34_catalog_projects_only_accepted_read_only_evidence(
|
||||
assert item["metrics"]["free_cell_rows"] == 0
|
||||
assert item["authority"] == authority
|
||||
assert item["access"] == "read-only"
|
||||
|
||||
|
||||
def test_e35_catalog_projects_recovery_and_review(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
result_id = f"e35-degradation-recovery-{'5' * 64}"
|
||||
root = tmp_path / "e35"
|
||||
candidate = root / result_id
|
||||
candidate.mkdir(parents=True)
|
||||
(candidate / "manifest.json").write_text("{}", encoding="utf-8")
|
||||
authority = {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
result_id=result_id,
|
||||
accepted=True,
|
||||
manifest={
|
||||
"created_at_utc": "2026-07-27T15:00:00Z",
|
||||
"acceptance_state": (
|
||||
"accepted-deterministic-degradation-recovery"
|
||||
),
|
||||
"identity": {
|
||||
"source_session_id": "source-session",
|
||||
"e32_result_id": f"e32-track-geometry-{'2' * 64}",
|
||||
"e33_result_id": f"e33-worker-shadow-{'3' * 64}",
|
||||
"e34_result_id": f"e34-temporal-occupied-{'4' * 64}",
|
||||
"pipeline": (
|
||||
"track-geometry/temporal-layer/"
|
||||
"degradation-recovery/v1"
|
||||
),
|
||||
"coordinate_frame": "map",
|
||||
"profile": {
|
||||
"profile_id": (
|
||||
"e35-six-channel-degradation-recovery/v1"
|
||||
),
|
||||
"acceptance": {
|
||||
"maximum_recovery_seconds": 0.25,
|
||||
"expected_scenario_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
report={
|
||||
"metrics": {
|
||||
"accounting": {
|
||||
"source_frames": 4489,
|
||||
"variant_count": 6,
|
||||
"variant_frame_outcomes": 26934,
|
||||
"injection_records": 360,
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario_id": "camera-loss",
|
||||
"recovery_seconds": 0.086,
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"build_elapsed_ms": 18618.0,
|
||||
"variant_frame_processing_ms": {"p95": 1.08},
|
||||
},
|
||||
},
|
||||
"acceptance": {"accepted": True, "requirements": {}},
|
||||
"decision": {"next_gate": "A9/E36"},
|
||||
"authority": authority,
|
||||
},
|
||||
review={
|
||||
"schema_version": "missioncore.e35-recovery-review/v1",
|
||||
"result_id": result_id,
|
||||
"scenarios": [],
|
||||
},
|
||||
)
|
||||
|
||||
def fake_read(
|
||||
root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> SimpleNamespace:
|
||||
assert root_text == str(candidate.resolve())
|
||||
assert signature
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(advanced_api, "_read_e35_cached", fake_read)
|
||||
router = build_advanced_laboratory_router(
|
||||
e35_root_provider=lambda: root,
|
||||
)
|
||||
route = _endpoint(router, "/api/v1/laboratory/e35/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
|
||||
assert catalog["candidate_total"] == 1
|
||||
assert catalog["invalid_total"] == 0
|
||||
item = catalog["items"][0]
|
||||
assert item["status"] == (
|
||||
"accepted-deterministic-degradation-recovery"
|
||||
)
|
||||
assert item["metrics"]["variant_frame_outcomes"] == 26934
|
||||
assert item["configuration"]["maximum_recovery_seconds"] == 0.25
|
||||
assert item["review"]["scenarios"] == []
|
||||
assert item["authority"] == authority
|
||||
assert item["access"] == "read-only"
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.compute.degradation_recovery import (
|
||||
DegradationKind,
|
||||
DegradationRecoveryError,
|
||||
DegradationScenario,
|
||||
transform_track_geometry_frame,
|
||||
)
|
||||
from k1link.compute.e35_degradation_replay import (
|
||||
E35DegradationReplayError,
|
||||
read_e35_degradation_profile,
|
||||
)
|
||||
from k1link.compute.track_geometry import (
|
||||
PointSlab,
|
||||
TrackGeometry,
|
||||
TrackGeometryCurrentness,
|
||||
TrackGeometryEvidenceState,
|
||||
TrackGeometryFrame,
|
||||
TrackGeometryMetricBasis,
|
||||
TrackGeometryOwnerKind,
|
||||
TrackGeometrySourceBinding,
|
||||
)
|
||||
|
||||
|
||||
def _binding() -> TrackGeometrySourceBinding:
|
||||
return TrackGeometrySourceBinding(
|
||||
source_pack_id="e10-lidar-pack-" + "a" * 64,
|
||||
source_session_id="source-session",
|
||||
representation_profile_id="representation-profile/v1",
|
||||
e31_qualification_id="e31-source-qualification-" + "b" * 64,
|
||||
calibration_sha256="c" * 64,
|
||||
coordinate_frame="map",
|
||||
time_basis="source-time",
|
||||
selected_offset_ms=0,
|
||||
)
|
||||
|
||||
|
||||
def _frame(frame_index: int = 10) -> TrackGeometryFrame:
|
||||
geometries = (
|
||||
TrackGeometry(
|
||||
owner_key="track:7",
|
||||
owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
evidence_state=TrackGeometryEvidenceState.AGREE,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS,
|
||||
reason_codes=("accepted-hit-backed-support",),
|
||||
semantic_track_id=7,
|
||||
semantic_label="car",
|
||||
bbox_xyxy=(1.0, 1.0, 2.0, 2.0),
|
||||
range_m=3.0,
|
||||
),
|
||||
TrackGeometry(
|
||||
owner_key="geometry:8",
|
||||
owner_kind=TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
evidence_state=TrackGeometryEvidenceState.GEOMETRY_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS,
|
||||
reason_codes=("accepted-hit-backed-support",),
|
||||
range_m=4.0,
|
||||
),
|
||||
TrackGeometry(
|
||||
owner_key="track:9",
|
||||
owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
evidence_state=TrackGeometryEvidenceState.CAMERA_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.UNAVAILABLE,
|
||||
reason_codes=("camera-without-current-points",),
|
||||
semantic_track_id=9,
|
||||
semantic_label="person",
|
||||
bbox_xyxy=(2.0, 2.0, 3.0, 4.0),
|
||||
),
|
||||
)
|
||||
return TrackGeometryFrame(
|
||||
binding=_binding(),
|
||||
frame_index=frame_index,
|
||||
source_frame_index=frame_index,
|
||||
session_seconds=frame_index / 10.0,
|
||||
source_available=True,
|
||||
point_slab=PointSlab(
|
||||
frame_index=frame_index,
|
||||
source_frame_index=frame_index,
|
||||
source_point_count=4,
|
||||
coordinate_frame="map",
|
||||
owner_keys=("track:7", "geometry:8"),
|
||||
source_indices=np.asarray([0, 1, 2, 3], dtype="<i8"),
|
||||
points_xyz_m=np.asarray(
|
||||
[
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.1, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[2.1, 0.0, 0.0],
|
||||
],
|
||||
dtype="<f4",
|
||||
),
|
||||
owner_indices=np.asarray([0, 0, 1, 1], dtype="<u4"),
|
||||
),
|
||||
geometries=geometries,
|
||||
)
|
||||
|
||||
|
||||
def _scenario(
|
||||
kind: DegradationKind,
|
||||
parameters: dict[str, object],
|
||||
*,
|
||||
frame_start: int = 10,
|
||||
frame_end: int = 20,
|
||||
) -> DegradationScenario:
|
||||
return DegradationScenario(
|
||||
scenario_id=kind.value,
|
||||
kind=kind,
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
|
||||
def test_camera_loss_removes_semantics_but_retains_geometry_points() -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(
|
||||
DegradationKind.CAMERA_LOSS,
|
||||
{"drop_camera_observations": True},
|
||||
),
|
||||
)
|
||||
|
||||
assert transformed.frame.point_slab.row_count == 4
|
||||
assert all(
|
||||
geometry.owner_kind is TrackGeometryOwnerKind.GEOMETRY_CLUSTER
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert all(
|
||||
geometry.semantic_label is None
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert transformed.transformation["channels"]["camera"] == "unavailable"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "parameters", "expected_source_available"),
|
||||
[
|
||||
(
|
||||
DegradationKind.LIDAR_LOSS,
|
||||
{"drop_lidar_points": True},
|
||||
False,
|
||||
),
|
||||
(
|
||||
DegradationKind.POSE_STALENESS,
|
||||
{"pose_age_seconds": 0.5},
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_metric_loss_keeps_camera_nonmetric_and_withholds_map_rows(
|
||||
kind: DegradationKind,
|
||||
parameters: dict[str, object],
|
||||
expected_source_available: bool,
|
||||
) -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(kind, parameters),
|
||||
)
|
||||
|
||||
assert transformed.frame.point_slab.row_count == 0
|
||||
assert transformed.frame.source_available is expected_source_available
|
||||
assert all(
|
||||
geometry.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert all(
|
||||
geometry.metric_basis is not TrackGeometryMetricBasis.CURRENT_POINTS
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert all(
|
||||
geometry.evidence_state is not TrackGeometryEvidenceState.AGREE
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
|
||||
|
||||
def test_delayed_frame_is_discarded_and_cannot_retain_claims() -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(
|
||||
DegradationKind.DELAYED_FRAMES,
|
||||
{"delay_seconds": 1.0, "late_result_policy": "discard"},
|
||||
),
|
||||
)
|
||||
|
||||
assert not transformed.frame.source_available
|
||||
assert transformed.frame.point_slab.row_count == 0
|
||||
assert transformed.frame.geometries == ()
|
||||
assert transformed.transformation["action"] == "late-frame-discarded"
|
||||
|
||||
|
||||
def test_bounded_drop_is_deterministic_and_journals_pass_frames() -> None:
|
||||
scenario = _scenario(
|
||||
DegradationKind.BOUNDED_DROP,
|
||||
{"drop_every_nth_frame": 3},
|
||||
)
|
||||
dropped = transform_track_geometry_frame(_frame(10), scenario)
|
||||
passed_input = _frame(11)
|
||||
passed = transform_track_geometry_frame(passed_input, scenario)
|
||||
|
||||
assert dropped.frame.geometries == ()
|
||||
assert dropped.transformation["action"] == "input-frame-dropped"
|
||||
assert passed.frame is passed_input
|
||||
assert passed.frame.geometries
|
||||
assert passed.transformation["action"] == "bounded-drop-pass"
|
||||
|
||||
|
||||
def test_timing_offset_splits_camera_and_geometry_without_agree() -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(
|
||||
DegradationKind.TIMING_OFFSET,
|
||||
{"camera_lidar_offset_ms": 250},
|
||||
),
|
||||
)
|
||||
|
||||
camera = [
|
||||
geometry
|
||||
for geometry in transformed.frame.geometries
|
||||
if geometry.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
]
|
||||
geometry = [
|
||||
item
|
||||
for item in transformed.frame.geometries
|
||||
if item.owner_kind is TrackGeometryOwnerKind.GEOMETRY_CLUSTER
|
||||
]
|
||||
assert transformed.frame.point_slab.row_count == 4
|
||||
assert len(camera) == 2
|
||||
assert len(geometry) == 2
|
||||
assert all(
|
||||
item.evidence_state is not TrackGeometryEvidenceState.AGREE
|
||||
for item in transformed.frame.geometries
|
||||
)
|
||||
assert transformed.frame.point_slab.owner_keys == (
|
||||
"e35-timing-offset:track:7",
|
||||
"geometry:8",
|
||||
)
|
||||
|
||||
|
||||
def test_outside_window_returns_exact_frame_with_inactive_journal() -> None:
|
||||
frame = _frame(9)
|
||||
transformed = transform_track_geometry_frame(
|
||||
frame,
|
||||
_scenario(
|
||||
DegradationKind.CAMERA_LOSS,
|
||||
{"drop_camera_observations": True},
|
||||
),
|
||||
)
|
||||
|
||||
assert transformed.frame is frame
|
||||
assert transformed.transformation["active"] is False
|
||||
assert transformed.transformation["original_frame_sha256"] == (
|
||||
transformed.transformation["transformed_frame_sha256"]
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_requires_exact_parameters_and_unsafe_offset_fails() -> None:
|
||||
with pytest.raises(DegradationRecoveryError):
|
||||
_scenario(DegradationKind.CAMERA_LOSS, {})
|
||||
with pytest.raises(DegradationRecoveryError):
|
||||
_scenario(
|
||||
DegradationKind.TIMING_OFFSET,
|
||||
{"camera_lidar_offset_ms": 100},
|
||||
)
|
||||
|
||||
|
||||
def test_source_frame_and_input_arrays_are_not_mutated() -> None:
|
||||
frame = _frame()
|
||||
before = copy.deepcopy(frame.to_dict())
|
||||
before_points = frame.point_slab.points_xyz_m.copy()
|
||||
|
||||
transform_track_geometry_frame(
|
||||
frame,
|
||||
_scenario(
|
||||
DegradationKind.CAMERA_LOSS,
|
||||
{"drop_camera_observations": True},
|
||||
),
|
||||
)
|
||||
|
||||
assert frame.to_dict() == before
|
||||
np.testing.assert_array_equal(frame.point_slab.points_xyz_m, before_points)
|
||||
|
||||
|
||||
def test_frozen_profile_contains_every_degradation_once() -> None:
|
||||
profile, scenarios = read_e35_degradation_profile(
|
||||
Path("experiments/perception/e35_deterministic_degradation_profile.json")
|
||||
)
|
||||
|
||||
assert profile["profile_id"] == "e35-six-channel-degradation-recovery/v1"
|
||||
assert {scenario.kind for scenario in scenarios} == set(DegradationKind)
|
||||
assert sum(scenario.frame_count for scenario in scenarios) == 360
|
||||
|
||||
|
||||
def test_profile_rejects_authority_and_policy_relaxation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = Path(
|
||||
"experiments/perception/e35_deterministic_degradation_profile.json"
|
||||
)
|
||||
profile = json.loads(source.read_text(encoding="utf-8"))
|
||||
profile["policy"]["late_results_may_reenter"] = True
|
||||
path = tmp_path / "relaxed.json"
|
||||
path.write_text(json.dumps(profile), encoding="utf-8")
|
||||
|
||||
with pytest.raises(E35DegradationReplayError):
|
||||
read_e35_degradation_profile(path)
|
||||
Reference in New Issue
Block a user