diff --git a/src/k1link/perception/reference_graph_parity.py b/src/k1link/perception/reference_graph_parity.py index 78f39d1..f7e87b5 100644 --- a/src/k1link/perception/reference_graph_parity.py +++ b/src/k1link/perception/reference_graph_parity.py @@ -134,10 +134,15 @@ def compare_reference_graph_to_accepted_ledgers( ] if observed_camera_uncertainty != expected_camera_uncertainty: mismatch["camera_uncertainty"] += 1 - if [item.to_dict() for item in delivery.threats] != _array( - threat, - "assessments", - ): + observed_assessments = _assessment_index( + [item.to_dict() for item in delivery.threats], + "graph threat assessments", + ) + expected_assessments = _assessment_index( + _array(threat, "assessments"), + "accepted threat assessments", + ) + if observed_assessments != expected_assessments: mismatch["threat_assessments"] += 1 compared += 1 if temporal_stream.readline() or threat_stream.readline(): @@ -204,6 +209,19 @@ def _boolean(document: dict[str, object], key: str) -> bool: return value +def _assessment_index( + rows: list[dict[str, object]], + label: str, +) -> dict[str, dict[str, object]]: + indexed: dict[str, dict[str, object]] = {} + for row in rows: + component_id = _string(row, "component_id") + if component_id in indexed: + raise ReferenceGraphParityError(f"{label} contains a duplicate component") + indexed[component_id] = row + return indexed + + __all__ = [ "REFERENCE_GRAPH_PARITY_SCHEMA", "ReferenceGraphParityError", diff --git a/tests/test_reference_graph_parity.py b/tests/test_reference_graph_parity.py index 16ed30d..a23769d 100644 --- a/tests/test_reference_graph_parity.py +++ b/tests/test_reference_graph_parity.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from pathlib import Path -from k1link.perception.contracts import LocalObstacleMap, SourceAccounting +from k1link.perception.contracts import LocalObstacleMap, SourceAccounting, ThreatAssessment from k1link.perception.graph_contracts import ( REFERENCE_GRAPH_ID_V2, DeliveredFrame, @@ -18,7 +18,7 @@ from k1link.perception.reference_graph_parity import ( ) -def _result(): +def _result(threats: tuple[ThreatAssessment, ...] = ()): obstacle_map = LocalObstacleMap( source_id="RAVNOVES00", session_id="20260720T065719Z_viewer_live", @@ -48,7 +48,7 @@ def _result(): reason="object-payload-delivered", ), ), - deliveries=(DeliveredFrame(0, obstacle_map, ()),), + deliveries=(DeliveredFrame(0, obstacle_map, threats),), queue_high_watermarks=(("detector", 1),), ) @@ -119,3 +119,43 @@ def test_reference_graph_parity_reports_threat_drift(tmp_path: Path) -> None: assert report.accepted is False assert dict(report.mismatch_counts)["threat_assessments"] == 1 + + +def test_reference_graph_parity_treats_assessment_order_as_nonsemantic( + tmp_path: Path, +) -> None: + first = { + "schema_version": "missioncore.threat-assessment/v1", + "assessment_id": "threat-first", + "component_id": "component-first", + "rig_profile_id": "rig-test", + "corridor_profile_id": "corridor-test", + "qualification": "unqualified", + "relative_speed_mps": None, + "closest_approach_m": None, + "ttc_seconds": None, + "corridor_intersection": "unknown", + "decision": "unknown", + "reason_codes": ["test-unavailable"], + "authority": "replay-simulated", + "physical_collision_accepted": False, + "actuation_allowed": False, + } + second = { + **first, + "assessment_id": "threat-second", + "component_id": "component-second", + } + temporal, threat = _write_ledgers( + tmp_path, + threat_assessments=[second, first], + ) + + report = compare_reference_graph_to_accepted_ledgers( + _result((ThreatAssessment.from_dict(first), ThreatAssessment.from_dict(second))), + temporal_frames_path=temporal, + threat_frames_path=threat, + expected_frames=1, + ) + + assert report.accepted is True