feat(perception): add dual evidence replay threat
This commit is contained in:
@@ -127,7 +127,7 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None:
|
||||
repository_root / "config" / "laboratories"
|
||||
)
|
||||
|
||||
assert len(registry.definitions) == 31
|
||||
assert len(registry.definitions) == 32
|
||||
assert {item.work_id for item in registry.definitions} >= {
|
||||
"e31-source-binding",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
@@ -136,4 +136,5 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None:
|
||||
"l32-pointpillars-camera-review",
|
||||
"l33-camera-first-detector-review",
|
||||
"l34f-adjudicated-reference",
|
||||
"m4-replay-threat",
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ def test_repository_registry_classifies_every_evidence_definition() -> None:
|
||||
evidence, execution = _registries()
|
||||
|
||||
assert {row.work_id for row in execution.definitions} == {
|
||||
"m4-replay-threat",
|
||||
"e33-worker-shadow",
|
||||
"e35-degradation-recovery",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
|
||||
@@ -80,11 +80,12 @@ def test_product_value_review_registry_covers_reviewed_laboratory_families() ->
|
||||
root / "config" / "laboratory-value-review.json"
|
||||
)
|
||||
|
||||
assert len(registry.entries) == 34
|
||||
assert len(registry.entries) == 35
|
||||
assert {entry.catalog_id for entry in registry.entries} >= {
|
||||
"e28-local-surface",
|
||||
"e46d-temporal-failure-audit",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
"l31-pointpillars-ravnoves",
|
||||
"l34f-adjudicated-reference",
|
||||
"m4-replay-threat",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from k1link.perception.threat_replay import read_threat_replay_result
|
||||
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESULT_ID = (
|
||||
"m4-threat-replay-"
|
||||
"7e1613a3ea35638b5ea7a3f7c1c78fe9eba1a3adae540b652dec167f815d45b2"
|
||||
)
|
||||
RESULTS_ROOT = REPOSITORY_ROOT / ".runtime/compute-experiments/m4/replay-threat"
|
||||
|
||||
|
||||
def _endpoint(path: str):
|
||||
router = build_m4_threat_replay_router(root_provider=lambda: RESULTS_ROOT)
|
||||
return next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if isinstance(route, APIRoute) and route.path == path
|
||||
)
|
||||
|
||||
|
||||
def test_full_source_threat_result_closes_m4_6_contract() -> None:
|
||||
result = read_threat_replay_result(RESULTS_ROOT / RESULT_ID)
|
||||
|
||||
assert result.accepted is True
|
||||
assert result.metrics["frames"] == {"total": 4489, "failed": 0}
|
||||
assert result.metrics["evidence"] == {
|
||||
"camera-only": 10158,
|
||||
"current-metric": 27299,
|
||||
"stale-or-held": 37995,
|
||||
}
|
||||
assert result.metrics["decisions"] == {
|
||||
"not-threat": 6610,
|
||||
"threat": 8010,
|
||||
"unknown": 60832,
|
||||
}
|
||||
assert result.metrics["fixtures"] == {
|
||||
"critical": 4,
|
||||
"critical_false_not_threat": 0,
|
||||
"passed": 9,
|
||||
"total": 9,
|
||||
}
|
||||
|
||||
|
||||
def test_threat_result_is_content_bound_and_visual_evidence_is_complete() -> None:
|
||||
result = read_threat_replay_result(RESULTS_ROOT / RESULT_ID)
|
||||
identity = result.manifest["identity"]
|
||||
assert isinstance(identity, dict)
|
||||
|
||||
assert identity["frames_sha256"] == (
|
||||
"bf690358efb45c323db7172251074b33c3ef7ede6ae99bd8d3da53cfba86b142"
|
||||
)
|
||||
assert identity["visuals_sha256"] == (
|
||||
"fb022c6efd84f27c0916a6c87887443c9b43993ac4b1f9910332433152533dea"
|
||||
)
|
||||
visual = result.metrics["visual_evidence"]
|
||||
assert isinstance(visual, dict)
|
||||
assert visual["frame_count"] == 32
|
||||
assert all(
|
||||
visual[key] is True
|
||||
for key in (
|
||||
"video_overlay_available",
|
||||
"camera_boxes_available",
|
||||
"point_cloud_available",
|
||||
"metric_distance_available",
|
||||
"virtual_corridor_available",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_m4_6_lab_api_projects_report_and_exact_visual_frame() -> None:
|
||||
list_results = _endpoint("/api/v1/laboratory/m4-threat/results")
|
||||
list_visuals = _endpoint(
|
||||
"/api/v1/laboratory/m4-threat/results/{result_id}/visuals"
|
||||
)
|
||||
get_visual = _endpoint(
|
||||
"/api/v1/laboratory/m4-threat/results/{result_id}/visuals/{ordinal}"
|
||||
)
|
||||
|
||||
catalog = list_results(limit=1)
|
||||
assert catalog["items"][0]["result_id"] == RESULT_ID
|
||||
assert catalog["items"][0]["authority"] == "replay-simulated"
|
||||
visuals = list_visuals(RESULT_ID)
|
||||
assert len(visuals["items"]) == 32
|
||||
frame = get_visual(RESULT_ID, 1)
|
||||
assert frame["schema_version"] == "missioncore.perception-threat-visual-frame/v1"
|
||||
assert frame["point_cloud_sample_count"] > 0
|
||||
assert frame["rig"] == {
|
||||
"length_m": 1.0,
|
||||
"nominal_sensor_height_m": 1.25,
|
||||
"width_m": 0.6,
|
||||
}
|
||||
|
||||
|
||||
def test_m4_6_video_overlay_covers_the_exact_recorded_camera_timeline() -> None:
|
||||
get_overlay = _endpoint(
|
||||
"/api/v1/laboratory/m4-threat/results/{result_id}/video-overlay"
|
||||
)
|
||||
|
||||
overlay = get_overlay(RESULT_ID)
|
||||
|
||||
assert overlay["frame_count"] == 4489
|
||||
assert overlay["recorded_source"]["session_id"] == (
|
||||
"20260720T065719Z_viewer_live"
|
||||
)
|
||||
assert overlay["frames"][0]["frame_index"] == 0
|
||||
assert overlay["frames"][-1]["frame_index"] == 4488
|
||||
assert overlay["authority"] == "replay-simulated"
|
||||
@@ -52,6 +52,20 @@ TEMPORAL_RUNTIME_MODULES = (
|
||||
"temporal_replay.py",
|
||||
"temporal_replay_cli.py",
|
||||
)
|
||||
THREAT_RUNTIME_MODULES = (
|
||||
"contracts.py",
|
||||
"detector_replay_contracts.py",
|
||||
"detector_replay_result.py",
|
||||
"geometry.py",
|
||||
"geometry_math.py",
|
||||
"geometry_replay.py",
|
||||
"providers.py",
|
||||
"recorded_source.py",
|
||||
"temporal_replay.py",
|
||||
"threat.py",
|
||||
"threat_replay.py",
|
||||
"threat_replay_cli.py",
|
||||
)
|
||||
|
||||
|
||||
def _imports(path: Path) -> set[str]:
|
||||
@@ -224,6 +238,25 @@ def test_temporal_runtime_closure_imports_no_legacy_compute_or_device_package()
|
||||
assert {name: modules for name, modules in violations.items() if modules} == {}
|
||||
|
||||
|
||||
def test_threat_runtime_closure_imports_no_legacy_compute_device_lab_or_web_package() -> None:
|
||||
violations = {
|
||||
name: sorted(
|
||||
module
|
||||
for module in _imports(PERCEPTION_ROOT / name)
|
||||
if module.startswith(
|
||||
(
|
||||
"k1link.compute",
|
||||
"k1link.device_plugins",
|
||||
"k1link.laboratory",
|
||||
"k1link.web",
|
||||
)
|
||||
)
|
||||
)
|
||||
for name in THREAT_RUNTIME_MODULES
|
||||
}
|
||||
assert {name: modules for name, modules in violations.items() if modules} == {}
|
||||
|
||||
|
||||
def test_new_perception_boundary_has_no_experiment_specific_imports() -> None:
|
||||
violations: dict[str, str] = {}
|
||||
for path in PERCEPTION_ROOT.glob("*.py"):
|
||||
|
||||
@@ -234,8 +234,7 @@ class _Threat:
|
||||
provider_id = "test-threat/v1"
|
||||
|
||||
def assess(self, obstacle_map: LocalObstacleMap) -> tuple[ThreatAssessment, ...]:
|
||||
occupied = obstacle_map.occupied
|
||||
return tuple(
|
||||
metric = tuple(
|
||||
ThreatAssessment(
|
||||
assessment_id=f"assessment-{item.component_id}",
|
||||
component_id=item.component_id,
|
||||
@@ -249,8 +248,41 @@ class _Threat:
|
||||
decision=ThreatDecision.NOT_THREAT,
|
||||
reason_codes=("qualified-corridor-clear",),
|
||||
)
|
||||
for item in occupied
|
||||
for item in obstacle_map.occupied
|
||||
)
|
||||
unknown = tuple(
|
||||
ThreatAssessment(
|
||||
assessment_id=f"assessment-{item.component_id}",
|
||||
component_id=item.component_id,
|
||||
rig_profile_id="ravnoves00-virtual-rig/v1",
|
||||
corridor_profile_id="ravnoves00-virtual-corridor/v1",
|
||||
qualification=QualificationState.UNQUALIFIED,
|
||||
relative_speed_mps=None,
|
||||
closest_approach_m=None,
|
||||
ttc_seconds=None,
|
||||
corridor_intersection=CorridorIntersection.UNKNOWN,
|
||||
decision=ThreatDecision.UNKNOWN,
|
||||
reason_codes=("incomplete-evidence",),
|
||||
)
|
||||
for item in obstacle_map.unknown
|
||||
)
|
||||
camera = tuple(
|
||||
ThreatAssessment(
|
||||
assessment_id=f"assessment-{item.proposal_id}",
|
||||
component_id=item.proposal_id,
|
||||
rig_profile_id="ravnoves00-virtual-rig/v1",
|
||||
corridor_profile_id="ravnoves00-virtual-corridor/v1",
|
||||
qualification=QualificationState.UNQUALIFIED,
|
||||
relative_speed_mps=None,
|
||||
closest_approach_m=None,
|
||||
ttc_seconds=None,
|
||||
corridor_intersection=CorridorIntersection.UNKNOWN,
|
||||
decision=ThreatDecision.UNKNOWN,
|
||||
reason_codes=("camera-only",),
|
||||
)
|
||||
for item in obstacle_map.camera_uncertainty
|
||||
)
|
||||
return (*metric, *unknown, *camera)
|
||||
|
||||
|
||||
def _config(
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
BoundingRegion2D,
|
||||
CorridorIntersection,
|
||||
GridCell,
|
||||
HistorySample,
|
||||
LocalObstacleMap,
|
||||
MotionState,
|
||||
ObjectProposal2D,
|
||||
SourceAccounting,
|
||||
TemporalObstacle,
|
||||
TemporalState,
|
||||
ThreatDecision,
|
||||
)
|
||||
from k1link.perception.graph_validation import validate_threats
|
||||
from k1link.perception.threat import (
|
||||
DualEvidenceReplayThreatProvider,
|
||||
ReplayPose,
|
||||
load_replay_threat_profile,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m4-replay-threat-v1.json"
|
||||
|
||||
|
||||
class _Poses:
|
||||
def pose_for_frame(self, frame_id: str) -> ReplayPose:
|
||||
return ReplayPose(
|
||||
frame_id=frame_id,
|
||||
position_map_xyz_m=(0.0, 0.0, 0.0),
|
||||
orientation_map_from_lidar_xyzw=(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _obstacle(
|
||||
component_id: str,
|
||||
cell: GridCell,
|
||||
*,
|
||||
motion: MotionState,
|
||||
history: tuple[tuple[str, int, tuple[float, float, float]], ...],
|
||||
state: TemporalState = TemporalState.CURRENT,
|
||||
semantic_hint: str | None = None,
|
||||
) -> TemporalObstacle:
|
||||
samples = tuple(
|
||||
HistorySample(frame_id=frame_id, evidence_time_ns=time_ns, centroid_xyz_m=point)
|
||||
for frame_id, time_ns, point in history
|
||||
)
|
||||
current = samples[-1]
|
||||
return TemporalObstacle(
|
||||
component_id=component_id,
|
||||
identity_scope="ephemeral",
|
||||
state=state,
|
||||
ttl_ns=750_000_000,
|
||||
last_hit_ns=current.evidence_time_ns,
|
||||
age_ns=0 if state is TemporalState.CURRENT else 100_000_000,
|
||||
association_basis="test-spatial-support",
|
||||
history=samples,
|
||||
cells=() if state is TemporalState.EXPIRED else (cell,),
|
||||
coordinate_frame=None if state is TemporalState.EXPIRED else "map",
|
||||
last_centroid_xyz_m=None if state is TemporalState.EXPIRED else current.centroid_xyz_m,
|
||||
motion=MotionState.UNKNOWN if state is not TemporalState.CURRENT else motion,
|
||||
motion_confidence=(
|
||||
0.0
|
||||
if state is not TemporalState.CURRENT or motion is MotionState.UNKNOWN
|
||||
else 1.0
|
||||
),
|
||||
motion_reason=(
|
||||
"stale-support"
|
||||
if state is not TemporalState.CURRENT
|
||||
else "bounded-map-history-moving"
|
||||
if motion is MotionState.MOVING
|
||||
else "bounded-map-history-stationary"
|
||||
if motion is MotionState.STATIONARY
|
||||
else "insufficient-history"
|
||||
),
|
||||
semantic_hint=semantic_hint,
|
||||
)
|
||||
|
||||
|
||||
def _proposal(frame_id: str = "frame-000002") -> ObjectProposal2D:
|
||||
return ObjectProposal2D(
|
||||
proposal_id="proposal-camera-only",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id=frame_id,
|
||||
region=BoundingRegion2D(10.0, 10.0, 20.0, 20.0),
|
||||
objectness=0.8,
|
||||
provider_id="test-detector/v1",
|
||||
model_id="test-model/v1",
|
||||
preprocess_id="test-preprocess/v1",
|
||||
semantic_hint="person",
|
||||
)
|
||||
|
||||
|
||||
def _map(
|
||||
*,
|
||||
occupied: tuple[TemporalObstacle, ...] = (),
|
||||
unknown: tuple[TemporalObstacle, ...] = (),
|
||||
camera: tuple[ObjectProposal2D, ...] = (),
|
||||
frame_id: str = "frame-000002",
|
||||
) -> LocalObstacleMap:
|
||||
return LocalObstacleMap(
|
||||
source_id="RAVNOVES00",
|
||||
session_id="20260720T065719Z_viewer_live",
|
||||
frame_id=frame_id,
|
||||
graph_id="reference-perception-graph/v1",
|
||||
generated_monotonic_ns=0,
|
||||
output_age_ns=0,
|
||||
occupied=occupied,
|
||||
unknown=unknown,
|
||||
camera_uncertainty=camera,
|
||||
accounting=SourceAccounting(1, 1, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def test_replay_threat_profile_freezes_virtual_authority_and_dual_evidence_policy() -> None:
|
||||
profile = load_replay_threat_profile(PROFILE_PATH)
|
||||
|
||||
assert profile.rig.body_length_m == 1.0
|
||||
assert profile.rig.body_width_m == 0.6
|
||||
assert profile.rig.nominal_sensor_height_m == 1.25
|
||||
assert profile.corridor.forward_length_m == 8.0
|
||||
assert profile.calibration_content_sha256 == (
|
||||
"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
)
|
||||
|
||||
|
||||
def test_static_crossing_approaching_and_geometry_only_critical_cases_are_never_safe() -> None:
|
||||
provider = DualEvidenceReplayThreatProvider(
|
||||
pose_resolver=_Poses(),
|
||||
profile=load_replay_threat_profile(PROFILE_PATH),
|
||||
)
|
||||
current_frame = "frame-000002"
|
||||
critical = (
|
||||
_obstacle(
|
||||
"static-in-corridor",
|
||||
GridCell(6, 0, 0),
|
||||
motion=MotionState.STATIONARY,
|
||||
history=(
|
||||
("frame-000000", 0, (2.925, 0.225, 0.225)),
|
||||
(current_frame, 300_000_000, (2.925, 0.225, 0.225)),
|
||||
),
|
||||
),
|
||||
_obstacle(
|
||||
"crossing",
|
||||
GridCell(6, 3, 0),
|
||||
motion=MotionState.MOVING,
|
||||
history=(
|
||||
("frame-000000", 0, (2.925, 2.575, 0.225)),
|
||||
(current_frame, 300_000_000, (2.925, 1.575, 0.225)),
|
||||
),
|
||||
),
|
||||
_obstacle(
|
||||
"approaching",
|
||||
GridCell(9, 0, 0),
|
||||
motion=MotionState.MOVING,
|
||||
history=(
|
||||
("frame-000000", 0, (6.275, 0.225, 0.225)),
|
||||
(current_frame, 300_000_000, (4.275, 0.225, 0.225)),
|
||||
),
|
||||
semantic_hint="car",
|
||||
),
|
||||
_obstacle(
|
||||
"geometry-only",
|
||||
GridCell(4, 0, 0),
|
||||
motion=MotionState.STATIONARY,
|
||||
history=(
|
||||
("frame-000000", 0, (2.025, 0.225, 0.225)),
|
||||
(current_frame, 300_000_000, (2.025, 0.225, 0.225)),
|
||||
),
|
||||
semantic_hint=None,
|
||||
),
|
||||
)
|
||||
|
||||
result = provider.assess(_map(occupied=critical))
|
||||
|
||||
assert {item.decision for item in result} == {ThreatDecision.THREAT}
|
||||
assert all(item.corridor_intersection is CorridorIntersection.INTERSECTS for item in result)
|
||||
assert (
|
||||
next(item for item in result if item.component_id == "approaching").ttc_seconds
|
||||
is not None
|
||||
)
|
||||
assert "geometry-only-evidence" in next(
|
||||
item for item in result if item.component_id == "geometry-only"
|
||||
).reason_codes
|
||||
|
||||
|
||||
def test_receding_and_static_outside_are_clear_but_incomplete_evidence_is_unknown() -> None:
|
||||
provider = DualEvidenceReplayThreatProvider(
|
||||
pose_resolver=_Poses(),
|
||||
profile=load_replay_threat_profile(PROFILE_PATH),
|
||||
)
|
||||
current_frame = "frame-000002"
|
||||
clear = (
|
||||
_obstacle(
|
||||
"static-outside",
|
||||
GridCell(6, 7, 0),
|
||||
motion=MotionState.STATIONARY,
|
||||
history=(
|
||||
("frame-000000", 0, (2.925, 3.375, 0.225)),
|
||||
(current_frame, 300_000_000, (2.925, 3.375, 0.225)),
|
||||
),
|
||||
),
|
||||
_obstacle(
|
||||
"receding-behind",
|
||||
GridCell(-5, 0, 0),
|
||||
motion=MotionState.MOVING,
|
||||
history=(
|
||||
("frame-000000", 0, (-1.025, 0.225, 0.225)),
|
||||
(current_frame, 300_000_000, (-2.025, 0.225, 0.225)),
|
||||
),
|
||||
),
|
||||
)
|
||||
incomplete = _obstacle(
|
||||
"unknown-motion",
|
||||
GridCell(6, 7, 0),
|
||||
motion=MotionState.UNKNOWN,
|
||||
history=((current_frame, 300_000_000, (2.925, 3.375, 0.225)),),
|
||||
)
|
||||
held = _obstacle(
|
||||
"occluded-held",
|
||||
GridCell(6, 0, 0),
|
||||
motion=MotionState.UNKNOWN,
|
||||
history=((current_frame, 300_000_000, (2.925, 0.225, 0.225)),),
|
||||
state=TemporalState.HELD,
|
||||
)
|
||||
obstacle_map = _map(
|
||||
occupied=(*clear, incomplete),
|
||||
unknown=(held,),
|
||||
camera=(_proposal(),),
|
||||
)
|
||||
|
||||
result = provider.assess(obstacle_map)
|
||||
by_id = {item.component_id: item for item in result}
|
||||
|
||||
assert by_id["static-outside"].decision is ThreatDecision.NOT_THREAT
|
||||
assert by_id["receding-behind"].decision is ThreatDecision.NOT_THREAT
|
||||
assert by_id["unknown-motion"].decision is ThreatDecision.UNKNOWN
|
||||
assert by_id["occluded-held"].decision is ThreatDecision.UNKNOWN
|
||||
assert by_id["proposal-camera-only"].decision is ThreatDecision.UNKNOWN
|
||||
assert by_id["proposal-camera-only"].closest_approach_m is None
|
||||
validate_threats(obstacle_map, result)
|
||||
|
||||
|
||||
def test_semantic_hint_and_ephemeral_component_name_do_not_change_threat_geometry() -> None:
|
||||
provider = DualEvidenceReplayThreatProvider(
|
||||
pose_resolver=_Poses(),
|
||||
profile=load_replay_threat_profile(PROFILE_PATH),
|
||||
)
|
||||
history = (
|
||||
("frame-000000", 0, (2.925, 0.225, 0.225)),
|
||||
("frame-000002", 300_000_000, (2.925, 0.225, 0.225)),
|
||||
)
|
||||
first = _obstacle(
|
||||
"ephemeral-a",
|
||||
GridCell(6, 0, 0),
|
||||
motion=MotionState.STATIONARY,
|
||||
history=history,
|
||||
semantic_hint="car",
|
||||
)
|
||||
second = _obstacle(
|
||||
"ephemeral-b",
|
||||
GridCell(6, 0, 0),
|
||||
motion=MotionState.STATIONARY,
|
||||
history=history,
|
||||
semantic_hint=None,
|
||||
)
|
||||
|
||||
values = provider.assess(_map(occupied=(first, second)))
|
||||
|
||||
assert values[0].decision == values[1].decision
|
||||
assert values[0].corridor_intersection == values[1].corridor_intersection
|
||||
assert values[0].relative_speed_mps == values[1].relative_speed_mps
|
||||
assert values[0].closest_approach_m == values[1].closest_approach_m
|
||||
assert values[0].ttc_seconds == values[1].ttc_seconds
|
||||
Reference in New Issue
Block a user