feat(perception): add dual evidence replay threat
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-evidence-definition/v1",
|
||||
"work_id": "m4-replay-threat",
|
||||
"evidence": {
|
||||
"runtime_relative_root": "m4/replay-threat",
|
||||
"result_id_prefix": "m4-threat-replay",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.perception-threat-replay-result/v1"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-execution-registry/v1",
|
||||
"definitions": [
|
||||
{
|
||||
"work_id": "m4-replay-threat",
|
||||
"lifecycle": "canonical",
|
||||
"isolation": "core-adapter",
|
||||
"adapter_id": "canonical.m4-replay-threat/v1",
|
||||
"input_roles": [
|
||||
"repository_root",
|
||||
"temporal_result_root",
|
||||
"geometry_result_root",
|
||||
"detector_result_root"
|
||||
],
|
||||
"contracts": {
|
||||
"source": "missioncore.perception-temporal-replay-result/v1",
|
||||
"provider": "missioncore.dual-evidence-threat-provider/v1",
|
||||
"graph": "missioncore.perception-threat-replay-graph/v1",
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.perception-threat-replay-result/v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"work_id": "e33-worker-shadow",
|
||||
"lifecycle": "canonical",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-value-review-registry/v1",
|
||||
"reviewed_at_utc": "2026-08-05T08:30:00Z",
|
||||
"reviewed_at_utc": "2026-08-05T15:34:00Z",
|
||||
"entries": [
|
||||
{
|
||||
"catalog_id": "e28-local-surface",
|
||||
@@ -191,6 +191,13 @@
|
||||
"lifecycle": "current",
|
||||
"visual_evidence": "available"
|
||||
},
|
||||
{
|
||||
"catalog_id": "m4-replay-threat",
|
||||
"evidence_id": "m4-threat-replay-7e1613a3ea35638b5ea7a3f7c1c78fe9eba1a3adae540b652dec167f815d45b2",
|
||||
"signal": "progress",
|
||||
"lifecycle": "current",
|
||||
"visual_evidence": "available"
|
||||
},
|
||||
{
|
||||
"catalog_id": "l34-right-yolox-truth-island-freeze",
|
||||
"evidence_id": "l34-right-yolox-truth-island-freeze-5175a03144978b25130019da6d37bceb8c6ed6aa3d0d3a4d2df4483e1e27ae76",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schema_version": "missioncore.replay-threat-profile/v1",
|
||||
"profile_id": "m4-ravnoves00-virtual-corridor/v1",
|
||||
"provider_id": "dual-evidence-replay-threat/v1",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"temporal_result_id": "m4-temporal-replay-9ed5dcd249ed3bcb81661dd18e2b854a7ffedf3fd2b92b9c994c3c70c34533f2",
|
||||
"temporal_frames_sha256": "1bf1365bdb3f20214443d3f8b87a0fa88f9848af8ca0456b7ca364d37631c3fc",
|
||||
"geometry_result_id": "m4-geometry-replay-8daf3109e3cf30b960b4b376032ff3b5ec58ca42a1e5899b841cf29fbcf14ad8",
|
||||
"geometry_frames_sha256": "b4db5d0ebaba4d6268a1006707dc313c229f3dbdfd73b1d863cad5d853be8ac4",
|
||||
"detector_result_id": "m4-detector-replay-11f83f2e0b81758ac2a5a5fc54e9d293b501678df5f6ef97b5c6069ba08605c5",
|
||||
"detector_frames_sha256": "9bf5ae17938cd57c112278781b38d54a7187cf2dabc7bb0332acdb1efad721f5",
|
||||
"source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||
"source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
|
||||
},
|
||||
"calibration": {
|
||||
"calibration_id": "camera-1-kb4-05f3ad9b",
|
||||
"content_identity_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
"usage": "projection-binding-only"
|
||||
},
|
||||
"virtual_rig": {
|
||||
"profile_id": "virtual-handheld-body-1000x600/v1",
|
||||
"body_length_m": 1.0,
|
||||
"body_width_m": 0.6,
|
||||
"lidar_reference": "virtual-body-center",
|
||||
"nominal_sensor_height_m": 1.25,
|
||||
"physical_mount_claimed": false
|
||||
},
|
||||
"corridor": {
|
||||
"profile_id": "ravnoves00-forward-corridor-8m/v1",
|
||||
"forward_length_m": 8.0,
|
||||
"rear_margin_m": 0.5,
|
||||
"lateral_clearance_m": 0.2,
|
||||
"prediction_horizon_seconds": 5.0,
|
||||
"occupied_voxel_size_m": 0.45,
|
||||
"minimum_motion_span_seconds": 0.2
|
||||
},
|
||||
"policy": {
|
||||
"camera_only_decision": "unknown",
|
||||
"held_or_stale_decision": "unknown",
|
||||
"semantic_class_used": false,
|
||||
"detector_identity_used": false,
|
||||
"absence_of_points_means_free": false,
|
||||
"geometry_only_is_eligible": true
|
||||
},
|
||||
"authority": {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": false,
|
||||
"physical_collision_accepted": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Date: 2026-08-05
|
||||
|
||||
Status: in progress; M4.0–M4.5 accepted, M4.6 replay threat is next
|
||||
Status: in progress; M4.0–M4.6 accepted, M4.7 Worker 006 cutover is next
|
||||
|
||||
Audit base: `1b3e0b3` on `feat/simulation-polygon-s1`
|
||||
|
||||
@@ -107,7 +107,7 @@ Those systems remain separate platform workstreams.
|
||||
| Degradation | E35 executes six deterministic full-source variants; maximum recovery is 0.102 s against a 0.25 s gate | Accepted reusable regression primitive |
|
||||
| Recorded pacing | E33 processes all 4,489 frames at 10.006 FPS with depth-two queues, zero replacement/drop/deadline miss and 2.668 ms result-age p95 | Accepted stage runner evidence, not end-to-end perception evidence |
|
||||
| Motion | E51 emits 22,885 bounded motion candidates with complete accounting and no map-frame jump candidate | Candidate implementation exists; moving/static correctness is not accepted |
|
||||
| Threat/collision | E51/E53 intentionally publish collision unavailable because body and LiDAR-to-body geometry are unbound | Replay simulation is possible with an explicit virtual rig; physical threat acceptance remains deferred |
|
||||
| Threat/collision | M4.6 evaluates all 4,489 RAVNOVES00 frames with an explicit 1.0 × 0.6 m virtual body, 1.25 m sensor height and 8 m corridor | Accepted only as `replay-simulated`; physical threat/collision acceptance remains deferred |
|
||||
| Realtime worker | Worker 006 has healthy Triton and persistent perception containers plus live Telegraf | Infrastructure exists |
|
||||
| Worker graph | The persistent process still executes `run_e15_shadow_inference.py serve` with E15/E19/E8/E3/E23 profiles | Open architectural blocker: runtime remains LAB-generation-specific |
|
||||
| Model service | Canonical Triton currently exposes pinned `yolox_s` and `pointpillars`; PointPillars was rejected as a K1 product candidate | Reuse `yolox_s`; do not reopen PointPillars |
|
||||
@@ -425,6 +425,10 @@ Exit:
|
||||
|
||||
### M4.6 — implement replay-only threat assessment
|
||||
|
||||
Status: accepted on 2026-08-05. See the implementation record below and ADR
|
||||
0040. The virtual dimensions are a replay hypothesis, not a retroactive physical
|
||||
rig measurement.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- define a versioned virtual rig and corridor profile for RAVNOVES00 replay;
|
||||
@@ -613,7 +617,7 @@ Milestone 4 is complete only when all of the following are true:
|
||||
contract.
|
||||
- [ ] Current, held, stale, unavailable and conflict states are explicit.
|
||||
- [ ] Moving/static/unknown state is measured without semantic-class dependence.
|
||||
- [ ] Replay-only threat assessment is explicit and cannot claim physical authority.
|
||||
- [x] Replay-only threat assessment is explicit and cannot claim physical authority.
|
||||
- [ ] Worker 006 runs the canonical graph instead of the E15-specific server.
|
||||
- [ ] Full source-paced replay, deterministic replay, degradation, recovery and
|
||||
soak gates pass.
|
||||
@@ -908,8 +912,56 @@ canonical temporal or motion bytes.
|
||||
|
||||
All fifteen M4.5 acceptance requirements are true. Synthetic tests cover ID and
|
||||
semantic-hint changes, moving, stationary, held, expiry, camera-only uncertainty
|
||||
and map-frame discontinuity. M4.5 is closed; M4.6 replay-only threat assessment
|
||||
is the next implementation phase.
|
||||
and map-frame discontinuity. This closed M4.5 and supplied the immutable input to
|
||||
the following M4.6 replay-only threat phase.
|
||||
|
||||
### 2026-08-05 — M4.6 dual-evidence replay threat
|
||||
|
||||
M4.6 is closed by `k1link.perception.threat` and the immutable replay builder in
|
||||
`k1link.perception.threat_replay`:
|
||||
|
||||
- `DualEvidenceReplayThreatProvider` consumes the canonical `LocalObstacleMap`;
|
||||
it does not select camera-first or LiDAR-first execution;
|
||||
- current LiDAR metric components are eligible for corridor assessment even when
|
||||
they have no semantic class or camera association;
|
||||
- camera-only observations and held/expired metric evidence publish `unknown`,
|
||||
never `not-threat`;
|
||||
- semantic hint and ephemeral detector/component identity do not participate in
|
||||
corridor intersection, closest approach or TTC;
|
||||
- the versioned replay profile fixes a virtual `1.0 × 0.6 m` body, nominal
|
||||
`1.25 m` sensor height, `8 m` forward corridor and `5 s` bounded prediction
|
||||
horizon; all documents retain `replay-simulated`, physical-collision false and
|
||||
actuation false authority.
|
||||
|
||||
The accepted immutable result is
|
||||
`m4-threat-replay-7e1613a3ea35638b5ea7a3f7c1c78fe9eba1a3adae540b652dec167f815d45b2`:
|
||||
|
||||
- `4,489 / 4,489` frames completed, zero failed;
|
||||
- `27,299` current metric, `37,995` stale/held and `10,158` camera-only evidence
|
||||
publications were each assessed exactly once;
|
||||
- decisions: `8,010 threat`, `6,610 not-threat`, `60,832 unknown`;
|
||||
- `21,958` geometry-only assessments remained in the decision path without a
|
||||
class requirement;
|
||||
- deterministic fixtures passed `9 / 9`; all four critical fixtures avoided a
|
||||
false `not-threat` outcome;
|
||||
- local uncapped execution measured `132.812 FPS`; provider latency was
|
||||
`3.932 ms` p50 and `17.567 ms` p95;
|
||||
- deterministic frame, visual and fixture ledgers are sealed by SHA-256
|
||||
`bf690358efb45c323db7172251074b33c3ef7ede6ae99bd8d3da53cfba86b142`,
|
||||
`fb022c6efd84f27c0916a6c87887443c9b43993ac4b1f9910332433152533dea`
|
||||
and `e217b61f3e8cf444f2620c0d815c18b2131eaafca29352bf12f78e05db96ee13`.
|
||||
|
||||
The standard LAB catalog exposes the exact result with a common evidence viewer:
|
||||
full recorded VIDEO, exact CAMERA samples with ranges/unknown boxes, and the same
|
||||
32 synchronized LiDAR point-cloud samples in interactive 3D and plan view. The
|
||||
recorded box overlay was extracted from E46C into a reusable component rather
|
||||
than copied into an M4-specific renderer. Visual availability is evidence for
|
||||
inspection, not independent ground truth.
|
||||
|
||||
M4.6 does not close moving/static correctness or object-presence correctness;
|
||||
those remain the independent M4.8 gate. It also does not authorize a physical
|
||||
mount, live K1, navigation, collision safety or commands. M4.7 is now the next
|
||||
implementation phase.
|
||||
|
||||
## Implementation order
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR 0040: Dual-evidence replay threat boundary
|
||||
|
||||
Date: 2026-08-05
|
||||
Status: accepted and implemented for M4.6
|
||||
|
||||
## Context
|
||||
|
||||
Historical camera-first experiments correctly kept camera semantics separate
|
||||
from LiDAR metric support, but the phrase "camera-first" is not an acceptable
|
||||
product threat architecture. The RAVNOVES00 camera detector visibly misses some
|
||||
unclassified occupied structures, while camera proposals without qualified
|
||||
LiDAR support cannot establish metric clearance. Making either sensor a gate for
|
||||
the other would discard useful evidence.
|
||||
|
||||
The portable RAVNOVES00 recording also has no admitted measured vehicle body or
|
||||
qualified LiDAR-to-body mount. A recorded threat experiment therefore needs an
|
||||
explicit virtual geometry without weakening the physical rig contract in ADR
|
||||
0035.
|
||||
|
||||
## Decision
|
||||
|
||||
Mission Core threat assessment consumes two independent evidence paths:
|
||||
|
||||
```text
|
||||
camera proposals ---------------------> camera-only uncertainty
|
||||
| |
|
||||
+---- optional association ----+ |
|
||||
v v
|
||||
LiDAR occupied geometry ----------> LocalObstacleMap ---> ThreatAssessment
|
||||
```
|
||||
|
||||
Neither path is called first:
|
||||
|
||||
- camera publishes image-space object proposals and optional semantics;
|
||||
- LiDAR publishes metric occupied components, including geometry with no class;
|
||||
- association enriches evidence but is not an admission gate;
|
||||
- current metric geometry may produce `threat` or `not-threat` from corridor
|
||||
geometry and bounded relative motion;
|
||||
- camera-only, held, expired or otherwise incomplete evidence produces
|
||||
`unknown`, never a safe decision;
|
||||
- semantic class, detector ID and persistent identity are excluded from the
|
||||
threat calculation.
|
||||
|
||||
M4.6 fixes a versioned replay hypothesis: body length `1.0 m`, width `0.6 m`,
|
||||
nominal sensor height `1.25 m`, forward corridor `8 m`, rear margin `0.5 m`,
|
||||
lateral clearance `0.2 m` and prediction horizon `5 s`. These values may be used
|
||||
only with `replay-simulated` authority. They do not populate or qualify
|
||||
`missioncore.rig-geometry/v1`, and they cannot support physical collision,
|
||||
navigation, safety or actuation claims.
|
||||
|
||||
## Evidence and presentation
|
||||
|
||||
The accepted replay must publish immutable frame, fixture, report and visual
|
||||
ledgers. Visual evidence uses the common LAB viewer and reusable renderers:
|
||||
|
||||
- full recorded camera video with synchronized proposal boxes;
|
||||
- exact camera samples with metric range or explicit missing range;
|
||||
- synchronized point cloud, occupied cells, virtual body and corridor in 3D and
|
||||
plan view;
|
||||
- visible threat/not-threat/unknown and `replay-simulated` authority.
|
||||
|
||||
Visuals are an inspection surface, not ground truth. Independent object-centric
|
||||
labels remain a separate gate.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Unclassified concrete, vegetation or road furniture can remain visible to the
|
||||
metric path without inventing a semantic label.
|
||||
- A camera detection cannot become safe merely because LiDAR support is absent.
|
||||
- New detectors and LiDAR geometry providers may replace either side behind the
|
||||
existing contracts without changing the threat provider.
|
||||
- Physical body/mount qualification and live acceptance remain intentional debt.
|
||||
- ADR 0035 remains valid for ownership of semantics, metric support and physical
|
||||
rig qualification; this ADR supersedes camera-first wording for the canonical
|
||||
product decision graph.
|
||||
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
||||
RUNNER_NAME = RUNNER.name
|
||||
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||
EXPECTED_WHEEL_SHA256 = "19d8caf9a522747c461fb3ca30aafe54169959d8bd8e671fa6fc8c0ac107875d"
|
||||
EXPECTED_WHEEL_SHA256 = "94ed4b7e70471d343eafa9728ce1bd496551a6ee2c2d9e3e67fa5c6c2eadfc5b"
|
||||
PAYLOAD_FILES = (
|
||||
RUNNER_NAME,
|
||||
WHEEL_NAME,
|
||||
|
||||
@@ -311,12 +311,29 @@ class LaboratoryRunner:
|
||||
|
||||
def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]:
|
||||
return {
|
||||
"canonical.m4-replay-threat/v1": _run_m4_replay_threat,
|
||||
"canonical.e33-worker-shadow/v1": _run_e33,
|
||||
"canonical.e35-degradation-recovery/v1": _run_e35,
|
||||
"canonical.e46j-raw-fisheye-realtime/v1": _run_e46j,
|
||||
}
|
||||
|
||||
|
||||
def _run_m4_replay_threat(request: LaboratoryRunRequest) -> LaboratoryAdapterResult:
|
||||
from k1link.perception.threat_replay import build_threat_replay
|
||||
|
||||
result = build_threat_replay(
|
||||
repository_root=request.inputs["repository_root"],
|
||||
temporal_result_root=request.inputs["temporal_result_root"],
|
||||
geometry_result_root=request.inputs["geometry_result_root"],
|
||||
detector_result_root=request.inputs["detector_result_root"],
|
||||
output_root=request.output_root,
|
||||
)
|
||||
return LaboratoryAdapterResult(
|
||||
result_root=result.result_root,
|
||||
result_id=result.result_id,
|
||||
)
|
||||
|
||||
|
||||
def _run_e33(request: LaboratoryRunRequest) -> LaboratoryAdapterResult:
|
||||
from k1link.compute.e33_worker_shadow import run_e33_worker_shadow
|
||||
|
||||
|
||||
@@ -245,6 +245,48 @@ class RecordedGeometryStore:
|
||||
points.setflags(write=False)
|
||||
return points
|
||||
|
||||
def pose_values_for_frame(
|
||||
self,
|
||||
frame_id: str,
|
||||
) -> tuple[tuple[float, float, float], tuple[float, float, float, float]] | None:
|
||||
"""Return one verified replay pose without exposing the source archive."""
|
||||
|
||||
prefix = "frame-"
|
||||
if not frame_id.startswith(prefix) or not frame_id[len(prefix) :].isdigit():
|
||||
raise GeometryProviderError("replay pose frame identity is invalid")
|
||||
frame_index = int(frame_id[len(prefix) :])
|
||||
if not 0 <= frame_index < self.profile.frame_count:
|
||||
raise GeometryProviderError("replay pose frame is outside the source profile")
|
||||
if not bool(self._source["sample_available"][frame_index]):
|
||||
return None
|
||||
position = np.asarray(
|
||||
self._source["pose_positions_map"][frame_index],
|
||||
dtype=np.float64,
|
||||
)
|
||||
orientation = np.asarray(
|
||||
self._source["pose_quaternions_map_from_lidar"][frame_index],
|
||||
dtype=np.float64,
|
||||
)
|
||||
if not np.isfinite(position).all() or not np.isfinite(orientation).all():
|
||||
raise GeometryProviderError("available replay pose is not finite")
|
||||
return (
|
||||
(float(position[0]), float(position[1]), float(position[2])),
|
||||
(
|
||||
float(orientation[0]),
|
||||
float(orientation[1]),
|
||||
float(orientation[2]),
|
||||
float(orientation[3]),
|
||||
),
|
||||
)
|
||||
|
||||
def available_frame_indices(self) -> tuple[int, ...]:
|
||||
"""Expose the immutable availability partition for deterministic sampling."""
|
||||
|
||||
return tuple(
|
||||
int(index)
|
||||
for index in np.flatnonzero(self._source["sample_available"])
|
||||
)
|
||||
|
||||
def _validate(self) -> None:
|
||||
source_required = {
|
||||
"frame_indices",
|
||||
|
||||
@@ -73,14 +73,20 @@ def validate_threats(
|
||||
obstacle_map: LocalObstacleMap,
|
||||
threats: tuple[ThreatAssessment, ...],
|
||||
) -> None:
|
||||
component_ids = {
|
||||
evidence_ids = {
|
||||
obstacle.component_id for obstacle in (*obstacle_map.occupied, *obstacle_map.unknown)
|
||||
}
|
||||
evidence_ids.update(proposal.proposal_id for proposal in obstacle_map.camera_uncertainty)
|
||||
assessment_ids = [threat.assessment_id for threat in threats]
|
||||
assessed_ids = [threat.component_id for threat in threats]
|
||||
if len(set(assessment_ids)) != len(assessment_ids):
|
||||
raise GraphExecutionError("threat assessment identities are duplicated")
|
||||
if any(threat.component_id not in component_ids for threat in threats):
|
||||
if len(set(assessed_ids)) != len(assessed_ids):
|
||||
raise GraphExecutionError("threat evidence references are duplicated")
|
||||
if any(threat.component_id not in evidence_ids for threat in threats):
|
||||
raise GraphExecutionError("threat assessment references an unknown component")
|
||||
if set(assessed_ids) != evidence_ids:
|
||||
raise GraphExecutionError("threat assessment coverage is incomplete")
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
"""Replay-only virtual-corridor threat assessment for Mission Core M4.6.
|
||||
|
||||
The provider consumes the canonical object map and a source-bound replay pose.
|
||||
It never reads semantic class or detector identity when calculating geometry,
|
||||
motion, corridor intersection, closest approach or TTC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final, Protocol
|
||||
|
||||
from .contracts import (
|
||||
CorridorIntersection,
|
||||
LocalObstacleMap,
|
||||
MotionState,
|
||||
QualificationState,
|
||||
TemporalObstacle,
|
||||
TemporalState,
|
||||
ThreatAssessment,
|
||||
ThreatDecision,
|
||||
)
|
||||
from .geometry_math import quaternion_xyzw_to_rotation_matrix
|
||||
|
||||
REPLAY_THREAT_PROFILE_SCHEMA: Final = "missioncore.replay-threat-profile/v1"
|
||||
REPLAY_THREAT_PROVIDER_ID: Final = "dual-evidence-replay-threat/v1"
|
||||
DEFAULT_REPLAY_THREAT_PROFILE_PATH: Final = (
|
||||
"config/perception/m4-replay-threat-v1.json"
|
||||
)
|
||||
|
||||
|
||||
class ReplayThreatError(ValueError):
|
||||
"""A virtual rig, source pose or threat input is ambiguous or unsafe."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayPose:
|
||||
frame_id: str
|
||||
position_map_xyz_m: tuple[float, float, float]
|
||||
orientation_map_from_lidar_xyzw: tuple[float, float, float, float]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.frame_id:
|
||||
raise ReplayThreatError("replay pose frame id is empty")
|
||||
if (
|
||||
len(self.position_map_xyz_m) != 3
|
||||
or len(self.orientation_map_from_lidar_xyzw) != 4
|
||||
or not all(
|
||||
math.isfinite(value)
|
||||
for value in (
|
||||
*self.position_map_xyz_m,
|
||||
*self.orientation_map_from_lidar_xyzw,
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ReplayThreatError("replay pose is not finite")
|
||||
norm = math.sqrt(sum(value * value for value in self.orientation_map_from_lidar_xyzw))
|
||||
if norm < 1e-9:
|
||||
raise ReplayThreatError("replay pose orientation has no usable norm")
|
||||
|
||||
def map_point_to_body(
|
||||
self,
|
||||
point_map_xyz_m: tuple[float, float, float],
|
||||
) -> tuple[float, float, float]:
|
||||
rotation = quaternion_xyzw_to_rotation_matrix(
|
||||
self.orientation_map_from_lidar_xyzw
|
||||
)
|
||||
delta = tuple(
|
||||
point_map_xyz_m[index] - self.position_map_xyz_m[index]
|
||||
for index in range(3)
|
||||
)
|
||||
values = tuple(
|
||||
float(sum(delta[row] * rotation[row, column] for row in range(3)))
|
||||
for column in range(3)
|
||||
)
|
||||
return values[0], values[1], values[2]
|
||||
|
||||
|
||||
class ReplayPoseResolver(Protocol):
|
||||
def pose_for_frame(self, frame_id: str) -> ReplayPose | None: ...
|
||||
|
||||
|
||||
class RecordedReplayPoseResolver:
|
||||
"""Adapt the verified geometry store to the source-neutral pose seam."""
|
||||
|
||||
def __init__(self, store: object) -> None:
|
||||
method = getattr(store, "pose_values_for_frame", None)
|
||||
if not callable(method):
|
||||
raise ReplayThreatError("recorded pose store does not expose verified poses")
|
||||
self._pose_values_for_frame = method
|
||||
|
||||
def pose_for_frame(self, frame_id: str) -> ReplayPose | None:
|
||||
values = self._pose_values_for_frame(frame_id)
|
||||
if values is None:
|
||||
return None
|
||||
position, orientation = values
|
||||
return ReplayPose(
|
||||
frame_id=frame_id,
|
||||
position_map_xyz_m=position,
|
||||
orientation_map_from_lidar_xyzw=orientation,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VirtualRigProfile:
|
||||
profile_id: str
|
||||
body_length_m: float
|
||||
body_width_m: float
|
||||
lidar_reference: str
|
||||
nominal_sensor_height_m: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VirtualCorridorProfile:
|
||||
profile_id: str
|
||||
forward_length_m: float
|
||||
rear_margin_m: float
|
||||
lateral_clearance_m: float
|
||||
prediction_horizon_seconds: float
|
||||
occupied_voxel_size_m: float
|
||||
minimum_motion_span_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayThreatProfile:
|
||||
profile_id: str
|
||||
provider_id: str
|
||||
source_id: str
|
||||
session_id: str
|
||||
temporal_result_id: str
|
||||
temporal_frames_sha256: str
|
||||
geometry_result_id: str
|
||||
geometry_frames_sha256: str
|
||||
detector_result_id: str
|
||||
detector_frames_sha256: str
|
||||
source_pack_id: str
|
||||
source_pack_sha256: str
|
||||
calibration_id: str
|
||||
calibration_content_sha256: str
|
||||
rig: VirtualRigProfile
|
||||
corridor: VirtualCorridorProfile
|
||||
profile_sha256: str
|
||||
|
||||
|
||||
class DualEvidenceReplayThreatProvider:
|
||||
"""Assess metric and nonmetric evidence without choosing a primary sensor."""
|
||||
|
||||
provider_id: str = REPLAY_THREAT_PROVIDER_ID
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
pose_resolver: ReplayPoseResolver,
|
||||
profile: ReplayThreatProfile,
|
||||
) -> None:
|
||||
if profile.provider_id != self.provider_id:
|
||||
raise ReplayThreatError("threat provider identity changed")
|
||||
self.pose_resolver = pose_resolver
|
||||
self.profile = profile
|
||||
|
||||
def assess(self, obstacle_map: LocalObstacleMap) -> tuple[ThreatAssessment, ...]:
|
||||
if (
|
||||
obstacle_map.source_id != self.profile.source_id
|
||||
or obstacle_map.session_id != self.profile.session_id
|
||||
):
|
||||
raise ReplayThreatError("obstacle map escaped the threat profile")
|
||||
pose = self.pose_resolver.pose_for_frame(obstacle_map.frame_id)
|
||||
assessments = [
|
||||
self._metric_or_stale(obstacle_map.frame_id, obstacle, pose)
|
||||
for obstacle in (*obstacle_map.occupied, *obstacle_map.unknown)
|
||||
]
|
||||
assessments.extend(
|
||||
self._camera_only(obstacle_map.frame_id, proposal.proposal_id)
|
||||
for proposal in obstacle_map.camera_uncertainty
|
||||
)
|
||||
return tuple(assessments)
|
||||
|
||||
def _metric_or_stale(
|
||||
self,
|
||||
frame_id: str,
|
||||
obstacle: TemporalObstacle,
|
||||
pose: ReplayPose | None,
|
||||
) -> ThreatAssessment:
|
||||
if obstacle.state is not TemporalState.CURRENT:
|
||||
return self._unknown(
|
||||
frame_id,
|
||||
obstacle.component_id,
|
||||
("stale-evidence", f"temporal-state-{obstacle.state.value}"),
|
||||
)
|
||||
if pose is None or obstacle.last_centroid_xyz_m is None or not obstacle.cells:
|
||||
return self._unknown(
|
||||
frame_id,
|
||||
obstacle.component_id,
|
||||
("current-pose-or-metric-geometry-unavailable",),
|
||||
)
|
||||
|
||||
centroid_body = pose.map_point_to_body(obstacle.last_centroid_xyz_m)
|
||||
cells_body = tuple(
|
||||
pose.map_point_to_body(
|
||||
(
|
||||
(cell.x + 0.5) * self.profile.corridor.occupied_voxel_size_m,
|
||||
(cell.y + 0.5) * self.profile.corridor.occupied_voxel_size_m,
|
||||
(cell.z + 0.5) * self.profile.corridor.occupied_voxel_size_m,
|
||||
)
|
||||
)
|
||||
for cell in obstacle.cells
|
||||
)
|
||||
velocity_body = self._relative_velocity_body(obstacle, pose)
|
||||
corridor_entry = _first_corridor_entry_seconds(
|
||||
cells_body,
|
||||
velocity_body,
|
||||
rig=self.profile.rig,
|
||||
corridor=self.profile.corridor,
|
||||
)
|
||||
current_intersection = _intersects_corridor_now(
|
||||
cells_body,
|
||||
rig=self.profile.rig,
|
||||
corridor=self.profile.corridor,
|
||||
)
|
||||
motion_complete = (
|
||||
obstacle.motion is not MotionState.UNKNOWN and velocity_body is not None
|
||||
)
|
||||
if current_intersection or (motion_complete and corridor_entry is not None):
|
||||
intersection = CorridorIntersection.INTERSECTS
|
||||
decision = ThreatDecision.THREAT
|
||||
reasons = [
|
||||
"current-corridor-intersection"
|
||||
if current_intersection
|
||||
else "predicted-corridor-intersection",
|
||||
"metric-lidar-geometry",
|
||||
]
|
||||
elif motion_complete:
|
||||
intersection = CorridorIntersection.CLEAR
|
||||
decision = ThreatDecision.NOT_THREAT
|
||||
reasons = ["predicted-corridor-clear", "metric-lidar-geometry"]
|
||||
else:
|
||||
intersection = CorridorIntersection.UNKNOWN
|
||||
decision = ThreatDecision.UNKNOWN
|
||||
reasons = ["motion-incomplete", "metric-lidar-geometry"]
|
||||
|
||||
closest = _closest_body_clearance_m(
|
||||
cells_body,
|
||||
velocity_body,
|
||||
rig=self.profile.rig,
|
||||
horizon_seconds=self.profile.corridor.prediction_horizon_seconds,
|
||||
)
|
||||
ttc = _first_body_entry_seconds(
|
||||
cells_body,
|
||||
velocity_body,
|
||||
rig=self.profile.rig,
|
||||
voxel_size_m=self.profile.corridor.occupied_voxel_size_m,
|
||||
horizon_seconds=self.profile.corridor.prediction_horizon_seconds,
|
||||
)
|
||||
relative_speed = _closing_speed_mps(centroid_body, velocity_body)
|
||||
if velocity_body is None:
|
||||
reasons.append(f"motion-{obstacle.motion_reason}")
|
||||
else:
|
||||
reasons.append(f"motion-{obstacle.motion.value}")
|
||||
if obstacle.semantic_hint is None:
|
||||
reasons.append("geometry-only-evidence")
|
||||
else:
|
||||
reasons.append("camera-lidar-associated-evidence")
|
||||
return ThreatAssessment(
|
||||
assessment_id=_assessment_id(frame_id, obstacle.component_id),
|
||||
component_id=obstacle.component_id,
|
||||
rig_profile_id=self.profile.rig.profile_id,
|
||||
corridor_profile_id=self.profile.corridor.profile_id,
|
||||
qualification=QualificationState.QUALIFIED,
|
||||
relative_speed_mps=relative_speed,
|
||||
closest_approach_m=closest,
|
||||
ttc_seconds=ttc,
|
||||
corridor_intersection=intersection,
|
||||
decision=decision,
|
||||
reason_codes=tuple(reasons),
|
||||
)
|
||||
|
||||
def _relative_velocity_body(
|
||||
self,
|
||||
obstacle: TemporalObstacle,
|
||||
current_pose: ReplayPose,
|
||||
) -> tuple[float, float] | None:
|
||||
if len(obstacle.history) < 2:
|
||||
return None
|
||||
first = obstacle.history[0]
|
||||
last = obstacle.history[-1]
|
||||
span_seconds = (last.evidence_time_ns - first.evidence_time_ns) / 1_000_000_000
|
||||
if span_seconds < self.profile.corridor.minimum_motion_span_seconds:
|
||||
return None
|
||||
first_pose = self.pose_resolver.pose_for_frame(first.frame_id)
|
||||
last_pose = self.pose_resolver.pose_for_frame(last.frame_id)
|
||||
if first_pose is None or last_pose is None or last.frame_id != current_pose.frame_id:
|
||||
return None
|
||||
obstacle_delta = tuple(
|
||||
last.centroid_xyz_m[index] - first.centroid_xyz_m[index]
|
||||
for index in range(3)
|
||||
)
|
||||
rig_delta = tuple(
|
||||
last_pose.position_map_xyz_m[index] - first_pose.position_map_xyz_m[index]
|
||||
for index in range(3)
|
||||
)
|
||||
relative_map = tuple(
|
||||
(obstacle_delta[index] - rig_delta[index]) / span_seconds
|
||||
for index in range(3)
|
||||
)
|
||||
rotation = quaternion_xyzw_to_rotation_matrix(
|
||||
current_pose.orientation_map_from_lidar_xyzw
|
||||
)
|
||||
body = tuple(
|
||||
float(sum(relative_map[row] * rotation[row, column] for row in range(3)))
|
||||
for column in range(3)
|
||||
)
|
||||
return body[0], body[1]
|
||||
|
||||
def _camera_only(self, frame_id: str, proposal_id: str) -> ThreatAssessment:
|
||||
return self._unknown(
|
||||
frame_id,
|
||||
proposal_id,
|
||||
("camera-only-no-metric-geometry", "absence-of-lidar-is-not-safe"),
|
||||
)
|
||||
|
||||
def _unknown(
|
||||
self,
|
||||
frame_id: str,
|
||||
component_id: str,
|
||||
reasons: tuple[str, ...],
|
||||
) -> ThreatAssessment:
|
||||
return ThreatAssessment(
|
||||
assessment_id=_assessment_id(frame_id, component_id),
|
||||
component_id=component_id,
|
||||
rig_profile_id=self.profile.rig.profile_id,
|
||||
corridor_profile_id=self.profile.corridor.profile_id,
|
||||
qualification=QualificationState.UNQUALIFIED,
|
||||
relative_speed_mps=None,
|
||||
closest_approach_m=None,
|
||||
ttc_seconds=None,
|
||||
corridor_intersection=CorridorIntersection.UNKNOWN,
|
||||
decision=ThreatDecision.UNKNOWN,
|
||||
reason_codes=reasons,
|
||||
)
|
||||
|
||||
|
||||
def load_replay_threat_profile(path: Path) -> ReplayThreatProfile:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ReplayThreatError("replay threat profile is not a regular file")
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
document = _object(json.loads(raw), "replay threat profile")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReplayThreatError("replay threat profile JSON is invalid") from exc
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"provider_id",
|
||||
"source",
|
||||
"calibration",
|
||||
"virtual_rig",
|
||||
"corridor",
|
||||
"policy",
|
||||
"authority",
|
||||
},
|
||||
"replay threat profile",
|
||||
)
|
||||
if (
|
||||
document["schema_version"] != REPLAY_THREAT_PROFILE_SCHEMA
|
||||
or document["provider_id"] != REPLAY_THREAT_PROVIDER_ID
|
||||
):
|
||||
raise ReplayThreatError("replay threat profile identity is incompatible")
|
||||
source = _object(document["source"], "threat source")
|
||||
calibration = _object(document["calibration"], "threat calibration")
|
||||
rig = _object(document["virtual_rig"], "virtual rig")
|
||||
corridor = _object(document["corridor"], "virtual corridor")
|
||||
policy = _object(document["policy"], "threat policy")
|
||||
authority = _object(document["authority"], "threat authority")
|
||||
_exact_keys(
|
||||
source,
|
||||
{
|
||||
"source_id",
|
||||
"session_id",
|
||||
"temporal_result_id",
|
||||
"temporal_frames_sha256",
|
||||
"geometry_result_id",
|
||||
"geometry_frames_sha256",
|
||||
"detector_result_id",
|
||||
"detector_frames_sha256",
|
||||
"source_pack_id",
|
||||
"source_pack_sha256",
|
||||
},
|
||||
"threat source",
|
||||
)
|
||||
_exact_keys(
|
||||
calibration,
|
||||
{"calibration_id", "content_identity_sha256", "usage"},
|
||||
"threat calibration",
|
||||
)
|
||||
_exact_keys(
|
||||
rig,
|
||||
{
|
||||
"profile_id",
|
||||
"body_length_m",
|
||||
"body_width_m",
|
||||
"lidar_reference",
|
||||
"nominal_sensor_height_m",
|
||||
"physical_mount_claimed",
|
||||
},
|
||||
"virtual rig",
|
||||
)
|
||||
_exact_keys(
|
||||
corridor,
|
||||
{
|
||||
"profile_id",
|
||||
"forward_length_m",
|
||||
"rear_margin_m",
|
||||
"lateral_clearance_m",
|
||||
"prediction_horizon_seconds",
|
||||
"occupied_voxel_size_m",
|
||||
"minimum_motion_span_seconds",
|
||||
},
|
||||
"virtual corridor",
|
||||
)
|
||||
_exact_keys(
|
||||
policy,
|
||||
{
|
||||
"camera_only_decision",
|
||||
"held_or_stale_decision",
|
||||
"semantic_class_used",
|
||||
"detector_identity_used",
|
||||
"absence_of_points_means_free",
|
||||
"geometry_only_is_eligible",
|
||||
},
|
||||
"threat policy",
|
||||
)
|
||||
_exact_keys(
|
||||
authority,
|
||||
{
|
||||
"mode",
|
||||
"physical_live",
|
||||
"physical_collision_accepted",
|
||||
"commands_enabled",
|
||||
"actuation_allowed",
|
||||
"navigation_or_safety_accepted",
|
||||
},
|
||||
"threat authority",
|
||||
)
|
||||
if (
|
||||
calibration.get("usage") != "projection-binding-only"
|
||||
or rig.get("physical_mount_claimed") is not False
|
||||
or policy
|
||||
!= {
|
||||
"camera_only_decision": "unknown",
|
||||
"held_or_stale_decision": "unknown",
|
||||
"semantic_class_used": False,
|
||||
"detector_identity_used": False,
|
||||
"absence_of_points_means_free": False,
|
||||
"geometry_only_is_eligible": True,
|
||||
}
|
||||
or authority
|
||||
!= {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": False,
|
||||
"physical_collision_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
):
|
||||
raise ReplayThreatError("replay threat policy or authority is unsafe")
|
||||
virtual_rig = VirtualRigProfile(
|
||||
profile_id=_string(rig, "profile_id"),
|
||||
body_length_m=_positive_number(rig, "body_length_m"),
|
||||
body_width_m=_positive_number(rig, "body_width_m"),
|
||||
lidar_reference=_string(rig, "lidar_reference"),
|
||||
nominal_sensor_height_m=_positive_number(rig, "nominal_sensor_height_m"),
|
||||
)
|
||||
virtual_corridor = VirtualCorridorProfile(
|
||||
profile_id=_string(corridor, "profile_id"),
|
||||
forward_length_m=_positive_number(corridor, "forward_length_m"),
|
||||
rear_margin_m=_nonnegative_number(corridor, "rear_margin_m"),
|
||||
lateral_clearance_m=_nonnegative_number(corridor, "lateral_clearance_m"),
|
||||
prediction_horizon_seconds=_positive_number(
|
||||
corridor, "prediction_horizon_seconds"
|
||||
),
|
||||
occupied_voxel_size_m=_positive_number(corridor, "occupied_voxel_size_m"),
|
||||
minimum_motion_span_seconds=_positive_number(
|
||||
corridor, "minimum_motion_span_seconds"
|
||||
),
|
||||
)
|
||||
if virtual_rig.lidar_reference != "virtual-body-center":
|
||||
raise ReplayThreatError("virtual LiDAR reference is unsupported")
|
||||
for value in (
|
||||
source.get("temporal_frames_sha256"),
|
||||
source.get("geometry_frames_sha256"),
|
||||
source.get("detector_frames_sha256"),
|
||||
source.get("source_pack_sha256"),
|
||||
calibration.get("content_identity_sha256"),
|
||||
):
|
||||
_sha256(value, "threat evidence digest")
|
||||
return ReplayThreatProfile(
|
||||
profile_id=_string(document, "profile_id"),
|
||||
provider_id=_string(document, "provider_id"),
|
||||
source_id=_string(source, "source_id"),
|
||||
session_id=_string(source, "session_id"),
|
||||
temporal_result_id=_string(source, "temporal_result_id"),
|
||||
temporal_frames_sha256=_string(source, "temporal_frames_sha256"),
|
||||
geometry_result_id=_string(source, "geometry_result_id"),
|
||||
geometry_frames_sha256=_string(source, "geometry_frames_sha256"),
|
||||
detector_result_id=_string(source, "detector_result_id"),
|
||||
detector_frames_sha256=_string(source, "detector_frames_sha256"),
|
||||
source_pack_id=_string(source, "source_pack_id"),
|
||||
source_pack_sha256=_string(source, "source_pack_sha256"),
|
||||
calibration_id=_string(calibration, "calibration_id"),
|
||||
calibration_content_sha256=_string(calibration, "content_identity_sha256"),
|
||||
rig=virtual_rig,
|
||||
corridor=virtual_corridor,
|
||||
profile_sha256=hashlib.sha256(raw).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def _assessment_id(frame_id: str, component_id: str) -> str:
|
||||
digest = hashlib.sha256(f"{frame_id}\0{component_id}".encode()).hexdigest()
|
||||
return f"threat-{digest}"
|
||||
|
||||
|
||||
def _corridor_bounds(
|
||||
rig: VirtualRigProfile,
|
||||
corridor: VirtualCorridorProfile,
|
||||
*,
|
||||
expansion_m: float,
|
||||
) -> tuple[float, float, float, float]:
|
||||
return (
|
||||
-(rig.body_length_m / 2 + corridor.rear_margin_m + expansion_m),
|
||||
rig.body_length_m / 2 + corridor.forward_length_m + expansion_m,
|
||||
-(rig.body_width_m / 2 + corridor.lateral_clearance_m + expansion_m),
|
||||
rig.body_width_m / 2 + corridor.lateral_clearance_m + expansion_m,
|
||||
)
|
||||
|
||||
|
||||
def _body_bounds(
|
||||
rig: VirtualRigProfile,
|
||||
*,
|
||||
expansion_m: float,
|
||||
) -> tuple[float, float, float, float]:
|
||||
return (
|
||||
-(rig.body_length_m / 2 + expansion_m),
|
||||
rig.body_length_m / 2 + expansion_m,
|
||||
-(rig.body_width_m / 2 + expansion_m),
|
||||
rig.body_width_m / 2 + expansion_m,
|
||||
)
|
||||
|
||||
|
||||
def _intersects_corridor_now(
|
||||
cells_body: tuple[tuple[float, float, float], ...],
|
||||
*,
|
||||
rig: VirtualRigProfile,
|
||||
corridor: VirtualCorridorProfile,
|
||||
) -> bool:
|
||||
expansion = corridor.occupied_voxel_size_m * math.sqrt(2) / 2
|
||||
bounds = _corridor_bounds(rig, corridor, expansion_m=expansion)
|
||||
return any(_inside((point[0], point[1]), bounds) for point in cells_body)
|
||||
|
||||
|
||||
def _first_corridor_entry_seconds(
|
||||
cells_body: tuple[tuple[float, float, float], ...],
|
||||
velocity_body: tuple[float, float] | None,
|
||||
*,
|
||||
rig: VirtualRigProfile,
|
||||
corridor: VirtualCorridorProfile,
|
||||
) -> float | None:
|
||||
if velocity_body is None:
|
||||
return None
|
||||
expansion = corridor.occupied_voxel_size_m * math.sqrt(2) / 2
|
||||
bounds = _corridor_bounds(rig, corridor, expansion_m=expansion)
|
||||
entries = (
|
||||
_ray_box_entry((point[0], point[1]), velocity_body, bounds)
|
||||
for point in cells_body
|
||||
)
|
||||
valid = [
|
||||
entry
|
||||
for entry in entries
|
||||
if entry is not None and entry <= corridor.prediction_horizon_seconds
|
||||
]
|
||||
return None if not valid else round(min(valid), 12)
|
||||
|
||||
|
||||
def _first_body_entry_seconds(
|
||||
cells_body: tuple[tuple[float, float, float], ...],
|
||||
velocity_body: tuple[float, float] | None,
|
||||
*,
|
||||
rig: VirtualRigProfile,
|
||||
voxel_size_m: float,
|
||||
horizon_seconds: float,
|
||||
) -> float | None:
|
||||
if velocity_body is None:
|
||||
return None
|
||||
expansion = voxel_size_m * math.sqrt(2) / 2
|
||||
bounds = _body_bounds(rig, expansion_m=expansion)
|
||||
valid = [
|
||||
entry
|
||||
for point in cells_body
|
||||
if (entry := _ray_box_entry((point[0], point[1]), velocity_body, bounds))
|
||||
is not None
|
||||
and entry <= horizon_seconds
|
||||
]
|
||||
return None if not valid else round(min(valid), 12)
|
||||
|
||||
|
||||
def _ray_box_entry(
|
||||
point: tuple[float, float],
|
||||
velocity: tuple[float, float],
|
||||
bounds: tuple[float, float, float, float],
|
||||
) -> float | None:
|
||||
t_min = 0.0
|
||||
t_max = math.inf
|
||||
for coordinate, speed, lower, upper in (
|
||||
(point[0], velocity[0], bounds[0], bounds[1]),
|
||||
(point[1], velocity[1], bounds[2], bounds[3]),
|
||||
):
|
||||
if abs(speed) < 1e-12:
|
||||
if coordinate < lower or coordinate > upper:
|
||||
return None
|
||||
continue
|
||||
first = (lower - coordinate) / speed
|
||||
second = (upper - coordinate) / speed
|
||||
near, far = min(first, second), max(first, second)
|
||||
t_min = max(t_min, near)
|
||||
t_max = min(t_max, far)
|
||||
if t_min > t_max:
|
||||
return None
|
||||
return max(0.0, t_min) if t_max >= 0.0 else None
|
||||
|
||||
|
||||
def _closest_body_clearance_m(
|
||||
cells_body: tuple[tuple[float, float, float], ...],
|
||||
velocity_body: tuple[float, float] | None,
|
||||
*,
|
||||
rig: VirtualRigProfile,
|
||||
horizon_seconds: float,
|
||||
) -> float:
|
||||
bounds = _body_bounds(rig, expansion_m=0.0)
|
||||
candidates = {0.0, horizon_seconds}
|
||||
if velocity_body is not None:
|
||||
speed_squared = velocity_body[0] ** 2 + velocity_body[1] ** 2
|
||||
if speed_squared > 1e-12:
|
||||
for point in cells_body:
|
||||
candidates.add(
|
||||
min(
|
||||
horizon_seconds,
|
||||
max(
|
||||
0.0,
|
||||
-(
|
||||
point[0] * velocity_body[0]
|
||||
+ point[1] * velocity_body[1]
|
||||
)
|
||||
/ speed_squared,
|
||||
),
|
||||
)
|
||||
)
|
||||
for coordinate, speed, lower, upper in (
|
||||
(point[0], velocity_body[0], bounds[0], bounds[1]),
|
||||
(point[1], velocity_body[1], bounds[2], bounds[3]),
|
||||
):
|
||||
if abs(speed) > 1e-12:
|
||||
candidates.add(
|
||||
min(horizon_seconds, max(0.0, (lower - coordinate) / speed))
|
||||
)
|
||||
candidates.add(
|
||||
min(horizon_seconds, max(0.0, (upper - coordinate) / speed))
|
||||
)
|
||||
velocity = velocity_body or (0.0, 0.0)
|
||||
clearance = min(
|
||||
_point_box_distance(
|
||||
(point[0] + velocity[0] * time_s, point[1] + velocity[1] * time_s),
|
||||
bounds,
|
||||
)
|
||||
for point in cells_body
|
||||
for time_s in candidates
|
||||
)
|
||||
return round(clearance, 12)
|
||||
|
||||
|
||||
def _point_box_distance(
|
||||
point: tuple[float, float],
|
||||
bounds: tuple[float, float, float, float],
|
||||
) -> float:
|
||||
dx = max(bounds[0] - point[0], 0.0, point[0] - bounds[1])
|
||||
dy = max(bounds[2] - point[1], 0.0, point[1] - bounds[3])
|
||||
return math.hypot(dx, dy)
|
||||
|
||||
|
||||
def _closing_speed_mps(
|
||||
centroid_body: tuple[float, float, float],
|
||||
velocity_body: tuple[float, float] | None,
|
||||
) -> float | None:
|
||||
if velocity_body is None:
|
||||
return None
|
||||
distance = math.hypot(centroid_body[0], centroid_body[1])
|
||||
if distance < 1e-9:
|
||||
return round(math.hypot(*velocity_body), 12)
|
||||
return round(
|
||||
-(
|
||||
centroid_body[0] * velocity_body[0]
|
||||
+ centroid_body[1] * velocity_body[1]
|
||||
)
|
||||
/ distance,
|
||||
12,
|
||||
)
|
||||
|
||||
|
||||
def _inside(
|
||||
point: tuple[float, float],
|
||||
bounds: tuple[float, float, float, float],
|
||||
) -> bool:
|
||||
return bounds[0] <= point[0] <= bounds[1] and bounds[2] <= point[1] <= bounds[3]
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise ReplayThreatError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(document: dict[str, object], expected: set[str], label: str) -> None:
|
||||
if set(document) != expected:
|
||||
raise ReplayThreatError(f"{label} fields are incompatible")
|
||||
|
||||
|
||||
def _string(document: dict[str, object], key: str) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ReplayThreatError(f"{key} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_number(document: dict[str, object], key: str) -> float:
|
||||
value = _number(document, key)
|
||||
if value <= 0.0:
|
||||
raise ReplayThreatError(f"{key} must be positive")
|
||||
return value
|
||||
|
||||
|
||||
def _nonnegative_number(document: dict[str, object], key: str) -> float:
|
||||
value = _number(document, key)
|
||||
if value < 0.0:
|
||||
raise ReplayThreatError(f"{key} must be nonnegative")
|
||||
return value
|
||||
|
||||
|
||||
def _number(document: dict[str, object], key: str) -> float:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, int | float) or isinstance(value, bool) or not math.isfinite(value):
|
||||
raise ReplayThreatError(f"{key} must be a finite number")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _sha256(value: object, label: str) -> None:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) != 64
|
||||
or any(character not in "0123456789abcdef" for character in value)
|
||||
):
|
||||
raise ReplayThreatError(f"{label} is invalid")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_REPLAY_THREAT_PROFILE_PATH",
|
||||
"DualEvidenceReplayThreatProvider",
|
||||
"REPLAY_THREAT_PROFILE_SCHEMA",
|
||||
"REPLAY_THREAT_PROVIDER_ID",
|
||||
"RecordedReplayPoseResolver",
|
||||
"ReplayPose",
|
||||
"ReplayPoseResolver",
|
||||
"ReplayThreatError",
|
||||
"ReplayThreatProfile",
|
||||
"VirtualCorridorProfile",
|
||||
"VirtualRigProfile",
|
||||
"load_replay_threat_profile",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
"""Command-line entrypoint for the local M4.6 replay threat run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .threat_replay import build_threat_replay
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository-root", type=Path, required=True)
|
||||
parser.add_argument("--temporal-result-root", type=Path, required=True)
|
||||
parser.add_argument("--geometry-result-root", type=Path, required=True)
|
||||
parser.add_argument("--detector-result-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
result = build_threat_replay(
|
||||
repository_root=args.repository_root,
|
||||
temporal_result_root=args.temporal_result_root,
|
||||
geometry_result_root=args.geometry_result_root,
|
||||
detector_result_root=args.detector_result_root,
|
||||
output_root=args.output_root,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"result_root": str(result.result_root),
|
||||
"accepted": result.accepted,
|
||||
"metrics": result.metrics,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0 if result.accepted else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -112,6 +112,7 @@ from k1link.web.laboratory_api import build_laboratory_router
|
||||
from k1link.web.laboratory_report_api import build_laboratory_report_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
||||
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
||||
from k1link.web.map_api import (
|
||||
MapGatewayConfiguration,
|
||||
MapGatewayProxy,
|
||||
@@ -752,6 +753,17 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_m4_threat_replay_router(
|
||||
root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "m4"
|
||||
/ "replay-threat"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e46e_ready_stack_router(
|
||||
root_provider=lambda: (
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Read-only LAB projection of the canonical M4.6 replay threat result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Iterator
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from k1link.perception.threat_replay import (
|
||||
THREAT_REPLAY_FRAME_SCHEMA,
|
||||
THREAT_REPLAY_RESULT_PREFIX,
|
||||
THREAT_REPLAY_VISUAL_SCHEMA,
|
||||
ThreatReplayError,
|
||||
ThreatReplayResult,
|
||||
read_threat_replay_result,
|
||||
)
|
||||
|
||||
M4_THREAT_CATALOG_SCHEMA: Final = "missioncore.m4-threat-replay-catalog/v1"
|
||||
M4_THREAT_VIEW_SCHEMA: Final = "missioncore.m4-threat-replay-view/v1"
|
||||
M4_THREAT_VIDEO_SCHEMA: Final = "missioncore.m4-threat-video-overlay/v1"
|
||||
M4_THREAT_VISUAL_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.m4-threat-visual-catalog/v1"
|
||||
)
|
||||
_RESULT_ID = re.compile(rf"^{THREAT_REPLAY_RESULT_PREFIX}[a-f0-9]{{64}}$")
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
def build_m4_threat_replay_router(
|
||||
*,
|
||||
root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/m4-threat", tags=["laboratory"])
|
||||
|
||||
def result(result_id: str) -> ThreatReplayResult:
|
||||
if _RESULT_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден")
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден")
|
||||
path = (root / result_id).resolve()
|
||||
if path.parent != root or path.is_symlink():
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден")
|
||||
try:
|
||||
return _read_threat_result_cached(str(path), _result_signature(path))
|
||||
except (ThreatReplayError, OSError, ValueError):
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден") from None
|
||||
|
||||
@router.get("/results")
|
||||
def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]:
|
||||
candidates = _candidates(root_provider)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
frozen = result(candidate.name)
|
||||
if len(items) < limit:
|
||||
items.append(_project_result(frozen))
|
||||
except HTTPException:
|
||||
invalid_total += 1
|
||||
return {
|
||||
"schema_version": M4_THREAT_CATALOG_SCHEMA,
|
||||
"configured": _configured_root(root_provider) is not None,
|
||||
"items": items,
|
||||
"candidate_total": len(candidates),
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/visuals")
|
||||
def list_visuals(result_id: str) -> dict[str, object]:
|
||||
frozen = result(result_id)
|
||||
frames = _read_jsonl(frozen.result_root / "visual-frames.jsonl")
|
||||
return {
|
||||
"schema_version": M4_THREAT_VISUAL_CATALOG_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"items": [
|
||||
{
|
||||
"ordinal": index + 1,
|
||||
"sequence": item["sequence"],
|
||||
"frame_id": item["frame_id"],
|
||||
"source_time_ns": item["source_time_ns"],
|
||||
"metric_obstacle_count": len(_array(item.get("metric_obstacles"))),
|
||||
"camera_proposal_count": len(_array(item.get("camera_proposals"))),
|
||||
"point_cloud_sample_count": item["point_cloud_sample_count"],
|
||||
}
|
||||
for index, item in enumerate(frames)
|
||||
],
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/visuals/{ordinal}")
|
||||
def get_visual(result_id: str, ordinal: int) -> dict[str, object]:
|
||||
frozen = result(result_id)
|
||||
if not 1 <= ordinal <= 32:
|
||||
raise HTTPException(status_code=404, detail="M4.6 visual frame не найден")
|
||||
frames = _read_jsonl(frozen.result_root / "visual-frames.jsonl")
|
||||
if len(frames) != 32:
|
||||
raise HTTPException(status_code=404, detail="M4.6 visual frame не найден")
|
||||
return {
|
||||
**copy.deepcopy(frames[ordinal - 1]),
|
||||
"result_id": result_id,
|
||||
"ordinal": ordinal,
|
||||
"ground_truth": False,
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/video-overlay")
|
||||
def get_video_overlay(result_id: str) -> dict[str, object]:
|
||||
frozen = result(result_id)
|
||||
identity = frozen.manifest["identity"]
|
||||
assert isinstance(identity, dict)
|
||||
return copy.deepcopy(
|
||||
_cached_video_overlay(
|
||||
result_id,
|
||||
str(frozen.result_root),
|
||||
str(identity["frames_sha256"]),
|
||||
str(identity["source_session_id"]),
|
||||
)
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_threat_result_cached(
|
||||
root_value: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> ThreatReplayResult:
|
||||
del signature
|
||||
return read_threat_replay_result(Path(root_value))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _cached_video_overlay(
|
||||
result_id: str,
|
||||
root_value: str,
|
||||
frames_sha256: str,
|
||||
source_session_id: str,
|
||||
) -> dict[str, object]:
|
||||
root = Path(root_value).resolve(strict=True)
|
||||
if root.is_symlink() or not root.is_dir() or len(frames_sha256) != 64:
|
||||
raise ValueError("M4.6 video evidence identity changed")
|
||||
frames = []
|
||||
for expected_sequence, row in enumerate(_iter_jsonl(root / "frames.jsonl")):
|
||||
if (
|
||||
row.get("schema_version") != THREAT_REPLAY_FRAME_SCHEMA
|
||||
or row.get("sequence") != expected_sequence
|
||||
):
|
||||
raise ValueError("M4.6 video frame order changed")
|
||||
frames.append(
|
||||
{
|
||||
"frame_index": expected_sequence,
|
||||
"session_seconds": _nonnegative_int(
|
||||
row.get("source_time_ns"), "source time"
|
||||
)
|
||||
/ 1_000_000_000,
|
||||
"source_available": row["source_available"],
|
||||
"camera_proposals": copy.deepcopy(row["camera_proposals"]),
|
||||
"decision_counts": _decision_counts(_array(row.get("assessments"))),
|
||||
}
|
||||
)
|
||||
if len(frames) != 4489:
|
||||
raise ValueError("M4.6 video frame coverage changed")
|
||||
return {
|
||||
"schema_version": M4_THREAT_VIDEO_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"recorded_source": {
|
||||
"session_id": source_session_id,
|
||||
"source_id": "sensor.camera.right",
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
},
|
||||
"image_width": 800,
|
||||
"image_height": 600,
|
||||
"timeline_start_seconds": frames[0]["session_seconds"],
|
||||
"timeline_end_seconds": frames[-1]["session_seconds"],
|
||||
"frame_count": len(frames),
|
||||
"frames": frames,
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"access": "read-only-replay-simulated-video",
|
||||
}
|
||||
|
||||
|
||||
def _project_result(result: ThreatReplayResult) -> dict[str, object]:
|
||||
identity = result.manifest["identity"]
|
||||
assert isinstance(identity, dict)
|
||||
return {
|
||||
"schema_version": M4_THREAT_VIEW_SCHEMA,
|
||||
"result_id": result.result_id,
|
||||
"created_at_utc": result.manifest["created_at_utc"],
|
||||
"status": result.report["status"],
|
||||
"profile_id": identity["profile_id"],
|
||||
"rig_profile_id": identity["rig_profile_id"],
|
||||
"corridor_profile_id": identity["corridor_profile_id"],
|
||||
"source_result_ids": {
|
||||
"detector": identity["detector_result_id"],
|
||||
"geometry": identity["geometry_result_id"],
|
||||
"temporal": identity["temporal_result_id"],
|
||||
},
|
||||
"metrics": copy.deepcopy(result.metrics),
|
||||
"configuration": copy.deepcopy(result.report["configuration"]),
|
||||
"acceptance_requirements": copy.deepcopy(
|
||||
result.report["acceptance_requirements"]
|
||||
),
|
||||
"limitations": copy.deepcopy(result.report["limitations"]),
|
||||
"accepted": result.accepted,
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"physical_collision_accepted": False,
|
||||
"actuation_allowed": False,
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
|
||||
def _decision_counts(raw: list[object]) -> dict[str, int]:
|
||||
result = {"threat": 0, "not-threat": 0, "unknown": 0}
|
||||
for item in raw:
|
||||
assessment = item if isinstance(item, dict) else {}
|
||||
decision = assessment.get("decision")
|
||||
if isinstance(decision, str) and decision in result:
|
||||
result[decision] += 1
|
||||
return result
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
value = provider()
|
||||
if value is None:
|
||||
return None
|
||||
candidate = value.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
return None
|
||||
try:
|
||||
root = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return root if root.is_dir() else None
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||
signature: list[int] = []
|
||||
for name in (
|
||||
"manifest.json",
|
||||
"report.json",
|
||||
"fixtures.json",
|
||||
"frames.jsonl",
|
||||
"visual-frames.jsonl",
|
||||
):
|
||||
path = root / name
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError("M4.6 result artifact is invalid")
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
def _candidates(provider: RootProvider) -> list[Path]:
|
||||
root = _configured_root(provider)
|
||||
if root is None:
|
||||
return []
|
||||
return sorted(
|
||||
(
|
||||
item
|
||||
for item in root.iterdir()
|
||||
if item.is_dir()
|
||||
and not item.is_symlink()
|
||||
and _RESULT_ID.fullmatch(item.name)
|
||||
),
|
||||
key=lambda item: item.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, object]]:
|
||||
return list(_iter_jsonl(path))
|
||||
|
||||
|
||||
def _iter_jsonl(path: Path) -> Iterator[dict[str, object]]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("M4.6 JSONL row is invalid")
|
||||
if value.get("schema_version") not in {
|
||||
THREAT_REPLAY_FRAME_SCHEMA,
|
||||
THREAT_REPLAY_VISUAL_SCHEMA,
|
||||
}:
|
||||
raise ValueError("M4.6 JSONL schema is invalid")
|
||||
yield value
|
||||
|
||||
|
||||
def _array(value: object) -> list[object]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("M4.6 array is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _nonnegative_int(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"M4.6 {label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"M4_THREAT_CATALOG_SCHEMA",
|
||||
"M4_THREAT_VIDEO_SCHEMA",
|
||||
"M4_THREAT_VIEW_SCHEMA",
|
||||
"M4_THREAT_VISUAL_CATALOG_SCHEMA",
|
||||
"build_m4_threat_replay_router",
|
||||
]
|
||||
@@ -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