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",
|
"schema_version": "missioncore.laboratory-execution-registry/v1",
|
||||||
"definitions": [
|
"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",
|
"work_id": "e33-worker-shadow",
|
||||||
"lifecycle": "canonical",
|
"lifecycle": "canonical",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "missioncore.laboratory-value-review-registry/v1",
|
"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": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"catalog_id": "e28-local-surface",
|
"catalog_id": "e28-local-surface",
|
||||||
@@ -191,6 +191,13 @@
|
|||||||
"lifecycle": "current",
|
"lifecycle": "current",
|
||||||
"visual_evidence": "available"
|
"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",
|
"catalog_id": "l34-right-yolox-truth-island-freeze",
|
||||||
"evidence_id": "l34-right-yolox-truth-island-freeze-5175a03144978b25130019da6d37bceb8c6ed6aa3d0d3a4d2df4483e1e27ae76",
|
"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
|
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`
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
### 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:
|
Deliverables:
|
||||||
|
|
||||||
- define a versioned virtual rig and corridor profile for RAVNOVES00 replay;
|
- 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.
|
contract.
|
||||||
- [ ] Current, held, stale, unavailable and conflict states are explicit.
|
- [ ] Current, held, stale, unavailable and conflict states are explicit.
|
||||||
- [ ] Moving/static/unknown state is measured without semantic-class dependence.
|
- [ ] 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.
|
- [ ] Worker 006 runs the canonical graph instead of the E15-specific server.
|
||||||
- [ ] Full source-paced replay, deterministic replay, degradation, recovery and
|
- [ ] Full source-paced replay, deterministic replay, degradation, recovery and
|
||||||
soak gates pass.
|
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
|
All fifteen M4.5 acceptance requirements are true. Synthetic tests cover ID and
|
||||||
semantic-hint changes, moving, stationary, held, expiry, camera-only uncertainty
|
semantic-hint changes, moving, stationary, held, expiry, camera-only uncertainty
|
||||||
and map-frame discontinuity. M4.5 is closed; M4.6 replay-only threat assessment
|
and map-frame discontinuity. This closed M4.5 and supplied the immutable input to
|
||||||
is the next implementation phase.
|
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
|
## 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
|
RUNNER_NAME = RUNNER.name
|
||||||
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||||
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||||
EXPECTED_WHEEL_SHA256 = "19d8caf9a522747c461fb3ca30aafe54169959d8bd8e671fa6fc8c0ac107875d"
|
EXPECTED_WHEEL_SHA256 = "94ed4b7e70471d343eafa9728ce1bd496551a6ee2c2d9e3e67fa5c6c2eadfc5b"
|
||||||
PAYLOAD_FILES = (
|
PAYLOAD_FILES = (
|
||||||
RUNNER_NAME,
|
RUNNER_NAME,
|
||||||
WHEEL_NAME,
|
WHEEL_NAME,
|
||||||
|
|||||||
@@ -311,12 +311,29 @@ class LaboratoryRunner:
|
|||||||
|
|
||||||
def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]:
|
def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]:
|
||||||
return {
|
return {
|
||||||
|
"canonical.m4-replay-threat/v1": _run_m4_replay_threat,
|
||||||
"canonical.e33-worker-shadow/v1": _run_e33,
|
"canonical.e33-worker-shadow/v1": _run_e33,
|
||||||
"canonical.e35-degradation-recovery/v1": _run_e35,
|
"canonical.e35-degradation-recovery/v1": _run_e35,
|
||||||
"canonical.e46j-raw-fisheye-realtime/v1": _run_e46j,
|
"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:
|
def _run_e33(request: LaboratoryRunRequest) -> LaboratoryAdapterResult:
|
||||||
from k1link.compute.e33_worker_shadow import run_e33_worker_shadow
|
from k1link.compute.e33_worker_shadow import run_e33_worker_shadow
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,48 @@ class RecordedGeometryStore:
|
|||||||
points.setflags(write=False)
|
points.setflags(write=False)
|
||||||
return points
|
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:
|
def _validate(self) -> None:
|
||||||
source_required = {
|
source_required = {
|
||||||
"frame_indices",
|
"frame_indices",
|
||||||
|
|||||||
@@ -73,14 +73,20 @@ def validate_threats(
|
|||||||
obstacle_map: LocalObstacleMap,
|
obstacle_map: LocalObstacleMap,
|
||||||
threats: tuple[ThreatAssessment, ...],
|
threats: tuple[ThreatAssessment, ...],
|
||||||
) -> None:
|
) -> None:
|
||||||
component_ids = {
|
evidence_ids = {
|
||||||
obstacle.component_id for obstacle in (*obstacle_map.occupied, *obstacle_map.unknown)
|
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]
|
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):
|
if len(set(assessment_ids)) != len(assessment_ids):
|
||||||
raise GraphExecutionError("threat assessment identities are duplicated")
|
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")
|
raise GraphExecutionError("threat assessment references an unknown component")
|
||||||
|
if set(assessed_ids) != evidence_ids:
|
||||||
|
raise GraphExecutionError("threat assessment coverage is incomplete")
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__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.laboratory_report_api import build_laboratory_report_router
|
||||||
from k1link.web.lidar_api import build_lidar_router
|
from k1link.web.lidar_api import build_lidar_router
|
||||||
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
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 (
|
from k1link.web.map_api import (
|
||||||
MapGatewayConfiguration,
|
MapGatewayConfiguration,
|
||||||
MapGatewayProxy,
|
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(
|
app.include_router(
|
||||||
build_e46e_ready_stack_router(
|
build_e46e_ready_stack_router(
|
||||||
root_provider=lambda: (
|
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"
|
repository_root / "config" / "laboratories"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(registry.definitions) == 31
|
assert len(registry.definitions) == 32
|
||||||
assert {item.work_id for item in registry.definitions} >= {
|
assert {item.work_id for item in registry.definitions} >= {
|
||||||
"e31-source-binding",
|
"e31-source-binding",
|
||||||
"e46j-raw-fisheye-realtime",
|
"e46j-raw-fisheye-realtime",
|
||||||
@@ -136,4 +136,5 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None:
|
|||||||
"l32-pointpillars-camera-review",
|
"l32-pointpillars-camera-review",
|
||||||
"l33-camera-first-detector-review",
|
"l33-camera-first-detector-review",
|
||||||
"l34f-adjudicated-reference",
|
"l34f-adjudicated-reference",
|
||||||
|
"m4-replay-threat",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ def test_repository_registry_classifies_every_evidence_definition() -> None:
|
|||||||
evidence, execution = _registries()
|
evidence, execution = _registries()
|
||||||
|
|
||||||
assert {row.work_id for row in execution.definitions} == {
|
assert {row.work_id for row in execution.definitions} == {
|
||||||
|
"m4-replay-threat",
|
||||||
"e33-worker-shadow",
|
"e33-worker-shadow",
|
||||||
"e35-degradation-recovery",
|
"e35-degradation-recovery",
|
||||||
"e46j-raw-fisheye-realtime",
|
"e46j-raw-fisheye-realtime",
|
||||||
|
|||||||
@@ -80,11 +80,12 @@ def test_product_value_review_registry_covers_reviewed_laboratory_families() ->
|
|||||||
root / "config" / "laboratory-value-review.json"
|
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} >= {
|
assert {entry.catalog_id for entry in registry.entries} >= {
|
||||||
"e28-local-surface",
|
"e28-local-surface",
|
||||||
"e46d-temporal-failure-audit",
|
"e46d-temporal-failure-audit",
|
||||||
"e46j-raw-fisheye-realtime",
|
"e46j-raw-fisheye-realtime",
|
||||||
"l31-pointpillars-ravnoves",
|
"l31-pointpillars-ravnoves",
|
||||||
"l34f-adjudicated-reference",
|
"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.py",
|
||||||
"temporal_replay_cli.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]:
|
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} == {}
|
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:
|
def test_new_perception_boundary_has_no_experiment_specific_imports() -> None:
|
||||||
violations: dict[str, str] = {}
|
violations: dict[str, str] = {}
|
||||||
for path in PERCEPTION_ROOT.glob("*.py"):
|
for path in PERCEPTION_ROOT.glob("*.py"):
|
||||||
|
|||||||
@@ -234,8 +234,7 @@ class _Threat:
|
|||||||
provider_id = "test-threat/v1"
|
provider_id = "test-threat/v1"
|
||||||
|
|
||||||
def assess(self, obstacle_map: LocalObstacleMap) -> tuple[ThreatAssessment, ...]:
|
def assess(self, obstacle_map: LocalObstacleMap) -> tuple[ThreatAssessment, ...]:
|
||||||
occupied = obstacle_map.occupied
|
metric = tuple(
|
||||||
return tuple(
|
|
||||||
ThreatAssessment(
|
ThreatAssessment(
|
||||||
assessment_id=f"assessment-{item.component_id}",
|
assessment_id=f"assessment-{item.component_id}",
|
||||||
component_id=item.component_id,
|
component_id=item.component_id,
|
||||||
@@ -249,8 +248,41 @@ class _Threat:
|
|||||||
decision=ThreatDecision.NOT_THREAT,
|
decision=ThreatDecision.NOT_THREAT,
|
||||||
reason_codes=("qualified-corridor-clear",),
|
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(
|
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