From 4fa1669ab70a3956a8c66fc232f0888d89486222 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 24 Aug 2026 22:38:26 +0300 Subject: [PATCH] feat(lab): publish M4.8 assisted regression evidence --- .../m48-object-centric-quality.json | 20 + .../m48-small-static-passage-regression.json | 10 + config/laboratory-execution.json | 28 + .../m48-object-quality-selection-v1.json | 235 ++ config/perception/m48-object-quality-v1.json | 72 + ...48-small-static-passage-regression-v1.json | 13 + scripts/prepare_m48_object_quality_pack.py | 53 + ...run_m48_small_static_passage_regression.py | 79 + src/k1link/laboratory/__init__.py | 4 + src/k1link/laboratory/evidence_registry.py | 137 +- src/k1link/laboratory/evidence_report.py | 23 +- src/k1link/laboratory/execution.py | 48 +- src/k1link/laboratory/m48_object_quality.py | 2660 ++++++++++++++++ src/k1link/laboratory/m48_ravnoves00_pack.py | 567 ++++ src/k1link/laboratory/m48_raw_evidence.py | 507 +++ .../laboratory/m48_small_static_regression.py | 711 +++++ src/k1link/web/advanced_laboratory_api.py | 16 +- src/k1link/web/app.py | 85 + src/k1link/web/m48_object_quality_api.py | 2777 +++++++++++++++++ tests/test_advanced_laboratory_api.py | 79 +- tests/test_laboratory_evidence_registry.py | 13 +- tests/test_laboratory_execution.py | 68 + tests/test_m48_object_quality.py | 613 ++++ tests/test_m48_object_quality_api.py | 1123 +++++++ tests/test_m48_ravnoves00_pack.py | 355 +++ tests/test_m48_raw_evidence.py | 350 +++ tests/test_m48_small_static_regression.py | 236 ++ 27 files changed, 10852 insertions(+), 30 deletions(-) create mode 100644 config/laboratories/m48-object-centric-quality.json create mode 100644 config/laboratories/m48-small-static-passage-regression.json create mode 100644 config/perception/m48-object-quality-selection-v1.json create mode 100644 config/perception/m48-object-quality-v1.json create mode 100644 config/perception/m48-small-static-passage-regression-v1.json create mode 100644 scripts/prepare_m48_object_quality_pack.py create mode 100644 scripts/run_m48_small_static_passage_regression.py create mode 100644 src/k1link/laboratory/m48_object_quality.py create mode 100644 src/k1link/laboratory/m48_ravnoves00_pack.py create mode 100644 src/k1link/laboratory/m48_raw_evidence.py create mode 100644 src/k1link/laboratory/m48_small_static_regression.py create mode 100644 src/k1link/web/m48_object_quality_api.py create mode 100644 tests/test_m48_object_quality.py create mode 100644 tests/test_m48_object_quality_api.py create mode 100644 tests/test_m48_ravnoves00_pack.py create mode 100644 tests/test_m48_raw_evidence.py create mode 100644 tests/test_m48_small_static_regression.py diff --git a/config/laboratories/m48-object-centric-quality.json b/config/laboratories/m48-object-centric-quality.json new file mode 100644 index 0000000..522a5ff --- /dev/null +++ b/config/laboratories/m48-object-centric-quality.json @@ -0,0 +1,20 @@ +{ + "schema_version": "missioncore.laboratory-evidence-definition/v2", + "work_id": "m48-object-centric-quality", + "evidence_lifecycle": [ + { + "phase": "review", + "runtime_relative_root": "m48/object-quality-packs", + "result_id_prefix": "m48-object-quality-pack", + "document_name": "manifest.json", + "schema_version": "missioncore.m48-object-centric-quality-pack/v1" + }, + { + "phase": "result", + "runtime_relative_root": "m48/object-quality-results", + "result_id_prefix": "m48-object-quality-result", + "document_name": "manifest.json", + "schema_version": "missioncore.m48-object-centric-quality-result/v1" + } + ] +} diff --git a/config/laboratories/m48-small-static-passage-regression.json b/config/laboratories/m48-small-static-passage-regression.json new file mode 100644 index 0000000..8f27361 --- /dev/null +++ b/config/laboratories/m48-small-static-passage-regression.json @@ -0,0 +1,10 @@ +{ + "schema_version": "missioncore.laboratory-evidence-definition/v1", + "work_id": "m48-small-static-passage-regression", + "evidence": { + "runtime_relative_root": "m48/small-static-passage-regression-results", + "result_id_prefix": "m48-small-static-passage-regression", + "document_name": "manifest.json", + "schema_version": "missioncore.m48-small-static-passage-regression-result/v1" + } +} diff --git a/config/laboratory-execution.json b/config/laboratory-execution.json index c34288d..05feb9e 100644 --- a/config/laboratory-execution.json +++ b/config/laboratory-execution.json @@ -1,6 +1,34 @@ { "schema_version": "missioncore.laboratory-execution-registry/v1", "definitions": [ + { + "work_id": "m48-small-static-passage-regression", + "lifecycle": "canonical", + "isolation": "core-adapter", + "adapter_id": "canonical.m48-small-static-passage-regression/v1", + "input_roles": ["pack_root", "correction_session_path", "profile_path"], + "contracts": { + "source": "missioncore.m48-object-centric-quality-pack/v1", + "provider": "missioncore.m48-assisted-object-correction-session/v1", + "graph": "missioncore.m48-assisted-anchor-comparison/v1", + "run": "missioncore.laboratory-run/v1", + "evidence": "missioncore.m48-small-static-passage-regression-result/v1" + } + }, + { + "work_id": "m48-object-centric-quality", + "lifecycle": "canonical", + "isolation": "core-adapter", + "adapter_id": "canonical.m48-object-centric-quality/v1", + "input_roles": ["pack_root", "truth_seal_root"], + "contracts": { + "source": "missioncore.m48-object-centric-quality-pack/v1", + "provider": "missioncore.m48-object-truth-seal/v1", + "graph": "missioncore.m48-object-centric-quality-graph/v1", + "run": "missioncore.laboratory-run/v1", + "evidence": "missioncore.m48-object-centric-quality-result/v1" + } + }, { "work_id": "m4-replay-threat", "lifecycle": "canonical", diff --git a/config/perception/m48-object-quality-selection-v1.json b/config/perception/m48-object-quality-selection-v1.json new file mode 100644 index 0000000..ff17f49 --- /dev/null +++ b/config/perception/m48-object-quality-selection-v1.json @@ -0,0 +1,235 @@ +{ + "schema_version": "missioncore.m48-object-quality-selection/v1", + "selection_id": "m48-ravnoves00-balanced-connected-clips/v1", + "source_id": "RAVNOVES00", + "source_session_id": "20260720T065719Z_viewer_live", + "selection_basis": "prediction-frozen-source-curation-before-independent-truth", + "camera_frame_size": { + "width": 800, + "height": 600 + }, + "selection_hypothesis_profile": { + "derivation": "exact-frozen-prediction-rows-before-independent-truth", + "small_obstacle_max_normalized_area": 0.001, + "fisheye_edge_margin_normalized": 0.08, + "sparse_scene_max_median_prediction_count": 2.0 + }, + "clips": [ + { + "clip_id": "m48-clip-01", + "component_id": "m48-component-development-01", + "route_block": "route-block-01", + "time_block": "time-block-01", + "split": "development", + "start_sequence": 1, + "end_sequence": 61 + }, + { + "clip_id": "m48-clip-02", + "component_id": "m48-component-development-01", + "route_block": "route-block-01", + "time_block": "time-block-01", + "split": "development", + "start_sequence": 121, + "end_sequence": 181 + }, + { + "clip_id": "m48-clip-03", + "component_id": "m48-component-development-01", + "route_block": "route-block-01", + "time_block": "time-block-01", + "split": "development", + "start_sequence": 241, + "end_sequence": 301 + }, + { + "clip_id": "m48-clip-04", + "component_id": "m48-component-development-02", + "route_block": "route-block-01", + "time_block": "time-block-02", + "split": "development", + "start_sequence": 421, + "end_sequence": 481 + }, + { + "clip_id": "m48-clip-05", + "component_id": "m48-component-development-02", + "route_block": "route-block-01", + "time_block": "time-block-02", + "split": "development", + "start_sequence": 581, + "end_sequence": 641 + }, + { + "clip_id": "m48-clip-06", + "component_id": "m48-component-development-02", + "route_block": "route-block-02", + "time_block": "time-block-02", + "split": "development", + "start_sequence": 821, + "end_sequence": 881 + }, + { + "clip_id": "m48-clip-07", + "component_id": "m48-component-development-03", + "route_block": "route-block-02", + "time_block": "time-block-03", + "split": "development", + "start_sequence": 1041, + "end_sequence": 1101 + }, + { + "clip_id": "m48-clip-08", + "component_id": "m48-component-development-03", + "route_block": "route-block-02", + "time_block": "time-block-03", + "split": "development", + "start_sequence": 1221, + "end_sequence": 1281 + }, + { + "clip_id": "m48-clip-09", + "component_id": "m48-component-development-03", + "route_block": "route-block-02", + "time_block": "time-block-03", + "split": "development", + "start_sequence": 1421, + "end_sequence": 1481 + }, + { + "clip_id": "m48-clip-10", + "component_id": "m48-component-development-04", + "route_block": "route-block-03-development", + "time_block": "time-block-04", + "split": "development", + "start_sequence": 1681, + "end_sequence": 1741 + }, + { + "clip_id": "m48-clip-11", + "component_id": "m48-component-development-04", + "route_block": "route-block-03-development", + "time_block": "time-block-04", + "split": "development", + "start_sequence": 1830, + "end_sequence": 1890 + }, + { + "clip_id": "m48-clip-12", + "component_id": "m48-component-development-04", + "route_block": "route-block-03-development", + "time_block": "time-block-04", + "split": "development", + "start_sequence": 2041, + "end_sequence": 2101 + }, + { + "clip_id": "m48-clip-13", + "component_id": "m48-component-validation-01", + "route_block": "route-block-03-validation", + "time_block": "time-block-05", + "split": "validation", + "start_sequence": 2191, + "end_sequence": 2251 + }, + { + "clip_id": "m48-clip-14", + "component_id": "m48-component-validation-01", + "route_block": "route-block-03-validation", + "time_block": "time-block-05", + "split": "validation", + "start_sequence": 2371, + "end_sequence": 2431 + }, + { + "clip_id": "m48-clip-15", + "component_id": "m48-component-validation-01", + "route_block": "route-block-03-validation", + "time_block": "time-block-05", + "split": "validation", + "start_sequence": 2551, + "end_sequence": 2611 + }, + { + "clip_id": "m48-clip-16", + "component_id": "m48-component-validation-02", + "route_block": "route-block-04", + "time_block": "time-block-06", + "split": "validation", + "start_sequence": 2731, + "end_sequence": 2791 + }, + { + "clip_id": "m48-clip-17", + "component_id": "m48-component-validation-02", + "route_block": "route-block-04", + "time_block": "time-block-06", + "split": "validation", + "start_sequence": 2911, + "end_sequence": 2971 + }, + { + "clip_id": "m48-clip-18", + "component_id": "m48-component-validation-02", + "route_block": "route-block-04", + "time_block": "time-block-06", + "split": "validation", + "start_sequence": 3111, + "end_sequence": 3171 + }, + { + "clip_id": "m48-clip-19", + "component_id": "m48-component-validation-03", + "route_block": "route-block-04", + "time_block": "time-block-07", + "split": "validation", + "start_sequence": 3291, + "end_sequence": 3351 + }, + { + "clip_id": "m48-clip-20", + "component_id": "m48-component-validation-03", + "route_block": "route-block-04", + "time_block": "time-block-07", + "split": "validation", + "start_sequence": 3471, + "end_sequence": 3531 + }, + { + "clip_id": "m48-clip-21", + "component_id": "m48-component-validation-03", + "route_block": "route-block-05", + "time_block": "time-block-07", + "split": "validation", + "start_sequence": 3651, + "end_sequence": 3711 + }, + { + "clip_id": "m48-clip-22", + "component_id": "m48-component-validation-04", + "route_block": "route-block-05", + "time_block": "time-block-08", + "split": "validation", + "start_sequence": 3831, + "end_sequence": 3891 + }, + { + "clip_id": "m48-clip-23", + "component_id": "m48-component-validation-04", + "route_block": "route-block-05", + "time_block": "time-block-08", + "split": "validation", + "start_sequence": 4051, + "end_sequence": 4111 + }, + { + "clip_id": "m48-clip-24", + "component_id": "m48-component-validation-04", + "route_block": "route-block-05", + "time_block": "time-block-08", + "split": "validation", + "start_sequence": 4429, + "end_sequence": 4489 + } + ] +} diff --git a/config/perception/m48-object-quality-v1.json b/config/perception/m48-object-quality-v1.json new file mode 100644 index 0000000..c03ea6a --- /dev/null +++ b/config/perception/m48-object-quality-v1.json @@ -0,0 +1,72 @@ +{ + "schema_version": "missioncore.m48-object-quality-profile/v1", + "profile_id": "m48-ravnoves00-object-quality/v1", + "source_graph_id": "reference-perception-graph/v2", + "source_profile_id": "m4-ravnoves00-recorded-realtime/v1", + "clip_contract": { + "minimum_clip_count": 20, + "maximum_clip_count": 30, + "minimum_duration_seconds": 5.0, + "maximum_duration_seconds": 10.0, + "required_validation_hypotheses": [ + "prediction-associated", + "prediction-fisheye-edge", + "prediction-moving", + "prediction-small-obstacle", + "prediction-sparse-scene", + "prediction-static", + "prediction-threat", + "prediction-unassociated" + ], + "selection_hypothesis_profile": { + "derivation": "exact-frozen-prediction-rows-before-independent-truth", + "small_obstacle_max_normalized_area": 0.001, + "fisheye_edge_margin_normalized": 0.08, + "sparse_scene_max_median_prediction_count": 2.0 + }, + "splits": [ + "development", + "validation" + ], + "connected_component_split_overlap_allowed": false, + "route_or_time_block_split_overlap_allowed": false, + "release_gate_split": "validation" + }, + "review_contract": { + "review_unit": "clip-local-object-tracklet", + "extent_labels": "sparse-normalized-xyxy-keyframes", + "state_labels": "contiguous-tracklet-state-segments", + "per_frame_expansion": { + "extent": "linear-between-bounding-keyframes", + "visibility": "left-keyframe-hold", + "state": "contiguous-state-segment" + }, + "semantic_class_labels_allowed": false, + "independent_reviewers_required": 2, + "adjudication_required": true, + "predictions_frozen_before_label_reveal": true, + "prediction_content_visible_to_reviewers": false, + "selection_hypotheses_visible_to_reviewers": false + }, + "matching": { + "extent_iou_threshold": 0.5 + }, + "release_thresholds": { + "terminal_outcome_accounting": 1.0, + "false_free_space_claims": 0, + "obstacle_presence_precision": 0.9, + "obstacle_presence_recall": 0.9, + "critical_corridor_obstacle_recall": 0.95, + "geometry_association_correctness": 0.9, + "freshness_correctness": 0.9, + "motion_decision_correctness": 0.9, + "critical_threat_not_threat": 0 + }, + "authority": { + "mode": "replay-simulated", + "physical_live": false, + "commands_enabled": false, + "actuation_allowed": false, + "navigation_or_safety_accepted": false + } +} diff --git a/config/perception/m48-small-static-passage-regression-v1.json b/config/perception/m48-small-static-passage-regression-v1.json new file mode 100644 index 0000000..9969088 --- /dev/null +++ b/config/perception/m48-small-static-passage-regression-v1.json @@ -0,0 +1,13 @@ +{ + "schema_version": "missioncore.m48-small-static-passage-regression-profile/v1", + "profile_id": "m48-small-static-passage-regression/v1", + "pipeline_id": "m48-class-free-object-quality/v1", + "experiment_id": "m48-small-static-passage-regression/v1", + "human_lab_id": "M4.8", + "run_label": "M4.8R1", + "anchor_selection": "operator-added-tracklets-in-reviewed-clips/v1", + "extent_iou_threshold": 0.5, + "minimum_assisted_anchor_recall": 0.9, + "minimum_anchor_count": 1, + "independent_truth": false +} diff --git a/scripts/prepare_m48_object_quality_pack.py b/scripts/prepare_m48_object_quality_pack.py new file mode 100644 index 0000000..05f2c74 --- /dev/null +++ b/scripts/prepare_m48_object_quality_pack.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Freeze the source-scoped RAVNOVES00 M4.8 independent-review pack.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from k1link.laboratory.m48_ravnoves00_pack import prepare_m48_ravnoves00_pack + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--m47-lab-root", type=Path, required=True) + parser.add_argument("--graph-result-root", type=Path, required=True) + parser.add_argument("--threat-result-root", type=Path, required=True) + parser.add_argument("--geometry-result-root", type=Path, required=True) + parser.add_argument("--camera-index", type=Path, required=True) + parser.add_argument("--selection", type=Path, required=True) + parser.add_argument("--frozen-at-utc", required=True) + parser.add_argument("--output-root", type=Path, required=True) + args = parser.parse_args() + result = prepare_m48_ravnoves00_pack( + m47_lab_root=args.m47_lab_root, + graph_result_root=args.graph_result_root, + threat_result_root=args.threat_result_root, + geometry_result_root=args.geometry_result_root, + camera_index_path=args.camera_index, + selection_path=args.selection, + frozen_at_utc=args.frozen_at_utc, + output_root=args.output_root, + ) + print( + json.dumps( + { + "result_id": result.result_id, + "result_root": str(result.result_root), + "status": result.report["status"], + "clip_count": result.report["metrics"]["clip_count"], + "frame_count": result.report["metrics"]["frame_count"], + "truth_labels_available": False, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_m48_small_static_passage_regression.py b/scripts/run_m48_small_static_passage_regression.py new file mode 100644 index 0000000..0a9aeb4 --- /dev/null +++ b/scripts/run_m48_small_static_passage_regression.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Publish one canonical append-only M4.8 small-static regression run.""" + +from __future__ import annotations + +import argparse +import json +import socket +from pathlib import Path + +from k1link.compute.pipeline_telemetry import JsonlPipelineTelemetrySink +from k1link.laboratory import ( + LaboratoryEvidenceRegistry, + LaboratoryExecutionRegistry, + LaboratoryRunner, + LaboratoryRunRequest, +) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument("--pack-root", type=Path, required=True) + parser.add_argument("--correction-session", type=Path, required=True) + parser.add_argument("--profile", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + parser.add_argument("--receipt-root", type=Path, required=True) + parser.add_argument("--telemetry-path", type=Path, required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--request-id", required=True) + return parser + + +def main() -> int: + args = _parser().parse_args() + repository_root = Path(__file__).resolve().parents[1] + evidence = LaboratoryEvidenceRegistry.from_directory( + repository_root / "config" / "laboratories" + ) + execution = LaboratoryExecutionRegistry.from_file( + repository_root / "config" / "laboratory-execution.json", + evidence, + ) + runner = LaboratoryRunner( + registry=execution, + evidence_registry=evidence, + sink=JsonlPipelineTelemetrySink(args.telemetry_path), + ) + pack_id = args.pack_root.name + result = runner.run( + LaboratoryRunRequest( + work_id="m48-small-static-passage-regression", + run_id=args.run_id, + request_id=args.request_id, + contour_id="mission-core-laboratory", + agent_id="local-control-plane", + node_id=socket.gethostname(), + source_id="RAVNOVES00", + source_package_id=pack_id, + method_id="m48-small-static-passage-regression/v1", + inputs={ + "pack_root": args.pack_root, + "correction_session_path": args.correction_session, + "profile_path": args.profile, + }, + output_root=args.output_root, + receipt_root=args.receipt_root, + ) + ) + print(json.dumps({ + "result_id": result.result_id, + "result_root": str(result.result_root), + "receipt_id": result.receipt_id, + "receipt_root": str(result.receipt_root), + }, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/k1link/laboratory/__init__.py b/src/k1link/laboratory/__init__.py index f2d88aa..77d7c05 100644 --- a/src/k1link/laboratory/__init__.py +++ b/src/k1link/laboratory/__init__.py @@ -2,8 +2,10 @@ from k1link.laboratory.evidence_registry import ( LABORATORY_EVIDENCE_DEFINITION_SCHEMA, + LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA, LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry, + LaboratoryEvidenceVariant, LaboratoryRegistryError, ) from k1link.laboratory.evidence_report import ( @@ -34,9 +36,11 @@ from k1link.laboratory.value_review_registry import ( __all__ = [ "LABORATORY_EVIDENCE_DEFINITION_SCHEMA", + "LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA", "LABORATORY_EVIDENCE_REPORT_SCHEMA", "LaboratoryEvidenceDefinition", "LaboratoryEvidenceRegistry", + "LaboratoryEvidenceVariant", "LaboratoryEvidenceReportError", "LaboratoryEvidenceReportNotFound", "LaboratoryEvidenceReportService", diff --git a/src/k1link/laboratory/evidence_registry.py b/src/k1link/laboratory/evidence_registry.py index e175a32..d8c3841 100644 --- a/src/k1link/laboratory/evidence_registry.py +++ b/src/k1link/laboratory/evidence_registry.py @@ -7,13 +7,22 @@ from pathlib import Path, PurePosixPath from typing import Final LABORATORY_EVIDENCE_DEFINITION_SCHEMA: Final = "missioncore.laboratory-evidence-definition/v1" +LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA: Final = ( + "missioncore.laboratory-evidence-definition/v2" +) _DEFINITION_MAX_BYTES: Final = 16 * 1024 _IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$") _SCHEMA_VERSION = re.compile(r"^missioncore\.[a-z0-9.-]+/v[1-9][0-9]*$") _TOP_LEVEL_KEYS: Final = frozenset({"schema_version", "work_id", "evidence"}) +_LIFECYCLE_TOP_LEVEL_KEYS: Final = frozenset( + {"schema_version", "work_id", "evidence_lifecycle"} +) _EVIDENCE_KEYS: Final = frozenset( {"runtime_relative_root", "result_id_prefix", "document_name", "schema_version"} ) +_LIFECYCLE_EVIDENCE_KEYS: Final = frozenset( + {"phase", "runtime_relative_root", "result_id_prefix", "document_name", "schema_version"} +) class LaboratoryRegistryError(ValueError): @@ -21,15 +30,15 @@ class LaboratoryRegistryError(ValueError): @dataclass(frozen=True, slots=True) -class LaboratoryEvidenceDefinition: - work_id: str +class LaboratoryEvidenceVariant: + phase: str runtime_relative_root: PurePosixPath result_id_prefix: str document_name: str result_schema_version: str def __post_init__(self) -> None: - _identifier(self.work_id, "work_id") + _identifier(self.phase, "evidence phase") _identifier(self.result_id_prefix, "result_id_prefix") _document_name(self.document_name) _schema_version(self.result_schema_version) @@ -45,6 +54,69 @@ class LaboratoryEvidenceDefinition: return runtime_root.joinpath(*self.runtime_relative_root.parts) +@dataclass(frozen=True, slots=True) +class LaboratoryEvidenceDefinition: + work_id: str + runtime_relative_root: PurePosixPath + result_id_prefix: str + document_name: str + result_schema_version: str + lifecycle_variants: tuple[LaboratoryEvidenceVariant, ...] = () + + def __post_init__(self) -> None: + _identifier(self.work_id, "work_id") + primary = LaboratoryEvidenceVariant( + phase="result", + runtime_relative_root=self.runtime_relative_root, + result_id_prefix=self.result_id_prefix, + document_name=self.document_name, + result_schema_version=self.result_schema_version, + ) + if not self.lifecycle_variants: + return + if not all( + isinstance(variant, LaboratoryEvidenceVariant) + for variant in self.lifecycle_variants + ): + raise LaboratoryRegistryError("LAB lifecycle variants must be immutable evidence") + if self.lifecycle_variants[-1] != primary: + raise LaboratoryRegistryError("LAB lifecycle terminal evidence must be primary") + phases = [variant.phase for variant in self.lifecycle_variants] + if len(phases) != len(set(phases)): + raise LaboratoryRegistryError("duplicate LAB evidence phase") + + @property + def evidence_variants(self) -> tuple[LaboratoryEvidenceVariant, ...]: + if self.lifecycle_variants: + return self.lifecycle_variants + return ( + LaboratoryEvidenceVariant( + phase="result", + runtime_relative_root=self.runtime_relative_root, + result_id_prefix=self.result_id_prefix, + document_name=self.document_name, + result_schema_version=self.result_schema_version, + ), + ) + + @property + def result_id_pattern(self) -> re.Pattern[str]: + return re.compile(rf"^{re.escape(self.result_id_prefix)}-[a-f0-9]{{64}}$") + + def result_root(self, runtime_root: Path) -> Path: + return runtime_root.joinpath(*self.runtime_relative_root.parts) + + def variant_for_result_id(self, result_id: str) -> LaboratoryEvidenceVariant | None: + return next( + ( + variant + for variant in self.evidence_variants + if variant.result_id_pattern.fullmatch(result_id) is not None + ), + None, + ) + + @dataclass(frozen=True, slots=True) class LaboratoryEvidenceRegistry: definitions: tuple[LaboratoryEvidenceDefinition, ...] @@ -89,14 +161,42 @@ def _read_definition(path: Path) -> LaboratoryEvidenceDefinition: except (json.JSONDecodeError, OSError) as exc: raise LaboratoryRegistryError(f"LAB definition is unreadable: {path.name}") from exc document = _object(payload, f"LAB definition {path.name}") - _exact_keys(document, _TOP_LEVEL_KEYS, f"LAB definition {path.name}") - if document["schema_version"] != LABORATORY_EVIDENCE_DEFINITION_SCHEMA: + schema_version = document.get("schema_version") + if schema_version not in { + LABORATORY_EVIDENCE_DEFINITION_SCHEMA, + LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA, + }: raise LaboratoryRegistryError(f"LAB definition schema is invalid: {path.name}") + expected_keys = ( + _TOP_LEVEL_KEYS + if schema_version == LABORATORY_EVIDENCE_DEFINITION_SCHEMA + else _LIFECYCLE_TOP_LEVEL_KEYS + ) + _exact_keys(document, expected_keys, f"LAB definition {path.name}") work_id = _identifier(document["work_id"], "work_id") if path.name != f"{work_id}.json": raise LaboratoryRegistryError(f"LAB definition filename must match work_id: {path.name}") - evidence = _object(document["evidence"], f"LAB evidence {work_id}") - _exact_keys(evidence, _EVIDENCE_KEYS, f"LAB evidence {work_id}") + if schema_version == LABORATORY_EVIDENCE_DEFINITION_SCHEMA: + lifecycle_variants: tuple[LaboratoryEvidenceVariant, ...] = () + evidence = _object(document["evidence"], f"LAB evidence {work_id}") + _exact_keys(evidence, _EVIDENCE_KEYS, f"LAB evidence {work_id}") + else: + lifecycle = document["evidence_lifecycle"] + if not isinstance(lifecycle, list) or len(lifecycle) < 2: + raise LaboratoryRegistryError( + f"LAB evidence lifecycle must contain at least two phases: {work_id}" + ) + lifecycle_variants = tuple( + _read_variant(row, f"LAB evidence {work_id}[{index}]") + for index, row in enumerate(lifecycle) + ) + terminal = lifecycle_variants[-1] + evidence = { + "runtime_relative_root": str(terminal.runtime_relative_root), + "result_id_prefix": terminal.result_id_prefix, + "document_name": terminal.document_name, + "schema_version": terminal.result_schema_version, + } result_id_prefix = _identifier(evidence["result_id_prefix"], "result_id_prefix") document_name = _document_name(evidence["document_name"]) result_schema_version = _schema_version(evidence["schema_version"]) @@ -106,6 +206,19 @@ def _read_definition(path: Path) -> LaboratoryEvidenceDefinition: result_id_prefix=result_id_prefix, document_name=document_name, result_schema_version=result_schema_version, + lifecycle_variants=lifecycle_variants, + ) + + +def _read_variant(value: object, label: str) -> LaboratoryEvidenceVariant: + evidence = _object(value, label) + _exact_keys(evidence, _LIFECYCLE_EVIDENCE_KEYS, label) + return LaboratoryEvidenceVariant( + phase=_identifier(evidence["phase"], f"{label}.phase"), + runtime_relative_root=_relative_root(evidence["runtime_relative_root"]), + result_id_prefix=_identifier(evidence["result_id_prefix"], "result_id_prefix"), + document_name=_document_name(evidence["document_name"]), + result_schema_version=_schema_version(evidence["schema_version"]), ) @@ -177,9 +290,15 @@ def _relative_root(value: object) -> PurePosixPath: def _reject_duplicates(definitions: tuple[LaboratoryEvidenceDefinition, ...]) -> None: dimensions = { "work_id": [definition.work_id for definition in definitions], - "result_id_prefix": [definition.result_id_prefix for definition in definitions], + "result_id_prefix": [ + variant.result_id_prefix + for definition in definitions + for variant in definition.evidence_variants + ], "runtime_relative_root": [ - str(definition.runtime_relative_root) for definition in definitions + str(variant.runtime_relative_root) + for definition in definitions + for variant in definition.evidence_variants ], } for label, values in dimensions.items(): diff --git a/src/k1link/laboratory/evidence_report.py b/src/k1link/laboratory/evidence_report.py index f3167e5..cf6b543 100644 --- a/src/k1link/laboratory/evidence_report.py +++ b/src/k1link/laboratory/evidence_report.py @@ -9,6 +9,7 @@ from typing import Any, Final from k1link.laboratory.evidence_registry import ( LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry, + LaboratoryEvidenceVariant, ) LABORATORY_EVIDENCE_REPORT_SCHEMA: Final = "missioncore.laboratory-evidence-report/v1" @@ -40,12 +41,13 @@ def verify_laboratory_evidence_result( resolved = candidate.resolve(strict=True) except OSError as exc: raise LaboratoryEvidenceReportError("LAB evidence result is unavailable") from exc - if not resolved.is_dir() or definition.result_id_pattern.fullmatch(resolved.name) is None: + variant = definition.variant_for_result_id(resolved.name) + if not resolved.is_dir() or variant is None: raise LaboratoryEvidenceReportError("LAB evidence result path is invalid") - document_path = _safe_file(resolved, definition.document_name) + document_path = _safe_file(resolved, variant.document_name) document_bytes = _read_bounded(document_path, _DOCUMENT_MAX_BYTES, "LAB document") document = _json_object(document_bytes, "LAB document") - _validate_document(document, definition, resolved.name) + _validate_document(document, variant, resolved.name) identity = _object_or_none(document.get("identity")) identity_sha256 = document.get("identity_sha256") if identity is None or not isinstance(identity_sha256, str): @@ -77,13 +79,14 @@ class LaboratoryEvidenceReportService: def read(self, work_id: str, result_id: str) -> dict[str, object]: definition = self._definitions.get(work_id) - if definition is None or definition.result_id_pattern.fullmatch(result_id) is None: + variant = definition.variant_for_result_id(result_id) if definition is not None else None + if definition is None or variant is None: raise LaboratoryEvidenceReportNotFound("LAB evidence identity is unknown") - result_root = self._result_root(definition, result_id) - document_path = _safe_file(result_root, definition.document_name) + result_root = self._result_root(variant, result_id) + document_path = _safe_file(result_root, variant.document_name) document_bytes = _read_bounded(document_path, _DOCUMENT_MAX_BYTES, "LAB document") document = _json_object(document_bytes, "LAB document") - _validate_document(document, definition, result_id) + _validate_document(document, variant, result_id) identity = _object_or_none(document.get("identity")) identity_sha256 = document.get("identity_sha256") @@ -210,7 +213,7 @@ class LaboratoryEvidenceReportService: def _result_root( self, - definition: LaboratoryEvidenceDefinition, + variant: LaboratoryEvidenceVariant, result_id: str, ) -> Path: configured = self._runtime_root_provider() @@ -223,7 +226,7 @@ class LaboratoryEvidenceReportService: runtime_root = runtime_root.resolve(strict=True) except OSError as exc: raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable") from exc - candidate = definition.result_root(runtime_root) / result_id + candidate = variant.result_root(runtime_root) / result_id if candidate.is_symlink(): raise LaboratoryEvidenceReportError("LAB result must not be a symlink") try: @@ -237,7 +240,7 @@ class LaboratoryEvidenceReportService: def _validate_document( document: dict[str, Any], - definition: LaboratoryEvidenceDefinition, + definition: LaboratoryEvidenceVariant, result_id: str, ) -> None: if document.get("schema_version") != definition.result_schema_version: diff --git a/src/k1link/laboratory/execution.py b/src/k1link/laboratory/execution.py index bd58932..0b5301d 100644 --- a/src/k1link/laboratory/execution.py +++ b/src/k1link/laboratory/execution.py @@ -169,10 +169,11 @@ class LaboratoryExecutionRegistry: f"laboratory classification is incomplete; missing={missing}, unknown={unknown}" ) for definition in self.definitions: - if ( - evidence_by_work_id[definition.work_id].result_schema_version - != definition.evidence_contract - ): + evidence_contracts = { + variant.result_schema_version + for variant in evidence_by_work_id[definition.work_id].evidence_variants + } + if definition.evidence_contract not in evidence_contracts: raise LaboratoryExecutionError( f"laboratory evidence contract mismatch: {definition.work_id}" ) @@ -311,6 +312,10 @@ class LaboratoryRunner: def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]: return { + "canonical.m48-small-static-passage-regression/v1": ( + _run_m48_small_static_passage_regression + ), + "canonical.m48-object-centric-quality/v1": _run_m48_object_centric_quality, "canonical.m4-replay-threat/v1": _run_m4_replay_threat, "canonical.e33-worker-shadow/v1": _run_e33, "canonical.e35-degradation-recovery/v1": _run_e35, @@ -319,6 +324,41 @@ def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]: } +def _run_m48_small_static_passage_regression( + request: LaboratoryRunRequest, +) -> LaboratoryAdapterResult: + from k1link.laboratory.m48_small_static_regression import ( + build_m48_small_static_passage_regression, + ) + + result = build_m48_small_static_passage_regression( + pack_root=request.inputs["pack_root"], + correction_session_path=request.inputs["correction_session_path"], + profile_path=request.inputs["profile_path"], + output_root=request.output_root, + ) + return LaboratoryAdapterResult( + result_root=result.result_root, + result_id=result.result_id, + ) + + +def _run_m48_object_centric_quality( + request: LaboratoryRunRequest, +) -> LaboratoryAdapterResult: + from k1link.laboratory.m48_object_quality import score_m48_object_quality + + result = score_m48_object_quality( + pack_root=request.inputs["pack_root"], + truth_seal_root=request.inputs["truth_seal_root"], + output_root=request.output_root, + ) + return LaboratoryAdapterResult( + result_root=result.result_root, + result_id=result.result_id, + ) + + def _run_m4_replay_threat(request: LaboratoryRunRequest) -> LaboratoryAdapterResult: from k1link.perception.threat_replay import build_threat_replay diff --git a/src/k1link/laboratory/m48_object_quality.py b/src/k1link/laboratory/m48_object_quality.py new file mode 100644 index 0000000..1a237a8 --- /dev/null +++ b/src/k1link/laboratory/m48_object_quality.py @@ -0,0 +1,2660 @@ +"""Class-free M4.8 source-scoped object-quality evidence primitives. + +The module deliberately has no web, worker, device, or model-runtime side effects. It turns an +already accepted M4.7 replay into three immutable generations: + +1. a connected-clip pack with predictions frozen before labels exist; +2. an independently reviewed and adjudicated class-free truth seal; +3. a deterministic quality result with a per-frame ledger and bounded failure atlas. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import shutil +import uuid +from collections import Counter +from collections.abc import Iterable, Mapping +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final, TypedDict + +from k1link.laboratory.m47_reference_graph import ( + M47_REFERENCE_GRAPH_LAB_SCHEMA, + M47ReferenceGraphLabError, + read_m47_reference_graph_lab, +) + +M48_PROFILE_SCHEMA: Final = "missioncore.m48-object-quality-profile/v1" +M48_PACK_SCHEMA: Final = "missioncore.m48-object-centric-quality-pack/v1" +M48_PACK_REPORT_SCHEMA: Final = "missioncore.m48-object-centric-pack-report/v1" +M48_CONTRACT_SCHEMA: Final = "missioncore.m48-object-centric-review-contract/v1" +M48_REVIEWER_PACKAGE_SCHEMA: Final = "missioncore.m48-neutral-reviewer-package/v1" +M48_CLIP_ROW_SCHEMA: Final = "missioncore.m48-connected-clip/v1" +M48_FRAME_REFERENCE_SCHEMA: Final = "missioncore.m48-frame-reference/v1" +M48_PREDICTION_ROW_SCHEMA: Final = "missioncore.m48-frozen-prediction-row/v1" +M48_REVIEW_SCHEMA: Final = "missioncore.m48-independent-object-review/v1" +M48_ADJUDICATION_SCHEMA: Final = "missioncore.m48-object-review-adjudication/v1" +M48_TRUTH_SEAL_SCHEMA: Final = "missioncore.m48-object-truth-seal/v1" +M48_TRUTH_REPORT_SCHEMA: Final = "missioncore.m48-object-truth-seal-report/v1" +M48_TRUTH_ROW_SCHEMA: Final = "missioncore.m48-object-truth-row/v1" +M48_RESULT_SCHEMA: Final = "missioncore.m48-object-centric-quality-result/v1" +M48_RESULT_REPORT_SCHEMA: Final = "missioncore.m48-object-centric-quality-report/v1" +M48_FRAME_LEDGER_SCHEMA: Final = "missioncore.m48-object-quality-frame/v1" +M48_FAILURE_CASE_SCHEMA: Final = "missioncore.m48-object-quality-failure/v1" +M48_PREPARATION_PROVENANCE_SCHEMA: Final = "missioncore.m48-ravnoves00-preparation-provenance/v1" + +M48_PACK_PREFIX: Final = "m48-object-quality-pack-" +M48_TRUTH_PREFIX: Final = "m48-object-truth-seal-" +M48_RESULT_PREFIX: Final = "m48-object-quality-result-" + +M48_MANIFEST_NAME: Final = "manifest.json" +M48_PACK_REPORT_NAME: Final = "pack-report.json" +M48_CONTRACT_NAME: Final = "review-contract.json" +M48_CLIPS_NAME: Final = "clips.jsonl" +M48_FRAME_REFERENCES_NAME: Final = "frame-references.jsonl" +M48_PREDICTIONS_NAME: Final = "frozen-predictions.jsonl" +M48_REVIEW_TEMPLATE_NAME: Final = "review-template.json" +M48_REVIEWER_PACKAGE_NAME: Final = "reviewer-package.json" +M48_TRUTH_REPORT_NAME: Final = "truth-seal-report.json" +M48_TRUTH_ROWS_NAME: Final = "adjudicated-truth.jsonl" +M48_REVIEW_PROVENANCE_NAME: Final = "review-provenance.json" +M48_RESULT_REPORT_NAME: Final = "report.json" +M48_FRAME_LEDGER_NAME: Final = "frame-ledger.jsonl" +M48_FAILURE_ATLAS_NAME: Final = "failure-atlas.jsonl" + +_AUTHORITY: Final = { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, +} +_REQUIRED_HYPOTHESES: Final = frozenset( + { + "prediction-associated", + "prediction-unassociated", + "prediction-moving", + "prediction-static", + "prediction-threat", + "prediction-small-obstacle", + "prediction-fisheye-edge", + "prediction-sparse-scene", + } +) + + +class _M48SelectionHypothesisProfile(TypedDict): + derivation: str + small_obstacle_max_normalized_area: float + fisheye_edge_margin_normalized: float + sparse_scene_max_median_prediction_count: float + + +M48_SELECTION_HYPOTHESIS_PROFILE: Final[_M48SelectionHypothesisProfile] = { + "derivation": "exact-frozen-prediction-rows-before-independent-truth", + "small_obstacle_max_normalized_area": 0.001, + "fisheye_edge_margin_normalized": 0.08, + "sparse_scene_max_median_prediction_count": 2.0, +} +_SPLITS: Final = frozenset({"development", "validation"}) +_TERMINAL_OUTCOMES: Final = frozenset( + {"delivered", "failed", "stale", "superseded", "rejected", "unavailable"} +) +_TERMINAL_REASONS: Final = frozenset( + { + "deadline-exceeded", + "provider-failed", + "source-unavailable", + "superseded-by-newer-source", + "validation-rejected", + } +) +_GEOMETRY_STATES: Final = frozenset({"associated", "unavailable", "ineligible", "unknown"}) +_FRESHNESS_STATES: Final = frozenset({"current", "held", "stale", "unavailable"}) +_MOTION_STATES: Final = frozenset({"moving", "static", "unknown", "unsupported"}) +_THREAT_STATES: Final = frozenset({"threat", "not-threat", "unknown"}) +_VISIBILITY_STATES: Final = frozenset({"visible", "partial", "occluded"}) +_UNKNOWN_CAUSES: Final = frozenset( + { + "geometry-conflict", + "insufficient-camera-evidence", + "insufficient-geometry-support", + "motion-not-supported", + "source-unavailable", + "stale-evidence", + "threat-evidence-insufficient", + } +) +_REVIEW_BLINDNESS: Final = { + "candidate_identity_seen": False, + "model_predictions_seen": False, + "model_scores_seen": False, + "semantic_class_task_seen": False, +} +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,95}$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") + + +class M48ObjectQualityError(RuntimeError): + """An M4.8 evidence generation or review document is invalid.""" + + +@dataclass(frozen=True, slots=True) +class M48ObjectQualityProfile: + """Frozen source-scoped release thresholds from the Milestone 4 canon.""" + + profile_id: str = "m48-ravnoves00-object-quality/v1" + minimum_clip_count: int = 20 + maximum_clip_count: int = 30 + minimum_duration_seconds: float = 5.0 + maximum_duration_seconds: float = 10.0 + extent_iou_threshold: float = 0.5 + obstacle_presence_precision: float = 0.9 + obstacle_presence_recall: float = 0.9 + critical_corridor_obstacle_recall: float = 0.95 + geometry_association_correctness: float = 0.9 + freshness_correctness: float = 0.9 + motion_decision_correctness: float = 0.9 + + def __post_init__(self) -> None: + rates = ( + self.extent_iou_threshold, + self.obstacle_presence_precision, + self.obstacle_presence_recall, + self.critical_corridor_obstacle_recall, + self.geometry_association_correctness, + self.freshness_correctness, + self.motion_decision_correctness, + ) + if ( + self.profile_id != "m48-ravnoves00-object-quality/v1" + or self.minimum_clip_count != 20 + or self.maximum_clip_count != 30 + or self.minimum_duration_seconds != 5.0 + or self.maximum_duration_seconds != 10.0 + or any(not 0.0 < value <= 1.0 for value in rates) + ): + raise M48ObjectQualityError("M4.8 profile is invalid") + + def to_dict(self) -> dict[str, object]: + return { + "schema_version": M48_PROFILE_SCHEMA, + **asdict(self), + "required_validation_hypotheses": sorted(_REQUIRED_HYPOTHESES), + "selection_hypothesis_profile": dict(M48_SELECTION_HYPOTHESIS_PROFILE), + "semantic_class_labels_allowed": False, + "independent_reviewers_required": 2, + "adjudication_required": True, + "false_free_space_claims_allowed": 0, + "critical_threat_not_threat_allowed": 0, + "terminal_outcome_accounting_required": 1.0, + } + + +DEFAULT_M48_OBJECT_QUALITY_PROFILE: Final = M48ObjectQualityProfile() + + +@dataclass(frozen=True, slots=True) +class M48ObjectQualityPack: + result_id: str + result_root: Path + manifest: dict[str, Any] + report: dict[str, Any] + clips: tuple[dict[str, Any], ...] + frame_references: tuple[dict[str, Any], ...] + predictions: tuple[dict[str, Any], ...] + + +@dataclass(frozen=True, slots=True) +class M48ObjectTruthSeal: + result_id: str + result_root: Path + manifest: dict[str, Any] + report: dict[str, Any] + truth_rows: tuple[dict[str, Any], ...] + provenance: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class M48ObjectQualityResult: + result_id: str + result_root: Path + manifest: dict[str, Any] + report: dict[str, Any] + frame_ledger: tuple[dict[str, Any], ...] + failure_atlas: tuple[dict[str, Any], ...] + + +@dataclass(frozen=True, slots=True) +class _M48ReviewPack: + result_id: str + result_root: Path + manifest: dict[str, Any] + report: dict[str, Any] + clips: tuple[dict[str, Any], ...] + frame_references: tuple[dict[str, Any], ...] + + +def build_m48_object_quality_pack( + *, + m47_lab_root: Path, + frame_catalog: Iterable[Mapping[str, object]], + clips: Iterable[Mapping[str, object]], + predictions: Iterable[Mapping[str, object]], + preparation_provenance: Mapping[str, object], + frozen_at_utc: str, + output_root: Path, + profile: M48ObjectQualityProfile = DEFAULT_M48_OBJECT_QUALITY_PROFILE, +) -> M48ObjectQualityPack: + """Freeze a deterministic class-free review pack over one accepted M4.7 replay.""" + + try: + m47 = read_m47_reference_graph_lab(m47_lab_root) + except M47ReferenceGraphLabError as reason: + raise M48ObjectQualityError("accepted M4.7 LAB is invalid") from reason + m47_source = _object(m47.report.get("source"), "M4.7 source") + m47_method = _object(m47.report.get("method"), "M4.7 method") + m47_decision = _object(m47.report.get("decision"), "M4.7 decision") + m47_acceptance = _object(m47.report.get("acceptance"), "M4.7 acceptance") + if ( + m47.manifest.get("schema_version") != M47_REFERENCE_GRAPH_LAB_SCHEMA + or m47.manifest.get("accepted") is not True + or m47.manifest.get("ground_truth") is not False + or m47_decision.get("state") != "accepted-reference-graph-replay" + or m47_decision.get("next_gate") != "independent-object-centric-detection-quality" + or m47_acceptance.get("accepted") is not True + or m47_method.get("graph_id") != "reference-perception-graph/v2" + or m47_method.get("run_mode") != "lossless-replay" + or m47_source.get("source_id") != "RAVNOVES00" + or m47_source.get("source_session_id") != "20260720T065719Z_viewer_live" + or not _authority_is_false(m47.report.get("authority")) + ): + raise M48ObjectQualityError("M4.7 LAB is not admitted for M4.8") + + frozen_at = _utc_timestamp(frozen_at_utc, "frozen_at_utc") + normalized_catalog = _normalize_frame_catalog(frame_catalog) + normalized_clips = _normalize_clips(clips, normalized_catalog, profile) + frame_references = _build_frame_references(normalized_catalog, normalized_clips) + normalized_predictions = _normalize_predictions(predictions, frame_references) + normalized_clips = _attach_prediction_hypotheses( + normalized_clips, + normalized_predictions, + ) + normalized_provenance = _normalize_preparation_provenance(preparation_provenance) + clips_sha256 = _rows_sha256(normalized_clips) + frame_references_sha256 = _rows_sha256(frame_references) + predictions_sha256 = _rows_sha256(normalized_predictions) + m47_manifest_sha256 = _file_sha256(m47.result_root / M48_MANIFEST_NAME) + + identity = { + "schema_version": M48_PACK_SCHEMA, + "source": { + "m47_lab_result_id": m47.result_id, + "m47_lab_manifest_sha256": m47_manifest_sha256, + "graph_result_id": m47_source.get("graph_result_id"), + "graph_id": m47_method.get("graph_id"), + "source_id": m47_source.get("source_id"), + "source_session_id": m47_source.get("source_session_id"), + "canonical_payload_sha256": m47_method.get("canonical_payload_sha256"), + }, + "profile": profile.to_dict(), + "freeze": { + "frozen_at_utc": frozen_at, + "labels_available_at_freeze": False, + "prediction_rows_sha256": predictions_sha256, + "frame_references_sha256": frame_references_sha256, + "clips_sha256": clips_sha256, + }, + "preparation": normalized_provenance, + "producer_sha256": _file_sha256(Path(__file__).resolve(strict=True)), + "authority": _AUTHORITY, + } + identity_sha256 = _canonical_sha256(identity) + result_id = f"{M48_PACK_PREFIX}{identity_sha256}" + destination = output_root.expanduser().absolute() / result_id + if destination.exists(): + return read_m48_object_quality_pack(destination) + + contract = { + "schema_version": M48_CONTRACT_SCHEMA, + "task": "class-free-object-centric-obstacle-quality", + "truth_state": "labels-unavailable", + "semantic_class_labels_allowed": False, + "reviewer_package": { + "frame_references_only": True, + "candidate_identity_included": False, + "model_predictions_included": False, + "model_scores_included": False, + "semantic_class_task_included": False, + }, + "labels": { + "unit": "clip-local-object-tracklet", + "obstacle_presence_and_extent": "sparse-normalized-xyxy-keyframes", + "visibility": sorted(_VISIBILITY_STATES), + "geometry_association": sorted(_GEOMETRY_STATES), + "freshness": sorted(_FRESHNESS_STATES), + "motion": sorted(_MOTION_STATES), + "virtual_corridor_threat": sorted(_THREAT_STATES), + "critical_corridor_obstacle": "boolean", + "per_frame_expansion": { + "extent": "linear-between-bounding-keyframes", + "visibility": "left-keyframe-hold", + "state": "contiguous-state-segment", + }, + }, + "review": { + "independent_reviewers_required": 2, + "reviewer_identities_must_differ": True, + "adjudication_required": True, + "predictions_frozen_before_label_reveal": True, + "connected_component_split_overlap_allowed": False, + "route_or_time_block_split_overlap_allowed": False, + }, + "scoring": profile.to_dict(), + "authority": _AUTHORITY, + } + neutral_clips = _neutral_reviewer_clips(normalized_clips, frame_references) + reviewer_package = { + "schema_version": M48_REVIEWER_PACKAGE_SCHEMA, + "pack_id": result_id, + "state": "prediction-blind-neutral-source-projection", + "strata_included": False, + "frozen_predictions_included": False, + "candidate_identity_included": False, + "semantic_class_task_included": False, + "clips": neutral_clips, + "authority": _AUTHORITY, + } + review_template = _review_template(result_id, neutral_clips) + clip_split_counts = Counter(str(row["split"]) for row in normalized_clips) + report = { + "schema_version": M48_PACK_REPORT_SCHEMA, + "result_id": result_id, + "status": "prepared-predictions-frozen-labels-unavailable", + "source": identity["source"], + "method": { + "profile_id": profile.profile_id, + "extent_iou_threshold": profile.extent_iou_threshold, + "semantic_class_scored": False, + }, + "metrics": { + "clip_count": len(normalized_clips), + "frame_count": len(frame_references), + "split_clip_counts": dict(sorted(clip_split_counts.items())), + "selection_hypotheses": sorted( + { + hypothesis + for row in normalized_clips + for hypothesis in row["selection_hypotheses"] + } + ), + "validation_selection_hypotheses": sorted( + { + hypothesis + for row in normalized_clips + if row["split"] == "validation" + for hypothesis in row["selection_hypotheses"] + } + ), + "route_block_count": len({row["route_block"] for row in normalized_clips}), + "time_block_count": len({row["time_block"] for row in normalized_clips}), + }, + "freeze": identity["freeze"], + "decision": { + "prediction_freeze_accepted": True, + "truth_labels_available": False, + "scoring_authorized": False, + "next_gate": "two-independent-class-free-reviews-and-adjudication", + }, + "limitations": [ + "The pack is source-scoped to the accepted RAVNOVES00 M4.7 replay.", + "Frozen predictions are not truth and are excluded from reviewer templates.", + "No semantic class is labelled or scored.", + "No physical-live, navigation, safety, command or actuation authority is granted.", + ], + "authority": _AUTHORITY, + } + _write_generation( + destination=destination, + schema_version=M48_PACK_SCHEMA, + result_id=result_id, + identity=identity, + created_at_utc=frozen_at, + state_fields={"accepted": False, "truth_labels_available": False}, + documents={ + M48_PACK_REPORT_NAME: (report, "pack-report", M48_PACK_REPORT_SCHEMA), + M48_CONTRACT_NAME: (contract, "review-contract", M48_CONTRACT_SCHEMA), + M48_REVIEW_TEMPLATE_NAME: ( + review_template, + "prediction-free-review-template", + M48_REVIEW_SCHEMA, + ), + M48_REVIEWER_PACKAGE_NAME: ( + reviewer_package, + "neutral-reviewer-package", + M48_REVIEWER_PACKAGE_SCHEMA, + ), + }, + row_sets={ + M48_CLIPS_NAME: (normalized_clips, "connected-clips", M48_CLIP_ROW_SCHEMA), + M48_FRAME_REFERENCES_NAME: ( + frame_references, + "frame-references", + M48_FRAME_REFERENCE_SCHEMA, + ), + M48_PREDICTIONS_NAME: ( + normalized_predictions, + "frozen-predictions", + M48_PREDICTION_ROW_SCHEMA, + ), + }, + ) + return read_m48_object_quality_pack(destination) + + +def read_m48_object_quality_pack(root: Path) -> M48ObjectQualityPack: + """Read and revalidate an immutable prediction-frozen M4.8 pack.""" + + resolved, manifest = _read_generation(root, M48_PACK_SCHEMA, M48_PACK_PREFIX) + if ( + manifest.get("accepted") is not False + or manifest.get("truth_labels_available") is not False + or manifest.get("authority") != _AUTHORITY + ): + raise M48ObjectQualityError("M4.8 pack authority or truth state changed") + report = _read_json(resolved / M48_PACK_REPORT_NAME) + contract = _read_json(resolved / M48_CONTRACT_NAME) + review_template = _read_json(resolved / M48_REVIEW_TEMPLATE_NAME) + reviewer_package = _read_json(resolved / M48_REVIEWER_PACKAGE_NAME) + clips = tuple(_read_jsonl(resolved / M48_CLIPS_NAME)) + references = tuple(_read_jsonl(resolved / M48_FRAME_REFERENCES_NAME)) + predictions = tuple(_read_jsonl(resolved / M48_PREDICTIONS_NAME)) + identity = _object(manifest.get("identity"), "pack identity") + freeze = _object(identity.get("freeze"), "pack freeze") + preparation = _normalize_preparation_provenance( + _mapping(identity.get("preparation"), "pack preparation provenance") + ) + if ( + report.get("schema_version") != M48_PACK_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or report.get("status") != "prepared-predictions-frozen-labels-unavailable" + or contract.get("schema_version") != M48_CONTRACT_SCHEMA + or contract.get("semantic_class_labels_allowed") is not False + or review_template.get("state") != "prepared-unreviewed-no-predictions" + or review_template.get("blindness") != _REVIEW_BLINDNESS + or reviewer_package.get("schema_version") != M48_REVIEWER_PACKAGE_SCHEMA + or reviewer_package.get("strata_included") is not False + or reviewer_package.get("frozen_predictions_included") is not False + or reviewer_package.get("candidate_identity_included") is not False + or identity.get("preparation") != preparation + or any( + "strata" in clip or "selection_hypotheses" in clip + for clip in reviewer_package.get("clips", []) + ) + or reviewer_package.get("clips") != _neutral_reviewer_clips(clips, references) + or review_template + != _review_template(resolved.name, _neutral_reviewer_clips(clips, references)) + or _rows_sha256(clips) != freeze.get("clips_sha256") + or _rows_sha256(references) != freeze.get("frame_references_sha256") + or _rows_sha256(predictions) != freeze.get("prediction_rows_sha256") + or len(references) != len(predictions) + or [row.get("sequence") for row in references] + != [row.get("sequence") for row in predictions] + ): + raise M48ObjectQualityError("M4.8 pack content changed") + return M48ObjectQualityPack( + result_id=resolved.name, + result_root=resolved, + manifest=manifest, + report=report, + clips=clips, + frame_references=references, + predictions=predictions, + ) + + +def _read_m48_review_pack(root: Path) -> _M48ReviewPack: + """Read the neutral review projection without opening frozen prediction rows.""" + + resolved, manifest = _read_generation_manifest(root, M48_PACK_SCHEMA, M48_PACK_PREFIX) + if ( + manifest.get("accepted") is not False + or manifest.get("truth_labels_available") is not False + or manifest.get("authority") != _AUTHORITY + ): + raise M48ObjectQualityError("M4.8 pack authority or truth state changed") + raw_artifacts = manifest.get("artifacts") + if not isinstance(raw_artifacts, list) or len(raw_artifacts) != 7: + raise M48ObjectQualityError("M4.8 review-pack artifact inventory changed") + descriptors: dict[str, dict[str, Any]] = {} + for raw in raw_artifacts: + descriptor = _object(raw, "M4.8 artifact") + _validate_artifact_descriptor(descriptor) + name = str(descriptor["path"]) + if name in descriptors: + raise M48ObjectQualityError("M4.8 review-pack artifact is duplicated") + descriptors[name] = descriptor + expected_names = { + M48_PACK_REPORT_NAME, + M48_CONTRACT_NAME, + M48_REVIEW_TEMPLATE_NAME, + M48_REVIEWER_PACKAGE_NAME, + M48_CLIPS_NAME, + M48_FRAME_REFERENCES_NAME, + M48_PREDICTIONS_NAME, + } + if set(descriptors) != expected_names: + raise M48ObjectQualityError("M4.8 review-pack artifact inventory changed") + for name in expected_names - {M48_PREDICTIONS_NAME}: + _verify_artifact_file(resolved, descriptors[name]) + prediction_descriptor = descriptors[M48_PREDICTIONS_NAME] + prediction_path = resolved / M48_PREDICTIONS_NAME + identity = _object(manifest.get("identity"), "pack identity") + freeze = _object(identity.get("freeze"), "pack freeze") + preparation = _normalize_preparation_provenance( + _mapping(identity.get("preparation"), "pack preparation provenance") + ) + if ( + prediction_path.is_symlink() + or not prediction_path.is_file() + or prediction_path.stat().st_size != prediction_descriptor["byte_length"] + or prediction_descriptor.get("sha256") != freeze.get("prediction_rows_sha256") + ): + raise M48ObjectQualityError("M4.8 frozen prediction descriptor changed") + report = _read_json(resolved / M48_PACK_REPORT_NAME) + contract = _read_json(resolved / M48_CONTRACT_NAME) + review_template = _read_json(resolved / M48_REVIEW_TEMPLATE_NAME) + reviewer_package = _read_json(resolved / M48_REVIEWER_PACKAGE_NAME) + clips = tuple(_read_jsonl(resolved / M48_CLIPS_NAME)) + references = tuple(_read_jsonl(resolved / M48_FRAME_REFERENCES_NAME)) + neutral_clips = _neutral_reviewer_clips(clips, references) + if ( + report.get("schema_version") != M48_PACK_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or contract.get("schema_version") != M48_CONTRACT_SCHEMA + or contract.get("semantic_class_labels_allowed") is not False + or reviewer_package.get("schema_version") != M48_REVIEWER_PACKAGE_SCHEMA + or reviewer_package.get("clips") != neutral_clips + or reviewer_package.get("strata_included") is not False + or reviewer_package.get("frozen_predictions_included") is not False + or identity.get("preparation") != preparation + or any( + "strata" in clip or "selection_hypotheses" in clip + for clip in reviewer_package.get("clips", []) + ) + or review_template != _review_template(resolved.name, neutral_clips) + or _rows_sha256(clips) != freeze.get("clips_sha256") + or _rows_sha256(references) != freeze.get("frame_references_sha256") + ): + raise M48ObjectQualityError("M4.8 neutral review projection changed") + return _M48ReviewPack( + result_id=resolved.name, + result_root=resolved, + manifest=manifest, + report=report, + clips=clips, + frame_references=references, + ) + + +def validate_m48_review_submission(*, pack_root: Path, review_path: Path) -> dict[str, Any]: + """Validate one prediction-blind class-free review without reading predictions.""" + + pack = _read_m48_review_pack(pack_root) + document = _read_json(review_path.resolve(strict=True)) + return _validate_review_document( + document, + pack_id=pack.result_id, + clips=pack.clips, + references=pack.frame_references, + ) + + +def build_m48_object_truth_seal( + *, + pack_root: Path, + reviewer_a_path: Path, + reviewer_b_path: Path, + adjudication_path: Path, + output_root: Path, +) -> M48ObjectTruthSeal: + """Seal two independent reviews and explicit adjudication without prediction access.""" + + pack = _read_m48_review_pack(pack_root) + freeze = _object( + _object(pack.manifest.get("identity"), "pack identity").get("freeze"), + "pack freeze", + ) + frozen_at = _parse_utc(_utc_timestamp(freeze.get("frozen_at_utc"), "frozen_at_utc")) + review_a_document = _read_json(reviewer_a_path.resolve(strict=True)) + review_b_document = _read_json(reviewer_b_path.resolve(strict=True)) + review_a = _validate_review_document( + review_a_document, + pack_id=pack.result_id, + clips=pack.clips, + references=pack.frame_references, + ) + review_b = _validate_review_document( + review_b_document, + pack_id=pack.result_id, + clips=pack.clips, + references=pack.frame_references, + ) + if review_a["reviewer_id"] == review_b["reviewer_id"]: + raise M48ObjectQualityError("independent reviewer identities must differ") + submitted_a = _parse_utc(str(review_a["submitted_at_utc"])) + submitted_b = _parse_utc(str(review_b["submitted_at_utc"])) + if min(submitted_a, submitted_b) < frozen_at: + raise M48ObjectQualityError("review submission predates prediction freeze") + + review_digests = sorted( + (_canonical_sha256(review_a_document), _canonical_sha256(review_b_document)) + ) + adjudication_document = _read_json(adjudication_path.resolve(strict=True)) + adjudication = _validate_adjudication_document( + adjudication_document, + pack_id=pack.result_id, + review_digests=review_digests, + clips=pack.clips, + references=pack.frame_references, + ) + sealed_at = _parse_utc(str(adjudication["sealed_at_utc"])) + if sealed_at < max(frozen_at, submitted_a, submitted_b): + raise M48ObjectQualityError("adjudication predates freeze or completed reviews") + + truth_rows = _expand_review_clips( + adjudication["clips"], + references=pack.frame_references, + adjudicated=True, + ) + truth_rows_sha256 = _rows_sha256(truth_rows) + pack_manifest_sha256 = _file_sha256(pack.result_root / M48_MANIFEST_NAME) + identity = { + "schema_version": M48_TRUTH_SEAL_SCHEMA, + "pack": { + "result_id": pack.result_id, + "manifest_sha256": pack_manifest_sha256, + "prediction_freeze_id": pack.result_id, + "prediction_content_read_by_sealer": False, + }, + "review_submission_sha256": review_digests, + "adjudication_sha256": _canonical_sha256(adjudication_document), + "truth_rows_sha256": truth_rows_sha256, + "semantic_class_labels_present": False, + "producer_sha256": _file_sha256(Path(__file__).resolve(strict=True)), + "authority": _AUTHORITY, + } + identity_sha256 = _canonical_sha256(identity) + result_id = f"{M48_TRUTH_PREFIX}{identity_sha256}" + destination = output_root.expanduser().absolute() / result_id + if destination.exists(): + return read_m48_object_truth_seal(destination) + + provenance = { + "schema_version": M48_TRUTH_SEAL_SCHEMA, + "pack_id": pack.result_id, + "prediction_content_read_by_sealer": False, + "prediction_content_seen_by_reviewers": False, + "reviewers": [ + { + "reviewer_id": review_a["reviewer_id"], + "submission_sha256": _canonical_sha256(review_a_document), + "submitted_at_utc": review_a["submitted_at_utc"], + "blindness": _REVIEW_BLINDNESS, + }, + { + "reviewer_id": review_b["reviewer_id"], + "submission_sha256": _canonical_sha256(review_b_document), + "submitted_at_utc": review_b["submitted_at_utc"], + "blindness": _REVIEW_BLINDNESS, + }, + ], + "adjudicator_id": adjudication["adjudicator_id"], + "adjudication_sha256": _canonical_sha256(adjudication_document), + "sealed_at_utc": adjudication["sealed_at_utc"], + "authority": _AUTHORITY, + } + expanded_review_a = _expand_review_clips( + review_a["clips"], + references=pack.frame_references, + adjudicated=False, + ) + expanded_review_b = _expand_review_clips( + review_b["clips"], + references=pack.frame_references, + adjudicated=False, + ) + review_agreement = _review_agreement(expanded_review_a, expanded_review_b) + truth_objects = [obj for row in truth_rows for obj in row["objects"]] + report = { + "schema_version": M48_TRUTH_REPORT_SCHEMA, + "result_id": result_id, + "status": "sealed-adjudicated-independent-object-truth", + "source": _object(pack.manifest["identity"], "pack identity")["source"], + "metrics": { + "frame_count": len(truth_rows), + "object_count": len(truth_objects), + "no_object_frame_count": sum(1 for row in truth_rows if row["no_object"]), + "critical_corridor_object_count": sum( + 1 for obj in truth_objects if obj["critical_corridor_obstacle"] + ), + "review_agreement_before_adjudication": review_agreement, + }, + "blindness": { + "independent_reviewer_count": 2, + "reviewer_identities_differ": True, + "prediction_content_seen_during_review": False, + "prediction_content_read_by_sealer": False, + "semantic_class_labels_present": False, + "adjudication_complete": True, + }, + "decision": { + "truth_labels_available": True, + "truth_join_authorized": True, + "scoring_authorized": True, + "next_gate": "one-shot-frozen-object-quality-scoring", + }, + "limitations": [ + "Truth is source-scoped to the frozen RAVNOVES00 connected clips.", + "The seal evaluates obstacle evidence and state, never semantic class.", + "Review agreement is provenance; adjudication is the scoring truth.", + "Truth sealing grants no physical-live, navigation, safety or actuation authority.", + ], + "authority": _AUTHORITY, + } + _write_generation( + destination=destination, + schema_version=M48_TRUTH_SEAL_SCHEMA, + result_id=result_id, + identity=identity, + created_at_utc=str(adjudication["sealed_at_utc"]), + state_fields={"accepted": True, "truth_labels_available": True}, + documents={ + M48_TRUTH_REPORT_NAME: (report, "truth-seal-report", M48_TRUTH_REPORT_SCHEMA), + M48_REVIEW_PROVENANCE_NAME: ( + provenance, + "review-provenance", + M48_TRUTH_SEAL_SCHEMA, + ), + }, + row_sets={ + M48_TRUTH_ROWS_NAME: (truth_rows, "adjudicated-truth", M48_TRUTH_ROW_SCHEMA), + }, + ) + return read_m48_object_truth_seal(destination) + + +def read_m48_object_truth_seal(root: Path) -> M48ObjectTruthSeal: + """Read and revalidate a source-scoped class-free truth seal.""" + + resolved, manifest = _read_generation(root, M48_TRUTH_SEAL_SCHEMA, M48_TRUTH_PREFIX) + if ( + manifest.get("accepted") is not True + or manifest.get("truth_labels_available") is not True + or manifest.get("authority") != _AUTHORITY + ): + raise M48ObjectQualityError("M4.8 truth authority or acceptance changed") + report = _read_json(resolved / M48_TRUTH_REPORT_NAME) + provenance = _read_json(resolved / M48_REVIEW_PROVENANCE_NAME) + rows = tuple(_read_jsonl(resolved / M48_TRUTH_ROWS_NAME)) + identity = _object(manifest.get("identity"), "truth identity") + if ( + report.get("schema_version") != M48_TRUTH_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or report.get("status") != "sealed-adjudicated-independent-object-truth" + or provenance.get("prediction_content_read_by_sealer") is not False + or provenance.get("prediction_content_seen_by_reviewers") is not False + or identity.get("semantic_class_labels_present") is not False + or _rows_sha256(rows) != identity.get("truth_rows_sha256") + or any(row.get("schema_version") != M48_TRUTH_ROW_SCHEMA for row in rows) + ): + raise M48ObjectQualityError("M4.8 truth content changed") + return M48ObjectTruthSeal( + result_id=resolved.name, + result_root=resolved, + manifest=manifest, + report=report, + truth_rows=rows, + provenance=provenance, + ) + + +def score_m48_object_quality( + *, + pack_root: Path, + truth_seal_root: Path, + output_root: Path, +) -> M48ObjectQualityResult: + """Join the frozen predictions to sealed truth exactly once and score M4.8.""" + + pack = read_m48_object_quality_pack(pack_root) + truth = read_m48_object_truth_seal(truth_seal_root) + truth_pack = _object( + _object(truth.manifest.get("identity"), "truth identity").get("pack"), + "truth pack", + ) + if ( + truth_pack.get("result_id") != pack.result_id + or truth_pack.get("manifest_sha256") != _file_sha256(pack.result_root / M48_MANIFEST_NAME) + or truth_pack.get("prediction_content_read_by_sealer") is not False + or len(pack.predictions) != len(truth.truth_rows) + ): + raise M48ObjectQualityError("truth seal does not bind the frozen prediction pack") + + profile = DEFAULT_M48_OBJECT_QUALITY_PROFILE + pack_profile = _object( + _object(pack.manifest.get("identity"), "pack identity").get("profile"), + "pack profile", + ) + if pack_profile != profile.to_dict(): + raise M48ObjectQualityError("M4.8 scoring profile changed after prediction freeze") + + frame_ledger: list[dict[str, Any]] = [] + failure_atlas: list[dict[str, Any]] = [] + totals_by_split: dict[str, Counter[str]] = {split: Counter() for split in sorted(_SPLITS)} + unknown_causes_by_split: dict[str, Counter[str]] = { + split: Counter() for split in sorted(_SPLITS) + } + frame_counts: Counter[str] = Counter() + for prediction, truth_row in zip(pack.predictions, truth.truth_rows, strict=True): + frame, failures, counts, causes = _score_frame( + prediction, + truth_row, + iou_threshold=profile.extent_iou_threshold, + ) + frame_ledger.append(frame) + split = str(frame["split"]) + if split not in _SPLITS: + raise M48ObjectQualityError("scored frame split changed") + totals_by_split[split].update(counts) + unknown_causes_by_split[split].update(causes) + frame_counts[split] += 1 + if failures: + failure_atlas.append(_failure_case(frame, failures)) + + split_metrics = { + split: { + **_quality_metrics(totals_by_split[split], frame_counts[split]), + "unknown_prediction_count": totals_by_split[split]["unknown_predictions"], + "unknown_causes": dict(sorted(unknown_causes_by_split[split].items())), + "failure_case_count": sum(1 for row in failure_atlas if row["split"] == split), + } + for split in sorted(_SPLITS) + } + aggregate_totals: Counter[str] = Counter() + aggregate_unknown_causes: Counter[str] = Counter() + for split in sorted(_SPLITS): + aggregate_totals.update(totals_by_split[split]) + aggregate_unknown_causes.update(unknown_causes_by_split[split]) + aggregate_metrics = { + **_quality_metrics(aggregate_totals, len(frame_ledger)), + "unknown_prediction_count": aggregate_totals["unknown_predictions"], + "unknown_causes": dict(sorted(aggregate_unknown_causes.items())), + "failure_case_count": len(failure_atlas), + } + validation_hypotheses = { + hypothesis + for clip in pack.clips + if clip["split"] == "validation" + for hypothesis in clip["selection_hypotheses"] + } + validation_metrics = split_metrics["validation"] + validation_totals = totals_by_split["validation"] + gates = _quality_gates( + validation_metrics, + validation_totals, + profile, + required_hypotheses_present=validation_hypotheses == _REQUIRED_HYPOTHESES, + ) + accepted = all(bool(value) for value in gates.values()) + validation_failures = [row for row in failure_atlas if row["split"] == "validation"] + failed_gates = _failed_gate_causes(gates, validation_failures, validation_totals) + frame_rows = tuple(frame_ledger) + failure_rows = tuple(failure_atlas) + identity = { + "schema_version": M48_RESULT_SCHEMA, + "pack": { + "result_id": pack.result_id, + "manifest_sha256": _file_sha256(pack.result_root / M48_MANIFEST_NAME), + }, + "truth_seal": { + "result_id": truth.result_id, + "manifest_sha256": _file_sha256(truth.result_root / M48_MANIFEST_NAME), + }, + "profile": profile.to_dict(), + "frame_ledger_sha256": _rows_sha256(frame_rows), + "failure_atlas_sha256": _rows_sha256(failure_rows), + "producer_sha256": _file_sha256(Path(__file__).resolve(strict=True)), + "authority": _AUTHORITY, + } + identity_sha256 = _canonical_sha256(identity) + result_id = f"{M48_RESULT_PREFIX}{identity_sha256}" + destination = output_root.expanduser().absolute() / result_id + if destination.exists(): + return read_m48_object_quality_result(destination) + + report = { + "schema_version": M48_RESULT_REPORT_SCHEMA, + "result_id": result_id, + "status": ( + "accepted-object-centric-source-quality" + if accepted + else "failed-object-centric-source-quality" + ), + "source": _object(pack.manifest["identity"], "pack identity")["source"], + "method": { + "profile_id": profile.profile_id, + "extent_matching": f"greedy-class-free-iou-gte-{profile.extent_iou_threshold}", + "semantic_class_scored": False, + "predictions_frozen_before_label_reveal": True, + "reviewer_count": 2, + "adjudication_complete": True, + }, + "metrics": validation_metrics, + "metrics_by_split": { + "development": split_metrics["development"], + "validation": validation_metrics, + }, + "aggregate_diagnostic_metrics": aggregate_metrics, + "acceptance": { + "accepted": accepted, + "scope": "validation-only", + "development_metrics_informational_only": True, + "required_validation_hypotheses": sorted(_REQUIRED_HYPOTHESES), + "observed_validation_hypotheses": sorted(validation_hypotheses), + "gates": gates, + "failed_gates": failed_gates, + "aggregate_waiver_allowed": False, + }, + "decision": { + "state": ( + "accepted-object-centric-source-quality" + if accepted + else "failed-object-centric-source-quality" + ), + "new_detector_challenger_authorized": False, + "next_gate": ( + "m4.9-recorded-realtime-release-candidate" + if accepted + else "bounded-cause-remediation-on-failed-m48-clusters" + ), + }, + "limitations": [ + "Acceptance is source-scoped to frozen RAVNOVES00 connected clips.", + "Semantic class is neither labelled nor scored.", + "Unknown remains conservative and its bounded causes are reported.", + "This result grants no physical-live, navigation, safety or actuation authority.", + ], + "authority": _AUTHORITY, + } + created_at_utc = str(truth.provenance["sealed_at_utc"]) + _write_generation( + destination=destination, + schema_version=M48_RESULT_SCHEMA, + result_id=result_id, + identity=identity, + created_at_utc=created_at_utc, + state_fields={"accepted": accepted, "ground_truth": False}, + documents={ + M48_RESULT_REPORT_NAME: (report, "quality-report", M48_RESULT_REPORT_SCHEMA), + }, + row_sets={ + M48_FRAME_LEDGER_NAME: ( + frame_rows, + "per-frame-quality-ledger", + M48_FRAME_LEDGER_SCHEMA, + ), + M48_FAILURE_ATLAS_NAME: ( + failure_rows, + "bounded-failure-atlas", + M48_FAILURE_CASE_SCHEMA, + ), + }, + ) + return read_m48_object_quality_result(destination) + + +def read_m48_object_quality_result(root: Path) -> M48ObjectQualityResult: + """Read and revalidate a scored M4.8 LAB result.""" + + resolved, manifest = _read_generation(root, M48_RESULT_SCHEMA, M48_RESULT_PREFIX) + if manifest.get("authority") != _AUTHORITY or manifest.get("ground_truth") is not False: + raise M48ObjectQualityError("M4.8 result authority changed") + report = _read_json(resolved / M48_RESULT_REPORT_NAME) + frame_ledger = tuple(_read_jsonl(resolved / M48_FRAME_LEDGER_NAME)) + failure_atlas = tuple(_read_jsonl(resolved / M48_FAILURE_ATLAS_NAME)) + identity = _object(manifest.get("identity"), "result identity") + acceptance = _object(report.get("acceptance"), "result acceptance") + accepted = acceptance.get("accepted") + metrics = _object(report.get("metrics"), "result metrics") + metrics_by_split = _object(report.get("metrics_by_split"), "split result metrics") + if ( + report.get("schema_version") != M48_RESULT_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or not isinstance(accepted, bool) + or acceptance.get("scope") != "validation-only" + or acceptance.get("development_metrics_informational_only") is not True + or set(metrics_by_split) != _SPLITS + or metrics_by_split.get("validation") != metrics + or not isinstance(metrics_by_split.get("development"), dict) + or not isinstance(report.get("aggregate_diagnostic_metrics"), dict) + or manifest.get("accepted") is not accepted + or _rows_sha256(frame_ledger) != identity.get("frame_ledger_sha256") + or _rows_sha256(failure_atlas) != identity.get("failure_atlas_sha256") + or any(row.get("schema_version") != M48_FRAME_LEDGER_SCHEMA for row in frame_ledger) + or any(row.get("schema_version") != M48_FAILURE_CASE_SCHEMA for row in failure_atlas) + ): + raise M48ObjectQualityError("M4.8 result content changed") + return M48ObjectQualityResult( + result_id=resolved.name, + result_root=resolved, + manifest=manifest, + report=report, + frame_ledger=frame_ledger, + failure_atlas=failure_atlas, + ) + + +def _normalize_frame_catalog( + values: Iterable[Mapping[str, object]], +) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + previous_time = -1 + for expected_sequence, raw in enumerate(values, start=1): + row = _mapping(raw, "frame catalog row") + _exact_keys( + row, + {"sequence", "source_time_ns", "camera_fragment_sha256"}, + "frame catalog row", + ) + sequence = _integer(row.get("sequence"), "frame sequence") + source_time_ns = _integer(row.get("source_time_ns"), "source_time_ns") + camera_sha256 = _sha256_text( + row.get("camera_fragment_sha256"), + "camera fragment hash", + ) + if sequence != expected_sequence or source_time_ns <= previous_time: + raise M48ObjectQualityError("frame catalog ordering is invalid") + previous_time = source_time_ns + rows.append( + { + "sequence": sequence, + "source_time_ns": source_time_ns, + "camera_fragment_sha256": camera_sha256, + } + ) + if len(rows) != 4489: + raise M48ObjectQualityError("M4.8 frame catalog must bind all 4,489 M4.7 frames") + return tuple(rows) + + +def _normalize_clips( + values: Iterable[Mapping[str, object]], + catalog: tuple[dict[str, Any], ...], + profile: M48ObjectQualityProfile, +) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + clip_ids: set[str] = set() + group_splits: dict[str, dict[str, str]] = { + "component_id": {}, + "route_block": {}, + "time_block": {}, + } + group_counts: dict[str, Counter[str]] = { + "component_id": Counter(), + "route_block": Counter(), + "time_block": Counter(), + } + for raw in values: + row = _mapping(raw, "clip row") + _exact_keys( + row, + { + "clip_id", + "component_id", + "route_block", + "time_block", + "split", + "start_sequence", + "end_sequence", + }, + "clip row", + ) + clip_id = _identifier(row.get("clip_id"), "clip_id") + component_id = _identifier(row.get("component_id"), "component_id") + route_block = _identifier(row.get("route_block"), "route_block") + time_block = _identifier(row.get("time_block"), "time_block") + split = row.get("split") + start = _integer(row.get("start_sequence"), "clip start_sequence") + end = _integer(row.get("end_sequence"), "clip end_sequence") + if clip_id in clip_ids or split not in _SPLITS or not 1 <= start <= end <= len(catalog): + raise M48ObjectQualityError("clip identity or split is invalid") + clip_ids.add(clip_id) + for field, value in ( + ("component_id", component_id), + ("route_block", route_block), + ("time_block", time_block), + ): + existing_split = group_splits[field].setdefault(value, str(split)) + if existing_split != split: + raise M48ObjectQualityError(f"{field} crosses development/validation") + group_counts[field][value] += 1 + start_time = int(catalog[start - 1]["source_time_ns"]) + end_time = int(catalog[end - 1]["source_time_ns"]) + duration_seconds = (end_time - start_time) / 1_000_000_000 + if not ( + profile.minimum_duration_seconds <= duration_seconds <= profile.maximum_duration_seconds + ): + raise M48ObjectQualityError("connected clip duration is outside 5–10 seconds") + rows.append( + { + "schema_version": M48_CLIP_ROW_SCHEMA, + "clip_id": clip_id, + "component_id": component_id, + "route_block": route_block, + "time_block": time_block, + "split": split, + "start_sequence": start, + "end_sequence": end, + "start_source_time_ns": start_time, + "end_source_time_ns": end_time, + "duration_seconds": round(duration_seconds, 9), + } + ) + rows.sort(key=lambda item: (int(item["start_sequence"]), str(item["clip_id"]))) + if not profile.minimum_clip_count <= len(rows) <= profile.maximum_clip_count: + raise M48ObjectQualityError("M4.8 requires 20–30 connected clips") + for left, right in zip(rows, rows[1:], strict=False): + if int(left["end_sequence"]) >= int(right["start_sequence"]): + raise M48ObjectQualityError("M4.8 connected clips overlap") + if ( + {str(row["split"]) for row in rows} != _SPLITS + or len({row["route_block"] for row in rows}) < 2 + or len({row["time_block"] for row in rows}) < 2 + or any( + not any( + count > 1 and group_splits[field][group] == split + for group, count in group_counts[field].items() + ) + for field in group_counts + for split in _SPLITS + ) + ): + raise M48ObjectQualityError( + "M4.8 clip pack lacks non-vacuous split-local grouping partitions" + ) + return tuple(rows) + + +def _attach_prediction_hypotheses( + clips: tuple[dict[str, Any], ...], + predictions: tuple[dict[str, Any], ...], +) -> tuple[dict[str, Any], ...]: + """Derive selection hypotheses only from rows already frozen before truth exists.""" + + rows_by_clip: dict[str, list[dict[str, Any]]] = {} + for prediction in predictions: + rows_by_clip.setdefault(str(prediction["clip_id"]), []).append(prediction) + derived: list[dict[str, Any]] = [] + for clip in clips: + clip_id = str(clip["clip_id"]) + frame_rows = rows_by_clip.get(clip_id, []) + if not frame_rows: + raise M48ObjectQualityError("clip has no frozen prediction rows") + objects = [obj for frame in frame_rows for obj in frame["objects"]] + counts = sorted(len(frame["objects"]) for frame in frame_rows) + middle = len(counts) // 2 + median_count = ( + float(counts[middle]) + if len(counts) % 2 + else (counts[middle - 1] + counts[middle]) / 2.0 + ) + hypotheses: set[str] = set() + if any(obj["geometry_association"] == "associated" for obj in objects): + hypotheses.add("prediction-associated") + if any(obj["geometry_association"] != "associated" for obj in objects): + hypotheses.add("prediction-unassociated") + if any(obj["motion"] == "moving" for obj in objects): + hypotheses.add("prediction-moving") + if any(obj["motion"] == "static" for obj in objects): + hypotheses.add("prediction-static") + if any(obj["threat"] == "threat" for obj in objects): + hypotheses.add("prediction-threat") + if any( + (obj["extent_xyxy"][2] - obj["extent_xyxy"][0]) + * (obj["extent_xyxy"][3] - obj["extent_xyxy"][1]) + <= M48_SELECTION_HYPOTHESIS_PROFILE["small_obstacle_max_normalized_area"] + for obj in objects + ): + hypotheses.add("prediction-small-obstacle") + edge_margin = float(M48_SELECTION_HYPOTHESIS_PROFILE["fisheye_edge_margin_normalized"]) + if any( + obj["extent_xyxy"][0] <= edge_margin + or obj["extent_xyxy"][1] <= edge_margin + or obj["extent_xyxy"][2] >= 1.0 - edge_margin + or obj["extent_xyxy"][3] >= 1.0 - edge_margin + for obj in objects + ): + hypotheses.add("prediction-fisheye-edge") + if ( + median_count + <= M48_SELECTION_HYPOTHESIS_PROFILE["sparse_scene_max_median_prediction_count"] + ): + hypotheses.add("prediction-sparse-scene") + derived.append({**clip, "selection_hypotheses": sorted(hypotheses)}) + + validation_hypotheses = { + hypothesis + for clip in derived + if clip["split"] == "validation" + for hypothesis in clip["selection_hypotheses"] + } + if validation_hypotheses != _REQUIRED_HYPOTHESES: + missing = sorted(_REQUIRED_HYPOTHESES - validation_hypotheses) + unexpected = sorted(validation_hypotheses - _REQUIRED_HYPOTHESES) + raise M48ObjectQualityError( + "validation split hypothesis coverage is invalid: " + f"missing={missing}, unexpected={unexpected}" + ) + return tuple(derived) + + +def _normalize_preparation_provenance( + value: Mapping[str, object], +) -> dict[str, Any]: + document = _mapping(value, "M4.8 preparation provenance") + _exact_keys( + document, + { + "schema_version", + "adapter", + "selection", + "camera_index", + "graph", + "threat", + "geometry", + }, + "M4.8 preparation provenance", + ) + adapter = _mapping(document.get("adapter"), "preparation adapter") + selection = _mapping(document.get("selection"), "preparation selection") + camera = _mapping(document.get("camera_index"), "preparation camera index") + _exact_keys(adapter, {"module", "sha256"}, "preparation adapter") + _exact_keys(selection, {"selection_id", "sha256"}, "preparation selection") + _exact_keys( + camera, + {"source_session_id", "sha256", "byte_length", "frame_count"}, + "preparation camera index", + ) + if ( + document.get("schema_version") != M48_PREPARATION_PROVENANCE_SCHEMA + or adapter.get("module") != "k1link.laboratory.m48_ravnoves00_pack" + or selection.get("selection_id") != "m48-ravnoves00-balanced-connected-clips/v1" + or camera.get("source_session_id") != "20260720T065719Z_viewer_live" + or _integer(camera.get("frame_count"), "camera index frame_count") != 4_489 + or _integer(camera.get("byte_length"), "camera index byte_length") <= 0 + ): + raise M48ObjectQualityError("M4.8 preparation provenance identity changed") + + normalized: dict[str, Any] = { + "schema_version": M48_PREPARATION_PROVENANCE_SCHEMA, + "adapter": { + "module": adapter["module"], + "sha256": _sha256_text(adapter.get("sha256"), "adapter hash"), + }, + "selection": { + "selection_id": selection["selection_id"], + "sha256": _sha256_text(selection.get("sha256"), "selection hash"), + }, + "camera_index": { + "source_session_id": camera["source_session_id"], + "sha256": _sha256_text(camera.get("sha256"), "camera index hash"), + "byte_length": int(camera["byte_length"]), + "frame_count": int(camera["frame_count"]), + }, + } + for source_name in ("graph", "threat", "geometry"): + source = _mapping(document.get(source_name), f"preparation {source_name}") + _exact_keys( + source, + {"result_id", "manifest_sha256", "frames_sha256"}, + f"preparation {source_name}", + ) + normalized[source_name] = { + "result_id": _identifier(source.get("result_id"), f"{source_name} result_id"), + "manifest_sha256": _sha256_text( + source.get("manifest_sha256"), f"{source_name} manifest hash" + ), + "frames_sha256": _sha256_text( + source.get("frames_sha256"), f"{source_name} frames hash" + ), + } + return normalized + + +def _build_frame_references( + catalog: tuple[dict[str, Any], ...], + clips: tuple[dict[str, Any], ...], +) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + for clip in clips: + for sequence in range(int(clip["start_sequence"]), int(clip["end_sequence"]) + 1): + source = catalog[sequence - 1] + rows.append( + { + "schema_version": M48_FRAME_REFERENCE_SCHEMA, + "clip_id": clip["clip_id"], + "component_id": clip["component_id"], + "split": clip["split"], + "sequence": sequence, + "source_time_ns": source["source_time_ns"], + "camera_fragment_sha256": source["camera_fragment_sha256"], + } + ) + if not rows: + raise M48ObjectQualityError("M4.8 frame reference set is empty") + return tuple(rows) + + +def _normalize_predictions( + values: Iterable[Mapping[str, object]], + references: tuple[dict[str, Any], ...], +) -> tuple[dict[str, Any], ...]: + by_sequence: dict[int, dict[str, Any]] = {} + reference_by_sequence = {int(row["sequence"]): row for row in references} + for raw in values: + row = _mapping(raw, "prediction row") + _exact_keys( + row, + { + "sequence", + "source_time_ns", + "terminal_outcome", + "terminal_reason", + "free_space_claimed", + "objects", + }, + "prediction row", + ) + sequence = _integer(row.get("sequence"), "prediction sequence") + reference = reference_by_sequence.get(sequence) + source_time_ns = _integer(row.get("source_time_ns"), "prediction source_time_ns") + outcome = row.get("terminal_outcome") + reason = row.get("terminal_reason") + free_space = row.get("free_space_claimed") + objects = _normalize_prediction_objects(row.get("objects")) + if ( + reference is None + or sequence in by_sequence + or source_time_ns != reference["source_time_ns"] + or outcome not in _TERMINAL_OUTCOMES + or not isinstance(free_space, bool) + ): + raise M48ObjectQualityError("prediction identity or terminal outcome is invalid") + if outcome == "delivered": + if reason is not None: + raise M48ObjectQualityError("delivered prediction cannot have terminal reason") + elif reason not in _TERMINAL_REASONS or objects or free_space: + raise M48ObjectQualityError("non-delivered prediction payload is invalid") + by_sequence[sequence] = { + "schema_version": M48_PREDICTION_ROW_SCHEMA, + "clip_id": reference["clip_id"], + "sequence": sequence, + "source_time_ns": source_time_ns, + "terminal_outcome": outcome, + "terminal_reason": reason, + "free_space_claimed": free_space, + "objects": list(objects), + } + expected_sequences = [int(row["sequence"]) for row in references] + if sorted(by_sequence) != expected_sequences: + raise M48ObjectQualityError("frozen prediction coverage is incomplete") + return tuple(by_sequence[sequence] for sequence in expected_sequences) + + +def _normalize_prediction_objects(value: object) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + object_ids: set[str] = set() + for raw in _list(value, "prediction objects"): + obj = _object(raw, "prediction object") + _exact_keys( + obj, + { + "prediction_id", + "extent_xyxy", + "geometry_association", + "freshness", + "motion", + "threat", + "unknown_causes", + }, + "prediction object", + ) + prediction_id = _identifier(obj.get("prediction_id"), "prediction_id") + geometry = obj.get("geometry_association") + freshness = obj.get("freshness") + motion = obj.get("motion") + threat = obj.get("threat") + causes = _list(obj.get("unknown_causes"), "unknown_causes") + if ( + prediction_id in object_ids + or geometry not in _GEOMETRY_STATES + or freshness not in _FRESHNESS_STATES + or motion not in _MOTION_STATES + or threat not in _THREAT_STATES + or len(causes) != len(set(causes)) + or not all(isinstance(cause, str) and cause in _UNKNOWN_CAUSES for cause in causes) + ): + raise M48ObjectQualityError("prediction object state is invalid") + has_unknown = "unknown" in {geometry, motion, threat} + if has_unknown != bool(causes): + raise M48ObjectQualityError("prediction unknown state must have bounded causes") + object_ids.add(prediction_id) + rows.append( + { + "prediction_id": prediction_id, + "extent_xyxy": _extent(obj.get("extent_xyxy")), + "geometry_association": geometry, + "freshness": freshness, + "motion": motion, + "threat": threat, + "unknown_causes": sorted(causes), + } + ) + rows.sort(key=lambda item: str(item["prediction_id"])) + return tuple(rows) + + +def _neutral_reviewer_clips( + clips: tuple[dict[str, Any], ...], + references: tuple[dict[str, Any], ...], +) -> list[dict[str, Any]]: + frames_by_clip: dict[str, list[dict[str, Any]]] = {} + for reference in references: + frames_by_clip.setdefault(str(reference["clip_id"]), []).append( + { + "sequence": reference["sequence"], + "source_time_ns": reference["source_time_ns"], + "camera_fragment_sha256": reference["camera_fragment_sha256"], + } + ) + return [ + { + "clip_id": clip["clip_id"], + "start_sequence": clip["start_sequence"], + "end_sequence": clip["end_sequence"], + "frames": frames_by_clip.get(str(clip["clip_id"]), []), + } + for clip in clips + ] + + +def _review_template(pack_id: str, clips: list[dict[str, Any]]) -> dict[str, Any]: + return { + "schema_version": M48_REVIEW_SCHEMA, + "pack_id": pack_id, + "state": "prepared-unreviewed-no-predictions", + "reviewer_id": None, + "review_round": 1, + "blindness": _REVIEW_BLINDNESS, + "clips": [ + { + "clip_id": clip["clip_id"], + "start_sequence": clip["start_sequence"], + "end_sequence": clip["end_sequence"], + "review_state": "pending", + "no_object": None, + "tracklets": [], + "notes": None, + } + for clip in clips + ], + "acceptance": None, + } + + +def _validate_review_document( + document: dict[str, Any], + *, + pack_id: str, + clips: tuple[dict[str, Any], ...], + references: tuple[dict[str, Any], ...], +) -> dict[str, Any]: + _exact_keys( + document, + { + "schema_version", + "pack_id", + "state", + "reviewer_id", + "review_round", + "blindness", + "clips", + "acceptance", + }, + "review submission", + ) + if ( + document.get("schema_version") != M48_REVIEW_SCHEMA + or document.get("pack_id") != pack_id + or document.get("state") != "completed-independent-no-predictions" + or document.get("review_round") != 1 + or document.get("blindness") != _REVIEW_BLINDNESS + ): + raise M48ObjectQualityError("review identity or blindness is invalid") + reviewer_id = _identifier(document.get("reviewer_id"), "reviewer_id") + acceptance = _object(document.get("acceptance"), "review acceptance") + _exact_keys( + acceptance, + {"all_clips_reviewed", "independent", "submitted_at_utc"}, + "review acceptance", + ) + if ( + acceptance.get("all_clips_reviewed") is not True + or acceptance.get("independent") is not True + ): + raise M48ObjectQualityError("review acceptance is incomplete") + return { + "reviewer_id": reviewer_id, + "submitted_at_utc": _utc_timestamp( + acceptance.get("submitted_at_utc"), + "review submitted_at_utc", + ), + "clips": _normalize_review_clips( + document.get("clips"), + clips=clips, + references=references, + expected_state="reviewed", + ), + } + + +def _validate_adjudication_document( + document: dict[str, Any], + *, + pack_id: str, + review_digests: list[str], + clips: tuple[dict[str, Any], ...], + references: tuple[dict[str, Any], ...], +) -> dict[str, Any]: + _exact_keys( + document, + { + "schema_version", + "pack_id", + "state", + "adjudicator_id", + "review_submission_sha256", + "clips", + "acceptance", + }, + "adjudication", + ) + digests = document.get("review_submission_sha256") + if ( + document.get("schema_version") != M48_ADJUDICATION_SCHEMA + or document.get("pack_id") != pack_id + or document.get("state") != "completed-adjudicated" + or not isinstance(digests, list) + or sorted(digests) != review_digests + ): + raise M48ObjectQualityError("adjudication identity is invalid") + acceptance = _object(document.get("acceptance"), "adjudication acceptance") + _exact_keys( + acceptance, + {"all_clips_adjudicated", "all_disagreements_resolved", "sealed_at_utc"}, + "adjudication acceptance", + ) + if ( + acceptance.get("all_clips_adjudicated") is not True + or acceptance.get("all_disagreements_resolved") is not True + ): + raise M48ObjectQualityError("adjudication acceptance is incomplete") + return { + "adjudicator_id": _identifier(document.get("adjudicator_id"), "adjudicator_id"), + "sealed_at_utc": _utc_timestamp( + acceptance.get("sealed_at_utc"), + "adjudication sealed_at_utc", + ), + "clips": _normalize_review_clips( + document.get("clips"), + clips=clips, + references=references, + expected_state="adjudicated", + ), + } + + +def _normalize_review_clips( + value: object, + *, + clips: tuple[dict[str, Any], ...], + references: tuple[dict[str, Any], ...], + expected_state: str, +) -> tuple[dict[str, Any], ...]: + values = _list(value, "review clips") + if len(values) != len(clips): + raise M48ObjectQualityError("review clip coverage is incomplete") + reference_sequences = {int(row["sequence"]) for row in references} + normalized: list[dict[str, Any]] = [] + for raw, source_clip in zip(values, clips, strict=True): + clip = _object(raw, "review clip") + _exact_keys( + clip, + { + "clip_id", + "start_sequence", + "end_sequence", + "review_state", + "no_object", + "tracklets", + "notes", + }, + "review clip", + ) + expected_identity = { + "clip_id": source_clip["clip_id"], + "start_sequence": source_clip["start_sequence"], + "end_sequence": source_clip["end_sequence"], + } + if any(clip.get(key) != expected for key, expected in expected_identity.items()): + raise M48ObjectQualityError("review clip identity changed") + no_object = clip.get("no_object") + if clip.get("review_state") != expected_state or not isinstance(no_object, bool): + raise M48ObjectQualityError("review clip is incomplete") + tracklets = _normalize_tracklets( + clip.get("tracklets"), + clip_start=int(source_clip["start_sequence"]), + clip_end=int(source_clip["end_sequence"]), + reference_sequences=reference_sequences, + ) + if no_object != (len(tracklets) == 0): + raise M48ObjectQualityError("no-object state conflicts with reviewed tracklets") + normalized.append( + { + **expected_identity, + "review_state": expected_state, + "no_object": no_object, + "tracklets": list(tracklets), + "notes": _optional_text(clip.get("notes"), "clip notes"), + } + ) + return tuple(normalized) + + +def _normalize_tracklets( + value: object, + *, + clip_start: int, + clip_end: int, + reference_sequences: set[int], +) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + object_ids: set[str] = set() + for raw in _list(value, "review tracklets"): + obj = _object(raw, "review tracklet") + _exact_keys( + obj, + { + "object_id", + "first_sequence", + "last_sequence", + "keyframes", + "state_segments", + "notes", + }, + "review tracklet", + ) + object_id = _identifier(obj.get("object_id"), "object_id") + first = _integer(obj.get("first_sequence"), "tracklet first_sequence") + last = _integer(obj.get("last_sequence"), "tracklet last_sequence") + if ( + object_id in object_ids + or not clip_start <= first <= last <= clip_end + or any(sequence not in reference_sequences for sequence in range(first, last + 1)) + ): + raise M48ObjectQualityError("review tracklet identity is invalid") + object_ids.add(object_id) + keyframes = _normalize_keyframes(obj.get("keyframes"), first=first, last=last) + segments = _normalize_state_segments(obj.get("state_segments"), first=first, last=last) + rows.append( + { + "object_id": object_id, + "first_sequence": first, + "last_sequence": last, + "keyframes": list(keyframes), + "state_segments": list(segments), + "notes": _optional_text(obj.get("notes"), "tracklet notes"), + } + ) + rows.sort(key=lambda item: str(item["object_id"])) + return tuple(rows) + + +def _normalize_keyframes(value: object, *, first: int, last: int) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + previous = first - 1 + for raw in _list(value, "tracklet keyframes"): + keyframe = _object(raw, "tracklet keyframe") + _exact_keys(keyframe, {"sequence", "extent_xyxy", "visibility"}, "tracklet keyframe") + sequence = _integer(keyframe.get("sequence"), "keyframe sequence") + visibility = keyframe.get("visibility") + if ( + sequence <= previous + or not first <= sequence <= last + or visibility not in _VISIBILITY_STATES + ): + raise M48ObjectQualityError("tracklet keyframe is invalid") + previous = sequence + rows.append( + { + "sequence": sequence, + "extent_xyxy": _extent(keyframe.get("extent_xyxy")), + "visibility": visibility, + } + ) + if not rows or rows[0]["sequence"] != first or rows[-1]["sequence"] != last: + raise M48ObjectQualityError("tracklet keyframes must bind first and last sequence") + return tuple(rows) + + +def _normalize_state_segments( + value: object, + *, + first: int, + last: int, +) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + expected_start = first + for raw in _list(value, "tracklet state segments"): + segment = _object(raw, "tracklet state segment") + _exact_keys( + segment, + { + "start_sequence", + "end_sequence", + "geometry_association", + "freshness", + "motion", + "threat", + "critical_corridor_obstacle", + }, + "tracklet state segment", + ) + start = _integer(segment.get("start_sequence"), "state segment start") + end = _integer(segment.get("end_sequence"), "state segment end") + geometry = segment.get("geometry_association") + freshness = segment.get("freshness") + motion = segment.get("motion") + threat = segment.get("threat") + critical = segment.get("critical_corridor_obstacle") + if ( + start != expected_start + or not start <= end <= last + or geometry not in _GEOMETRY_STATES + or freshness not in _FRESHNESS_STATES + or motion not in _MOTION_STATES + or threat not in _THREAT_STATES + or not isinstance(critical, bool) + ): + raise M48ObjectQualityError("tracklet state segment is invalid") + rows.append( + { + "start_sequence": start, + "end_sequence": end, + "geometry_association": geometry, + "freshness": freshness, + "motion": motion, + "threat": threat, + "critical_corridor_obstacle": critical, + } + ) + expected_start = end + 1 + if not rows or rows[-1]["end_sequence"] != last: + raise M48ObjectQualityError("tracklet state segments must cover the full lifetime") + return tuple(rows) + + +def _expand_review_clips( + clips: tuple[dict[str, Any], ...], + *, + references: tuple[dict[str, Any], ...], + adjudicated: bool, +) -> tuple[dict[str, Any], ...]: + by_clip = {str(clip["clip_id"]): clip for clip in clips} + rows: list[dict[str, Any]] = [] + for reference in references: + clip = by_clip.get(str(reference["clip_id"])) + if clip is None: + raise M48ObjectQualityError("review clip expansion lost source coverage") + sequence = int(reference["sequence"]) + objects = [ + _expand_tracklet(tracklet, sequence) + for tracklet in clip["tracklets"] + if int(tracklet["first_sequence"]) <= sequence <= int(tracklet["last_sequence"]) + ] + objects.sort(key=lambda item: str(item["object_id"])) + rows.append( + { + "schema_version": M48_TRUTH_ROW_SCHEMA, + **_review_frame_identity(reference), + "no_object": not objects, + "objects": objects, + "adjudicated": adjudicated, + } + ) + return tuple(rows) + + +def _expand_tracklet(tracklet: Mapping[str, Any], sequence: int) -> dict[str, Any]: + keyframes = list(tracklet["keyframes"]) + left = keyframes[0] + right = keyframes[-1] + for candidate in keyframes: + if int(candidate["sequence"]) <= sequence: + left = candidate + if int(candidate["sequence"]) >= sequence: + right = candidate + break + if left["sequence"] == right["sequence"]: + extent = list(left["extent_xyxy"]) + else: + fraction = (sequence - int(left["sequence"])) / ( + int(right["sequence"]) - int(left["sequence"]) + ) + extent = [ + round(float(a) + (float(b) - float(a)) * fraction, 9) + for a, b in zip(left["extent_xyxy"], right["extent_xyxy"], strict=True) + ] + segment = next( + value + for value in tracklet["state_segments"] + if int(value["start_sequence"]) <= sequence <= int(value["end_sequence"]) + ) + return { + "object_id": tracklet["object_id"], + "extent_xyxy": extent, + "visibility": left["visibility"], + "geometry_association": segment["geometry_association"], + "freshness": segment["freshness"], + "motion": segment["motion"], + "threat": segment["threat"], + "critical_corridor_obstacle": segment["critical_corridor_obstacle"], + } + + +def _score_frame( + prediction: Mapping[str, Any], + truth: Mapping[str, Any], + *, + iou_threshold: float, +) -> tuple[dict[str, Any], list[str], Counter[str], Counter[str]]: + if ( + prediction.get("sequence") != truth.get("sequence") + or prediction.get("source_time_ns") != truth.get("source_time_ns") + or prediction.get("clip_id") != truth.get("clip_id") + ): + raise M48ObjectQualityError("prediction/truth source join changed") + predictions = [_object(value, "prediction object") for value in prediction["objects"]] + truth_objects = [_object(value, "truth object") for value in truth["objects"]] + matches, unmatched_predictions, unmatched_truth = _match_objects( + predictions, + truth_objects, + iou_threshold=iou_threshold, + ) + counts: Counter[str] = Counter( + { + "terminal_accounted": 1, + "truth_objects": len(truth_objects), + "prediction_objects": len(predictions), + "presence_true_positive": len(matches), + "presence_false_positive": len(unmatched_predictions), + "presence_false_negative": len(unmatched_truth), + "critical_truth": sum(1 for obj in truth_objects if obj["critical_corridor_obstacle"]), + } + ) + failures: set[str] = set() + if prediction["terminal_outcome"] != "delivered": + failures.add("terminal-non-delivery") + counts["terminal_non_delivery"] += 1 + if unmatched_predictions: + failures.add("presence-false-positive") + if unmatched_truth: + failures.add("presence-false-negative") + for truth_index in unmatched_truth: + if truth_objects[truth_index]["critical_corridor_obstacle"]: + failures.add("critical-corridor-miss") + counts["critical_missed"] += 1 + + matched_rows: list[dict[str, Any]] = [] + for prediction_index, truth_index, overlap in matches: + predicted = predictions[prediction_index] + expected = truth_objects[truth_index] + if expected["critical_corridor_obstacle"]: + counts["critical_matched"] += 1 + geometry_expected = expected["geometry_association"] + if geometry_expected not in {"ineligible", "unknown"}: + counts["geometry_evaluable"] += 1 + if predicted["geometry_association"] == geometry_expected: + counts["geometry_correct"] += 1 + else: + failures.add("geometry-association-error") + counts["geometry_error"] += 1 + counts["freshness_evaluable"] += 1 + if predicted["freshness"] == expected["freshness"]: + counts["freshness_correct"] += 1 + else: + failures.add("freshness-state-error") + counts["freshness_error"] += 1 + if {predicted["freshness"], expected["freshness"]} == {"current", "stale"}: + failures.add("stale-current-substitution") + counts["stale_current_substitution"] += 1 + if expected["motion"] in {"moving", "static"}: + counts["motion_evaluable"] += 1 + if predicted["motion"] == expected["motion"]: + counts["motion_correct"] += 1 + else: + failures.add("motion-state-error") + counts["motion_error"] += 1 + if ( + expected["critical_corridor_obstacle"] + and expected["threat"] == "threat" + and predicted["threat"] == "not-threat" + ): + failures.add("critical-not-threat") + counts["critical_not_threat"] += 1 + matched_rows.append( + { + "prediction_id": predicted["prediction_id"], + "truth_object_id": expected["object_id"], + "extent_iou": round(overlap, 9), + } + ) + + if prediction["free_space_claimed"] and any( + obj["critical_corridor_obstacle"] for obj in truth_objects + ): + failures.add("false-free-space-claim") + counts["false_free_space_claims"] += 1 + unknown_causes: Counter[str] = Counter() + for predicted in predictions: + if "unknown" in { + predicted["geometry_association"], + predicted["motion"], + predicted["threat"], + }: + counts["unknown_predictions"] += 1 + unknown_causes.update(str(cause) for cause in predicted["unknown_causes"]) + + frame = { + "schema_version": M48_FRAME_LEDGER_SCHEMA, + "clip_id": truth["clip_id"], + "component_id": truth["component_id"], + "split": truth["split"], + "sequence": truth["sequence"], + "source_time_ns": truth["source_time_ns"], + "terminal_outcome": prediction["terminal_outcome"], + "terminal_reason": prediction["terminal_reason"], + "free_space_claimed": prediction["free_space_claimed"], + "counts": dict(sorted(counts.items())), + "matched_objects": matched_rows, + "unmatched_prediction_ids": sorted( + str(predictions[index]["prediction_id"]) for index in unmatched_predictions + ), + "unmatched_truth_object_ids": sorted( + str(truth_objects[index]["object_id"]) for index in unmatched_truth + ), + "failure_causes": sorted(failures), + } + return frame, sorted(failures), counts, unknown_causes + + +def _match_objects( + predictions: list[dict[str, Any]], + truth: list[dict[str, Any]], + *, + iou_threshold: float, +) -> tuple[list[tuple[int, int, float]], list[int], list[int]]: + candidates = sorted( + ( + ( + box_iou(predicted["extent_xyxy"], expected["extent_xyxy"]), + prediction_index, + truth_index, + ) + for prediction_index, predicted in enumerate(predictions) + for truth_index, expected in enumerate(truth) + ), + key=lambda item: (-item[0], item[1], item[2]), + ) + used_predictions: set[int] = set() + used_truth: set[int] = set() + matches: list[tuple[int, int, float]] = [] + for overlap, prediction_index, truth_index in candidates: + if overlap < iou_threshold: + break + if prediction_index in used_predictions or truth_index in used_truth: + continue + used_predictions.add(prediction_index) + used_truth.add(truth_index) + matches.append((prediction_index, truth_index, overlap)) + matches.sort(key=lambda item: (item[0], item[1])) + return ( + matches, + [index for index in range(len(predictions)) if index not in used_predictions], + [index for index in range(len(truth)) if index not in used_truth], + ) + + +def box_iou(left: object, right: object) -> float: + """Return IoU for two validated normalized extents.""" + + a = _extent(left) + b = _extent(right) + width = max(0.0, min(a[2], b[2]) - max(a[0], b[0])) + height = max(0.0, min(a[3], b[3]) - max(a[1], b[1])) + intersection = width * height + area_a = (a[2] - a[0]) * (a[3] - a[1]) + area_b = (b[2] - b[0]) * (b[3] - b[1]) + union = area_a + area_b - intersection + return intersection / union if union > 0.0 else 0.0 + + +def _quality_metrics(totals: Counter[str], frame_count: int) -> dict[str, Any]: + return { + "frame_count": frame_count, + "terminal_outcome_accounting": _ratio(totals["terminal_accounted"], frame_count), + "terminal_non_delivery_count": totals["terminal_non_delivery"], + "false_free_space_claims": totals["false_free_space_claims"], + "obstacle_presence_precision": _ratio( + totals["presence_true_positive"], + totals["presence_true_positive"] + totals["presence_false_positive"], + ), + "obstacle_presence_recall": _ratio( + totals["presence_true_positive"], + totals["presence_true_positive"] + totals["presence_false_negative"], + ), + "critical_corridor_obstacle_recall": _ratio( + totals["critical_matched"], totals["critical_truth"] + ), + "geometry_association_correctness": _ratio( + totals["geometry_correct"], totals["geometry_evaluable"] + ), + "freshness_correctness": _ratio(totals["freshness_correct"], totals["freshness_evaluable"]), + "motion_decision_correctness": _ratio(totals["motion_correct"], totals["motion_evaluable"]), + "critical_threat_not_threat": totals["critical_not_threat"], + "stale_current_substitution_count": totals["stale_current_substitution"], + "presence_counts": { + "true_positive": totals["presence_true_positive"], + "false_positive": totals["presence_false_positive"], + "false_negative": totals["presence_false_negative"], + }, + "evaluable_counts": { + "critical_corridor": totals["critical_truth"], + "geometry_association": totals["geometry_evaluable"], + "freshness": totals["freshness_evaluable"], + "motion": totals["motion_evaluable"], + }, + } + + +def _quality_gates( + metrics: Mapping[str, Any], + totals: Counter[str], + profile: M48ObjectQualityProfile, + *, + required_hypotheses_present: bool, +) -> dict[str, bool]: + return { + "required_hypothesis_coverage": required_hypotheses_present, + "obstacle_presence_evaluable": ( + totals["prediction_objects"] > 0 and totals["truth_objects"] > 0 + ), + "critical_corridor_evaluable": totals["critical_truth"] > 0, + "geometry_association_evaluable": totals["geometry_evaluable"] > 0, + "freshness_evaluable": totals["freshness_evaluable"] > 0, + "motion_evaluable": totals["motion_evaluable"] > 0, + "terminal_outcome_accounting": metrics["terminal_outcome_accounting"] == 1.0, + "false_free_space_claims": metrics["false_free_space_claims"] == 0, + "obstacle_presence_precision": _at_least( + metrics["obstacle_presence_precision"], profile.obstacle_presence_precision + ), + "obstacle_presence_recall": _at_least( + metrics["obstacle_presence_recall"], profile.obstacle_presence_recall + ), + "critical_corridor_obstacle_recall": _at_least( + metrics["critical_corridor_obstacle_recall"], + profile.critical_corridor_obstacle_recall, + ), + "geometry_association_correctness": _at_least( + metrics["geometry_association_correctness"], + profile.geometry_association_correctness, + ), + "freshness_correctness": _at_least( + metrics["freshness_correctness"], profile.freshness_correctness + ), + "motion_decision_correctness": _at_least( + metrics["motion_decision_correctness"], profile.motion_decision_correctness + ), + "critical_threat_not_threat": totals["critical_not_threat"] == 0, + } + + +def _failure_case(frame: Mapping[str, Any], failures: list[str]) -> dict[str, Any]: + critical_causes = { + "critical-corridor-miss", + "critical-not-threat", + "false-free-space-claim", + "stale-current-substitution", + } + high_causes = { + "presence-false-negative", + "geometry-association-error", + "freshness-state-error", + "terminal-non-delivery", + } + severity = ( + "critical" + if critical_causes.intersection(failures) + else "high" + if high_causes.intersection(failures) + else "medium" + ) + identity = { + "clip_id": frame["clip_id"], + "split": frame["split"], + "sequence": frame["sequence"], + "causes": sorted(failures), + } + return { + "schema_version": M48_FAILURE_CASE_SCHEMA, + "failure_case_id": f"m48-failure-{_canonical_sha256(identity)}", + **identity, + "severity": severity, + "terminal_outcome": frame["terminal_outcome"], + "unmatched_prediction_ids": frame["unmatched_prediction_ids"], + "unmatched_truth_object_ids": frame["unmatched_truth_object_ids"], + } + + +def _failed_gate_causes( + gates: Mapping[str, bool], + failures: list[dict[str, Any]], + totals: Counter[str], +) -> list[dict[str, Any]]: + cause_by_gate = { + "required_hypothesis_coverage": "missing-required-validation-hypothesis", + "obstacle_presence_evaluable": "insufficient-evaluable-obstacle-presence", + "critical_corridor_evaluable": "insufficient-evaluable-critical-corridor", + "geometry_association_evaluable": ("insufficient-evaluable-geometry-association"), + "freshness_evaluable": "insufficient-evaluable-freshness", + "motion_evaluable": "insufficient-evaluable-motion", + "terminal_outcome_accounting": "incomplete-terminal-accounting", + "false_free_space_claims": "false-free-space-claim", + "obstacle_presence_precision": "presence-false-positive-cluster", + "obstacle_presence_recall": "presence-false-negative-cluster", + "critical_corridor_obstacle_recall": "critical-corridor-miss-cluster", + "geometry_association_correctness": "geometry-association-error-cluster", + "freshness_correctness": "freshness-state-error-cluster", + "motion_decision_correctness": "motion-state-error-cluster", + "critical_threat_not_threat": "critical-not-threat", + } + atlas_cause_by_gate = { + "false_free_space_claims": "false-free-space-claim", + "obstacle_presence_precision": "presence-false-positive", + "obstacle_presence_recall": "presence-false-negative", + "critical_corridor_obstacle_recall": "critical-corridor-miss", + "geometry_association_correctness": "geometry-association-error", + "freshness_correctness": "freshness-state-error", + "motion_decision_correctness": "motion-state-error", + "critical_threat_not_threat": "critical-not-threat", + } + denominator_by_gate = { + "obstacle_presence_precision": totals["prediction_objects"], + "obstacle_presence_recall": totals["truth_objects"], + "critical_corridor_obstacle_recall": totals["critical_truth"], + "geometry_association_correctness": totals["geometry_evaluable"], + "freshness_correctness": totals["freshness_evaluable"], + "motion_decision_correctness": totals["motion_evaluable"], + } + rows = [] + for gate, passed in gates.items(): + if passed: + continue + atlas_cause = atlas_cause_by_gate.get(gate) + cause = ( + f"insufficient-evaluable-{gate}" + if gate in denominator_by_gate and denominator_by_gate[gate] == 0 + else cause_by_gate[gate] + ) + case_ids = sorted( + str(row["failure_case_id"]) for row in failures if atlas_cause in row["causes"] + ) + rows.append({"gate": gate, "bounded_cause": cause, "failure_case_ids": case_ids}) + return rows + + +def _review_agreement( + review_a: tuple[dict[str, Any], ...], + review_b: tuple[dict[str, Any], ...], +) -> dict[str, Any]: + matched = 0 + ious: list[float] = [] + unmatched_a = 0 + unmatched_b = 0 + no_object_agreement = 0 + for left, right in zip(review_a, review_b, strict=True): + if left["no_object"] == right["no_object"]: + no_object_agreement += 1 + left_objects = [_object(value, "review A object") for value in left["objects"]] + right_objects = [_object(value, "review B object") for value in right["objects"]] + pairs, only_left, only_right = _match_objects( + [ + {"prediction_id": row["object_id"], "extent_xyxy": row["extent_xyxy"]} + for row in left_objects + ], + right_objects, + iou_threshold=0.5, + ) + matched += len(pairs) + ious.extend(overlap for _, _, overlap in pairs) + unmatched_a += len(only_left) + unmatched_b += len(only_right) + return { + "matched_class_free_iou_gte_0_5": matched, + "unmatched_reviewer_a": unmatched_a, + "unmatched_reviewer_b": unmatched_b, + "matched_iou_mean": round(sum(ious) / len(ious), 9) if ious else None, + "no_object_agreement_frames": no_object_agreement, + "frame_count": len(review_a), + } + + +def _write_generation( + *, + destination: Path, + schema_version: str, + result_id: str, + identity: dict[str, Any], + created_at_utc: str, + state_fields: dict[str, object], + documents: Mapping[str, tuple[dict[str, Any], str, str]], + row_sets: Mapping[str, tuple[Iterable[dict[str, Any]], str, str]], +) -> None: + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if destination.parent.is_symlink() or not destination.parent.is_dir(): + raise M48ObjectQualityError("M4.8 output root must be a real directory") + staging = destination.parent / f".{result_id}.{uuid.uuid4().hex}.tmp" + staging.mkdir(mode=0o700, exist_ok=False) + try: + artifact_schemas: dict[str, tuple[str, str]] = {} + for name, (document, role, artifact_schema) in documents.items(): + _safe_artifact_name(name) + _write_json(staging / name, document) + artifact_schemas[name] = (role, artifact_schema) + for name, (rows, role, artifact_schema) in row_sets.items(): + _safe_artifact_name(name) + _write_jsonl(staging / name, rows) + artifact_schemas[name] = (role, artifact_schema) + artifacts = [ + _artifact(staging / name, role, artifact_schema) + for name, (role, artifact_schema) in sorted(artifact_schemas.items()) + ] + identity_sha256 = _canonical_sha256(identity) + manifest = { + "schema_version": schema_version, + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": _utc_timestamp(created_at_utc, "created_at_utc"), + **state_fields, + "authority": _AUTHORITY, + "artifacts": artifacts, + } + _write_json(staging / M48_MANIFEST_NAME, manifest) + _publish(staging, destination) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def _read_generation(root: Path, schema: str, prefix: str) -> tuple[Path, dict[str, Any]]: + resolved, manifest = _read_generation_manifest(root, schema, prefix) + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or not artifacts or len(artifacts) > 16: + raise M48ObjectQualityError("M4.8 artifact inventory is invalid") + paths: set[str] = set() + for raw in artifacts: + descriptor = _object(raw, "M4.8 artifact") + _validate_artifact_descriptor(descriptor) + name = _safe_artifact_name(descriptor.get("path")) + path = resolved / name + if ( + name in paths + or path.is_symlink() + or not path.is_file() + or descriptor.get("byte_length") != path.stat().st_size + or descriptor.get("sha256") != _file_sha256(path) + ): + raise M48ObjectQualityError("M4.8 artifact proof changed") + paths.add(name) + return resolved, manifest + + +def _read_generation_manifest( + root: Path, + schema: str, + prefix: str, +) -> tuple[Path, dict[str, Any]]: + candidate = root.expanduser().absolute() + if candidate.is_symlink(): + raise M48ObjectQualityError("M4.8 result root must not be a symlink") + try: + resolved = candidate.resolve(strict=True) + except OSError as reason: + raise M48ObjectQualityError("M4.8 result root is unavailable") from reason + if not resolved.is_dir() or not resolved.name.startswith(prefix): + raise M48ObjectQualityError("M4.8 result root is invalid") + manifest = _read_json(resolved / M48_MANIFEST_NAME) + identity = _object(manifest.get("identity"), "M4.8 identity") + identity_sha256 = _canonical_sha256(identity) + if ( + manifest.get("schema_version") != schema + or manifest.get("result_id") != resolved.name + or manifest.get("identity_sha256") != identity_sha256 + or resolved.name != f"{prefix}{identity_sha256}" + ): + raise M48ObjectQualityError("M4.8 result identity changed") + _utc_timestamp(manifest.get("created_at_utc"), "M4.8 created_at_utc") + return resolved, manifest + + +def _validate_artifact_descriptor(descriptor: dict[str, Any]) -> None: + _exact_keys( + descriptor, + {"path", "role", "byte_length", "sha256", "schema_version", "media_type"}, + "M4.8 artifact", + ) + if ( + not isinstance(descriptor.get("byte_length"), int) + or isinstance(descriptor.get("byte_length"), bool) + or int(descriptor["byte_length"]) < 0 + or not isinstance(descriptor.get("role"), str) + or not isinstance(descriptor.get("schema_version"), str) + or not isinstance(descriptor.get("media_type"), str) + ): + raise M48ObjectQualityError("M4.8 artifact descriptor is invalid") + _safe_artifact_name(descriptor.get("path")) + _sha256_text(descriptor.get("sha256"), "artifact sha256") + + +def _verify_artifact_file(root: Path, descriptor: Mapping[str, Any]) -> None: + name = _safe_artifact_name(descriptor.get("path")) + path = root / name + if ( + path.is_symlink() + or not path.is_file() + or path.stat().st_size != descriptor.get("byte_length") + or _file_sha256(path) != descriptor.get("sha256") + ): + raise M48ObjectQualityError("M4.8 artifact proof changed") + + +def _publish(staging: Path, destination: Path) -> None: + if destination.exists(): + existing = { + path.name: _file_sha256(path) for path in destination.iterdir() if path.is_file() + } + proposed = {path.name: _file_sha256(path) for path in staging.iterdir() if path.is_file()} + if existing != proposed: + raise M48ObjectQualityError("immutable M4.8 identity collision") + shutil.rmtree(staging) + return + os.replace(staging, destination) + + +def _artifact(path: Path, role: str, schema_version: str) -> dict[str, object]: + return { + "path": path.name, + "role": role, + "byte_length": path.stat().st_size, + "sha256": _file_sha256(path), + "schema_version": schema_version, + "media_type": "application/x-ndjson" if path.suffix == ".jsonl" else "application/json", + } + + +def _write_json(path: Path, value: object) -> None: + with path.open("xb") as stream: + stream.write(_canonical_json(value) + b"\n") + stream.flush() + os.fsync(stream.fileno()) + + +def _write_jsonl(path: Path, rows: Iterable[object]) -> None: + with path.open("xb") as stream: + for row in rows: + stream.write(_canonical_json(row) + b"\n") + stream.flush() + os.fsync(stream.fileno()) + + +def _read_json(path: Path) -> dict[str, Any]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 2 * 1024 * 1024: + raise M48ObjectQualityError(f"M4.8 JSON artifact is invalid: {path.name}") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as reason: + raise M48ObjectQualityError(f"M4.8 JSON artifact is unreadable: {path.name}") from reason + return _object(value, path.name) + + +def _read_jsonl(path: Path) -> Iterable[dict[str, Any]]: + if path.is_symlink() or not path.is_file(): + raise M48ObjectQualityError(f"M4.8 JSONL artifact is invalid: {path.name}") + try: + with path.open("r", encoding="utf-8") as stream: + for line_number, line in enumerate(stream, start=1): + if len(line) > 1024 * 1024: + raise M48ObjectQualityError( + f"M4.8 JSONL row is too large: {path.name}:{line_number}" + ) + yield _object(json.loads(line), f"{path.name}:{line_number}") + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as reason: + raise M48ObjectQualityError(f"M4.8 JSONL artifact is unreadable: {path.name}") from reason + + +def _review_frame_identity(reference: Mapping[str, Any]) -> dict[str, Any]: + return { + "clip_id": reference["clip_id"], + "component_id": reference["component_id"], + "split": reference["split"], + "sequence": reference["sequence"], + "source_time_ns": reference["source_time_ns"], + "camera_fragment_sha256": reference["camera_fragment_sha256"], + } + + +def _authority_is_false(value: object) -> bool: + authority = _object(value, "authority") + return all( + authority.get(key) is False + for key in ( + "physical_live", + "commands_enabled", + "actuation_allowed", + "navigation_or_safety_accepted", + ) + ) + + +def _ratio(numerator: int, denominator: int) -> float: + return round(numerator / denominator, 9) if denominator > 0 else 0.0 + + +def _at_least(value: object, threshold: float) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and value >= threshold + + +def _extent(value: object) -> list[float]: + if ( + not isinstance(value, list) + or len(value) != 4 + or not all( + isinstance(item, (int, float)) + and not isinstance(item, bool) + and math.isfinite(float(item)) + for item in value + ) + ): + raise M48ObjectQualityError("object extent is invalid") + extent = [float(item) for item in value] + if not 0.0 <= extent[0] < extent[2] <= 1.0 or not 0.0 <= extent[1] < extent[3] <= 1.0: + raise M48ObjectQualityError("object extent is outside normalized bounds") + return extent + + +def _identifier(value: object, field: str) -> str: + if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None: + raise M48ObjectQualityError(f"{field} is invalid") + return value + + +def _sha256_text(value: object, field: str) -> str: + if not isinstance(value, str) or _SHA256.fullmatch(value) is None: + raise M48ObjectQualityError(f"{field} is invalid") + return value + + +def _integer(value: object, field: str) -> int: + if not isinstance(value, int) or isinstance(value, bool): + raise M48ObjectQualityError(f"{field} must be an integer") + return value + + +def _optional_text(value: object, field: str) -> str | None: + if value is None: + return None + if not isinstance(value, str) or len(value) > 2000: + raise M48ObjectQualityError(f"{field} is invalid") + return value + + +def _utc_timestamp(value: object, field: str) -> str: + if not isinstance(value, str): + raise M48ObjectQualityError(f"{field} is invalid") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as reason: + raise M48ObjectQualityError(f"{field} is invalid") from reason + if parsed.tzinfo is None or parsed.utcoffset() != UTC.utcoffset(parsed): + raise M48ObjectQualityError(f"{field} must be UTC") + return value + + +def _parse_utc(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _mapping(value: object, field: str) -> dict[str, Any]: + if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value): + raise M48ObjectQualityError(f"{field} must be an object") + return dict(value) + + +def _object(value: object, field: str) -> dict[str, Any]: + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise M48ObjectQualityError(f"{field} must be an object") + return value + + +def _list(value: object, field: str) -> list[Any]: + if not isinstance(value, list): + raise M48ObjectQualityError(f"{field} must be a list") + return value + + +def _exact_keys(value: Mapping[str, object], expected: set[str], field: str) -> None: + if set(value) != expected: + raise M48ObjectQualityError(f"{field} fields are invalid") + + +def _safe_artifact_name(value: object) -> str: + if ( + not isinstance(value, str) + or not value + or "/" in value + or "\\" in value + or value in {".", ".."} + or Path(value).name != value + ): + raise M48ObjectQualityError("M4.8 artifact name is invalid") + return value + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _canonical_sha256(value: object) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def _rows_sha256(rows: Iterable[object]) -> str: + digest = hashlib.sha256() + for row in rows: + digest.update(_canonical_json(row)) + digest.update(b"\n") + return digest.hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +__all__ = [ + "DEFAULT_M48_OBJECT_QUALITY_PROFILE", + "M48_ADJUDICATION_SCHEMA", + "M48_CONTRACT_SCHEMA", + "M48_PACK_PREFIX", + "M48_PACK_SCHEMA", + "M48_PREPARATION_PROVENANCE_SCHEMA", + "M48_PROFILE_SCHEMA", + "M48_RESULT_PREFIX", + "M48_RESULT_SCHEMA", + "M48_REVIEW_SCHEMA", + "M48_REVIEWER_PACKAGE_SCHEMA", + "M48_REVIEWER_PACKAGE_NAME", + "M48_SELECTION_HYPOTHESIS_PROFILE", + "M48_TRUTH_PREFIX", + "M48_TRUTH_SEAL_SCHEMA", + "M48ObjectQualityError", + "M48ObjectQualityPack", + "M48ObjectQualityProfile", + "M48ObjectQualityResult", + "M48ObjectTruthSeal", + "box_iou", + "build_m48_object_quality_pack", + "build_m48_object_truth_seal", + "read_m48_object_quality_pack", + "read_m48_object_quality_result", + "read_m48_object_truth_seal", + "score_m48_object_quality", + "validate_m48_review_submission", +] diff --git a/src/k1link/laboratory/m48_ravnoves00_pack.py b/src/k1link/laboratory/m48_ravnoves00_pack.py new file mode 100644 index 0000000..b88cc0e --- /dev/null +++ b/src/k1link/laboratory/m48_ravnoves00_pack.py @@ -0,0 +1,567 @@ +"""Deterministic RAVNOVES00 adapter for the M4.8 object-quality pack.""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from collections.abc import Iterator, Mapping +from itertools import zip_longest +from pathlib import Path +from typing import Any, Final + +from k1link.laboratory.m47_reference_graph import read_m47_reference_graph_lab +from k1link.laboratory.m48_object_quality import ( + M48_PREPARATION_PROVENANCE_SCHEMA, + M48_SELECTION_HYPOTHESIS_PROFILE, + M48ObjectQualityPack, + build_m48_object_quality_pack, +) + +M48_SELECTION_SCHEMA: Final = "missioncore.m48-object-quality-selection/v1" +M48_SELECTION_ID: Final = "m48-ravnoves00-balanced-connected-clips/v1" +M48_SOURCE_ID: Final = "RAVNOVES00" +M48_SOURCE_SESSION_ID: Final = "20260720T065719Z_viewer_live" +M48_FRAME_COUNT: Final = 4_489 +M48_IMAGE_WIDTH: Final = 800 +M48_IMAGE_HEIGHT: Final = 600 +_CAMERA_INDEX_SCHEMA: Final = "missioncore.camera-recording-index/v1" +_GRAPH_FRAME_SCHEMA: Final = "missioncore.local-obstacle-map/v1" +_THREAT_FRAME_SCHEMAS: Final = frozenset( + { + "missioncore.perception-threat-replay-frame/v1", + "missioncore.perception-threat-replay-frame/v2", + } +) +_SHA256 = re.compile(r"^[a-f0-9]{64}$") + + +class M48Ravnoves00PackError(RuntimeError): + """The source adapter escaped the accepted immutable RAVNOVES00 evidence.""" + + +def prepare_m48_ravnoves00_pack( + *, + m47_lab_root: Path, + graph_result_root: Path, + threat_result_root: Path, + geometry_result_root: Path, + camera_index_path: Path, + selection_path: Path, + frozen_at_utc: str, + output_root: Path, +) -> M48ObjectQualityPack: + """Freeze the selected M4.8 clips from the exact accepted M4.7 source.""" + + lab = read_m47_reference_graph_lab(m47_lab_root) + source = _mapping(lab.report.get("source"), "M4.7 source") + graph_root = _directory(graph_result_root, "M4.7 graph result") + threat_root = _directory(threat_result_root, "M4.6 visual result") + geometry_root = _directory(geometry_result_root, "M4.4 geometry result") + camera_index = _file(camera_index_path, "recorded camera index") + selection = _read_json(_file(selection_path, "M4.8 selection"), "M4.8 selection") + clips = _selection_clips(selection) + + if ( + graph_root.name != source.get("graph_result_id") + or threat_root.name != source.get("visual_result_id") + or source.get("source_id") != M48_SOURCE_ID + or source.get("source_session_id") != M48_SOURCE_SESSION_ID + ): + raise M48Ravnoves00PackError("M4.8 source roots do not match the accepted M4.7 LAB") + + graph_frames_path = _validate_graph_result(graph_root) + threat_frames_path, threat_identity = _validate_threat_result( + threat_root, + expected_frames_sha256=source.get("threat_frames_sha256"), + ) + geometry_frames_path = _validate_geometry_result( + geometry_root, + expected_result_id=threat_identity.get("geometry_result_id"), + expected_frames_sha256=threat_identity.get("geometry_frames_sha256"), + ) + camera_rows = tuple(_iter_jsonl(camera_index, "recorded camera index")) + _validate_camera_rows(camera_rows) + + selected_sequences = { + sequence + for clip in clips + for sequence in range( + _integer(clip.get("start_sequence"), "clip start_sequence"), + _integer(clip.get("end_sequence"), "clip end_sequence") + 1, + ) + } + frame_catalog: list[dict[str, object]] = [] + predictions: list[dict[str, object]] = [] + previous_source_time_ns = -1 + graph_rows = _iter_jsonl(graph_frames_path, "M4.7 graph frames") + threat_rows = _iter_jsonl(threat_frames_path, "M4.6 threat frames") + geometry_rows = _iter_jsonl(geometry_frames_path, "M4.4 geometry frames") + for frame_index, values in enumerate( + zip_longest(graph_rows, threat_rows, geometry_rows, camera_rows), + ): + graph_row, threat_row, geometry_row, camera_row = values + if graph_row is None or threat_row is None or geometry_row is None or camera_row is None: + raise M48Ravnoves00PackError("M4.8 source ledgers have different lengths") + sequence = frame_index + 1 + source_time_ns = _validate_bound_frame( + graph_row=graph_row, + threat_row=threat_row, + geometry_row=geometry_row, + camera_row=camera_row, + frame_index=frame_index, + previous_source_time_ns=previous_source_time_ns, + ) + previous_source_time_ns = source_time_ns + frame_catalog.append( + { + "sequence": sequence, + "source_time_ns": source_time_ns, + "camera_fragment_sha256": camera_row["sha256"], + } + ) + if sequence in selected_sequences: + obstacle_map = _mapping(graph_row.get("obstacle_map"), "M4.7 obstacle map") + predictions.append( + { + "sequence": sequence, + "source_time_ns": source_time_ns, + "terminal_outcome": "delivered", + "terminal_reason": None, + "free_space_claimed": obstacle_map["free_space_claimed"], + "objects": _prediction_objects( + threat_row.get("camera_proposals"), + geometry_observations=geometry_row.get("observations"), + metric_obstacles=threat_row.get("metric_obstacles"), + ), + } + ) + if len(frame_catalog) != M48_FRAME_COUNT: + raise M48Ravnoves00PackError("M4.8 source frame count changed") + + preparation_provenance = { + "schema_version": M48_PREPARATION_PROVENANCE_SCHEMA, + "adapter": { + "module": "k1link.laboratory.m48_ravnoves00_pack", + "sha256": _file_sha256(Path(__file__).resolve(strict=True)), + }, + "selection": { + "selection_id": M48_SELECTION_ID, + "sha256": _file_sha256(selection_path), + }, + "camera_index": { + "source_session_id": M48_SOURCE_SESSION_ID, + "sha256": _file_sha256(camera_index), + "byte_length": camera_index.stat().st_size, + "frame_count": len(camera_rows), + }, + "graph": _source_provenance(graph_root, graph_frames_path), + "threat": _source_provenance(threat_root, threat_frames_path), + "geometry": _source_provenance(geometry_root, geometry_frames_path), + } + + return build_m48_object_quality_pack( + m47_lab_root=lab.result_root, + frame_catalog=frame_catalog, + clips=clips, + predictions=predictions, + preparation_provenance=preparation_provenance, + frozen_at_utc=frozen_at_utc, + output_root=output_root, + ) + + +def _validate_graph_result(root: Path) -> Path: + manifest = _read_json(_file(root / "manifest.json", "M4.7 graph manifest"), "graph manifest") + files = _mapping(manifest.get("files"), "M4.7 graph files") + descriptor = _mapping(files.get("frames.jsonl"), "M4.7 graph frame descriptor") + frames = _file(root / "frames.jsonl", "M4.7 graph frames") + expected_bytes = descriptor.get("bytes") + expected_sha256 = descriptor.get("sha256") + if ( + manifest.get("schema_version") != "missioncore.reference-perception-graph-manifest/v1" + or manifest.get("result_id") != root.name + or manifest.get("accepted") is not True + or manifest.get("graph_id") != "reference-perception-graph/v2" + or manifest.get("run_mode") != "lossless-replay" + or not isinstance(expected_bytes, int) + or expected_bytes != frames.stat().st_size + or not _is_sha256(expected_sha256) + or _file_sha256(frames) != expected_sha256 + ): + raise M48Ravnoves00PackError("M4.7 graph result changed") + return frames + + +def _validate_threat_result( + root: Path, + *, + expected_frames_sha256: object, +) -> tuple[Path, dict[str, Any]]: + manifest = _read_json( + _file(root / "manifest.json", "M4.6 threat manifest"), + "threat manifest", + ) + identity = _mapping(manifest.get("identity"), "M4.6 threat identity") + frames = _file(root / "frames.jsonl", "M4.6 threat frames") + if ( + manifest.get("schema_version") != "missioncore.perception-threat-replay-result/v2" + or manifest.get("result_id") != root.name + or manifest.get("accepted") is not True + or identity.get("source_session_id") != M48_SOURCE_SESSION_ID + or not _is_sha256(expected_frames_sha256) + or identity.get("frames_sha256") != expected_frames_sha256 + or _file_sha256(frames) != expected_frames_sha256 + ): + raise M48Ravnoves00PackError("M4.6 threat result changed") + return frames, identity + + +def _validate_geometry_result( + root: Path, + *, + expected_result_id: object, + expected_frames_sha256: object, +) -> Path: + manifest = _read_json( + _file(root / "manifest.json", "M4.4 geometry manifest"), + "geometry manifest", + ) + identity = _mapping(manifest.get("identity"), "M4.4 geometry identity") + frames = _file(root / "frames.jsonl", "M4.4 geometry frames") + if ( + manifest.get("schema_version") != "missioncore.perception-geometry-replay-result/v1" + or root.name != expected_result_id + or identity.get("accepted") is not True + or identity.get("source_pack_id") + != "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b" + or not _is_sha256(expected_frames_sha256) + or identity.get("frames_sha256") != expected_frames_sha256 + or _file_sha256(frames) != expected_frames_sha256 + ): + raise M48Ravnoves00PackError("M4.4 geometry result changed") + return frames + + +def _selection_clips(document: Mapping[str, object]) -> tuple[dict[str, object], ...]: + expected_keys = { + "schema_version", + "selection_id", + "source_id", + "source_session_id", + "selection_basis", + "camera_frame_size", + "selection_hypothesis_profile", + "clips", + } + frame_size = _mapping(document.get("camera_frame_size"), "selection frame size") + raw_clips = document.get("clips") + if ( + set(document) != expected_keys + or document.get("schema_version") != M48_SELECTION_SCHEMA + or document.get("selection_id") != M48_SELECTION_ID + or document.get("source_id") != M48_SOURCE_ID + or document.get("source_session_id") != M48_SOURCE_SESSION_ID + or document.get("selection_basis") + != "prediction-frozen-source-curation-before-independent-truth" + or frame_size != {"width": M48_IMAGE_WIDTH, "height": M48_IMAGE_HEIGHT} + or document.get("selection_hypothesis_profile") != M48_SELECTION_HYPOTHESIS_PROFILE + or not isinstance(raw_clips, list) + or any(not isinstance(item, dict) for item in raw_clips) + ): + raise M48Ravnoves00PackError("M4.8 selection contract changed") + return tuple(dict(item) for item in raw_clips) + + +def _source_provenance(root: Path, frames_path: Path) -> dict[str, str]: + return { + "result_id": root.name, + "manifest_sha256": _file_sha256(root / "manifest.json"), + "frames_sha256": _file_sha256(frames_path), + } + + +def _validate_camera_rows(rows: tuple[dict[str, Any], ...]) -> None: + if len(rows) != M48_FRAME_COUNT: + raise M48Ravnoves00PackError("recorded camera index frame count changed") + previous_session_time = -1 + for expected_sequence, row in enumerate(rows, start=1): + session_time = row.get("session_monotonic_ns") + if ( + row.get("schema_version") != _CAMERA_INDEX_SCHEMA + or row.get("kind") != "media" + or row.get("sequence") != expected_sequence + or not isinstance(session_time, int) + or session_time <= previous_session_time + or not _is_sha256(row.get("sha256")) + ): + raise M48Ravnoves00PackError("recorded camera index changed") + previous_session_time = session_time + + +def _validate_bound_frame( + *, + graph_row: Mapping[str, Any], + threat_row: Mapping[str, Any], + geometry_row: Mapping[str, Any], + camera_row: Mapping[str, Any], + frame_index: int, + previous_source_time_ns: int, +) -> int: + obstacle_map = _mapping(graph_row.get("obstacle_map"), "M4.7 obstacle map") + source_time_ns = threat_row.get("source_time_ns") + if ( + graph_row.get("sequence") != frame_index + or obstacle_map.get("schema_version") != _GRAPH_FRAME_SCHEMA + or obstacle_map.get("frame_id") != f"frame-{frame_index:06d}" + or not isinstance(obstacle_map.get("free_space_claimed"), bool) + or threat_row.get("schema_version") not in _THREAT_FRAME_SCHEMAS + or threat_row.get("sequence") != frame_index + or threat_row.get("frame_id") != f"frame-{frame_index:06d}" + or geometry_row.get("schema_version") != "missioncore.perception-geometry-replay-frame/v1" + or geometry_row.get("sequence") != frame_index + or geometry_row.get("frame_id") != f"frame-{frame_index:06d}" + or geometry_row.get("source_available") != threat_row.get("source_available") + or not isinstance(source_time_ns, int) + or source_time_ns <= previous_source_time_ns + or camera_row.get("sequence") != frame_index + 1 + ): + raise M48Ravnoves00PackError("M4.8 frame binding changed") + return source_time_ns + + +def _prediction_objects( + value: object, + *, + geometry_observations: object, + metric_obstacles: object, +) -> list[dict[str, object]]: + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + raise M48Ravnoves00PackError("M4.6 camera proposal collection changed") + observations = _proposal_observations(geometry_observations) + obstacles = _metric_obstacles(metric_obstacles) + objects: list[dict[str, object]] = [] + seen: set[str] = set() + for proposal in value: + prediction_id = proposal.get("proposal_id") + occupied_support = proposal.get("occupied_support") + threat_value = proposal.get("threat_decision") + if ( + not isinstance(prediction_id, str) + or prediction_id in seen + or not isinstance(occupied_support, bool) + or threat_value not in {None, "threat", "not-threat", "unknown"} + ): + raise M48Ravnoves00PackError("M4.6 camera proposal identity changed") + seen.add(prediction_id) + observation = observations.get(prediction_id) + geometry = "associated" if occupied_support else "unknown" + freshness = "current" + motion = "unsupported" + threat = threat_value if isinstance(threat_value, str) else "unknown" + if occupied_support: + if observation is None: + raise M48Ravnoves00PackError("associated proposal lost its geometry observation") + currentness = observation.get("currentness") + if currentness not in {"current", "held", "stale", "unavailable"}: + raise M48Ravnoves00PackError("associated proposal currentness changed") + freshness = str(currentness) + centroid = _metric_centroid(observation) + obstacle = _match_metric_obstacle(centroid, obstacles) + raw_motion = obstacle.get("motion") + motion = { + "moving": "moving", + "stationary": "static", + "unknown": "unknown", + }.get(str(raw_motion), "") + assessment = _mapping(obstacle.get("assessment"), "metric obstacle assessment") + obstacle_threat = assessment.get("decision") + if not motion or obstacle_threat not in {"threat", "not-threat", "unknown"}: + raise M48Ravnoves00PackError("associated proposal state changed") + threat = str(obstacle_threat) + causes: set[str] = set() + if geometry == "unknown": + causes.add("insufficient-geometry-support") + if threat == "unknown": + causes.add("threat-evidence-insufficient") + if motion == "unknown": + causes.add("motion-not-supported") + objects.append( + { + "prediction_id": prediction_id, + "extent_xyxy": _normalized_extent(proposal.get("bbox_xyxy")), + "geometry_association": geometry, + "freshness": freshness, + "motion": motion, + "threat": threat, + "unknown_causes": sorted(causes), + } + ) + return objects + + +def _proposal_observations(value: object) -> dict[str, dict[str, Any]]: + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + raise M48Ravnoves00PackError("M4.4 observation collection changed") + mapped: dict[str, dict[str, Any]] = {} + for observation in value: + proposal_ids = observation.get("proposal_ids") + if not isinstance(proposal_ids, list) or any( + not isinstance(item, str) for item in proposal_ids + ): + raise M48Ravnoves00PackError("M4.4 proposal binding changed") + for proposal_id in proposal_ids: + if proposal_id in mapped: + raise M48Ravnoves00PackError("M4.4 proposal has multiple observations") + mapped[proposal_id] = observation + return mapped + + +def _metric_obstacles(value: object) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + raise M48Ravnoves00PackError("M4.6 metric obstacle collection changed") + return tuple(value) + + +def _metric_centroid(observation: Mapping[str, Any]) -> tuple[float, float, float]: + geometry = _mapping(observation.get("metric_geometry"), "proposal metric geometry") + value = geometry.get("centroid_xyz_m") + if ( + not isinstance(value, list) + or len(value) != 3 + or any( + not isinstance(item, (int, float)) + or isinstance(item, bool) + or not math.isfinite(float(item)) + for item in value + ) + ): + raise M48Ravnoves00PackError("proposal metric centroid changed") + return float(value[0]), float(value[1]), float(value[2]) + + +def _match_metric_obstacle( + centroid: tuple[float, float, float], + obstacles: tuple[dict[str, Any], ...], +) -> dict[str, Any]: + matches: list[dict[str, Any]] = [] + for obstacle in obstacles: + value = obstacle.get("centroid_map_xyz_m") + if ( + isinstance(value, list) + and len(value) == 3 + and all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in value) + and max( + abs(float(left) - float(right)) for left, right in zip(value, centroid, strict=True) + ) + <= 1e-9 + ): + matches.append(obstacle) + if len(matches) != 1: + raise M48Ravnoves00PackError("proposal metric obstacle association is ambiguous") + return matches[0] + + +def _normalized_extent(value: object) -> list[float]: + if ( + not isinstance(value, list) + or len(value) != 4 + or any( + not isinstance(item, (int, float)) + or isinstance(item, bool) + or not math.isfinite(float(item)) + for item in value + ) + ): + raise M48Ravnoves00PackError("M4.6 proposal extent changed") + x_min, y_min, x_max, y_max = (float(item) for item in value) + extent = [ + x_min / M48_IMAGE_WIDTH, + y_min / M48_IMAGE_HEIGHT, + x_max / M48_IMAGE_WIDTH, + y_max / M48_IMAGE_HEIGHT, + ] + if not 0.0 <= extent[0] < extent[2] <= 1.0 or not 0.0 <= extent[1] < extent[3] <= 1.0: + raise M48Ravnoves00PackError("M4.6 proposal extent escaped the camera raster") + return extent + + +def _iter_jsonl(path: Path, label: str) -> Iterator[dict[str, Any]]: + with path.open("r", encoding="utf-8") as stream: + for line_number, line in enumerate(stream, start=1): + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + raise M48Ravnoves00PackError(f"{label} row {line_number} is invalid") from exc + if not isinstance(value, dict): + raise M48Ravnoves00PackError(f"{label} row {line_number} is not an object") + yield value + + +def _read_json(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise M48Ravnoves00PackError(f"{label} is invalid") from exc + if not isinstance(value, dict): + raise M48Ravnoves00PackError(f"{label} must be an object") + return value + + +def _mapping(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise M48Ravnoves00PackError(f"{label} is invalid") + return value + + +def _integer(value: object, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool): + raise M48Ravnoves00PackError(f"{label} is invalid") + return value + + +def _directory(path: Path, label: str) -> Path: + candidate = path.expanduser().absolute() + if candidate.is_symlink(): + raise M48Ravnoves00PackError(f"{label} must not be a symlink") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise M48Ravnoves00PackError(f"{label} is unavailable") from exc + if not resolved.is_dir(): + raise M48Ravnoves00PackError(f"{label} is unavailable") + return resolved + + +def _file(path: Path, label: str) -> Path: + candidate = path.expanduser().absolute() + if candidate.is_symlink(): + raise M48Ravnoves00PackError(f"{label} must not be a symlink") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise M48Ravnoves00PackError(f"{label} is unavailable") from exc + if not resolved.is_file(): + raise M48Ravnoves00PackError(f"{label} is unavailable") + return resolved + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _is_sha256(value: object) -> bool: + return isinstance(value, str) and _SHA256.fullmatch(value) is not None + + +__all__ = [ + "M48Ravnoves00PackError", + "M48_SELECTION_ID", + "M48_SELECTION_SCHEMA", + "prepare_m48_ravnoves00_pack", +] diff --git a/src/k1link/laboratory/m48_raw_evidence.py b/src/k1link/laboratory/m48_raw_evidence.py new file mode 100644 index 0000000..327bde5 --- /dev/null +++ b/src/k1link/laboratory/m48_raw_evidence.py @@ -0,0 +1,507 @@ +"""Prediction-free raw spatial evidence for the neutral M4.8 review surface. + +This reader deliberately does not open the M4.7 graph payload or the frozen M4.8 +prediction ledger. It reuses the already verified recorded-geometry and replay +body-frame primitives to expose only a bounded current LiDAR increment in the +virtual body frame, together with immutable rig/corridor parameters. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from threading import RLock +from typing import Any, Final + +from k1link.laboratory.m47_reference_graph import ( + M47ReferenceGraphLabError, + read_m47_reference_graph_lab, +) +from k1link.laboratory.m48_object_quality import M48ObjectQualityPack +from k1link.perception.spatial_evidence import ( + SpatialEvidenceProjectionError, + sample_points_in_body_frame, +) +from k1link.perception.threat_replay import ( + ThreatReplayError, + ThreatReplayResult, + read_threat_replay_result, +) +from k1link.perception.threat_timeline import ( + RECORDED_SPATIAL_POINT_LIMIT, + RecordedThreatTimeline, + RecordedThreatTimelineError, +) + +M48_RAW_SPATIAL_FRAME_SCHEMA: Final = "missioncore.m48-neutral-object-review-spatial-frame/v1" +M48_EXPECTED_SOURCE_ID: Final = "RAVNOVES00" +M48_EXPECTED_E10_SOURCE_ID: Final = "sensor.camera.right" +M48_EXPECTED_SESSION_ID: Final = "20260720T065719Z_viewer_live" +M48_EXPECTED_FRAME_COUNT: Final = 4_489 +M48_EXPECTED_SOURCE_PACK_ID: Final = ( + "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b" +) +M48_EXPECTED_SOURCE_PACK_SHA256: Final = ( + "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944" +) +M48_EXPECTED_THREAT_RESULT_ID: Final = ( + "m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324" +) + +_E10_SCHEMA: Final = "missioncore.e10-lidar-replay-pack/v1" +_E10_ARTIFACT_NAME: Final = "lidar-pack.npz" +_FALSE_AUTHORITY: Final = { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, +} +_MANIFEST_KEYS: Final = { + "artifact", + "classification", + "created_at_utc", + "ground_truth", + "identity", + "identity_sha256", + "pack_id", + "schema_version", +} +_IDENTITY_KEYS: Final = { + "available_lidar_frames", + "calibration_sha256", + "camera_slot", + "e6_profile_sha256", + "e6_result_id", + "frame_count", + "input_sha256", + "job_id", + "point_count", + "producer_sha256", + "projection", + "schema_version", + "semantic_timeline_result_id", + "session_id", + "source_end_frame_index", + "source_id", + "source_start_frame_index", + "temporal_binding", + "temporal_policy", + "timeline_end_seconds", + "timeline_start_seconds", +} +_ARTIFACT_KEYS: Final = {"byte_length", "media_type", "path", "sha256"} + + +class M48RawEvidenceError(RuntimeError): + """Neutral M4.8 spatial evidence escaped an immutable source binding.""" + + +@dataclass(frozen=True, slots=True) +class _PackFrameBinding: + clip_id: str + source_time_ns: int + + +class M48RawEvidenceReader: + """Provide one prediction-blind, bounded body-frame projection per call. + + Construct production instances with :meth:`from_repository`. The object is + directly compatible with the M4.8 API provider callable: + ``reader(pack, one_based_sequence)``. + """ + + def __init__( + self, + *, + repository_root: Path, + threat_result: ThreatReplayResult, + timeline: RecordedThreatTimeline, + point_limit: int = RECORDED_SPATIAL_POINT_LIMIT, + ) -> None: + if ( + not isinstance(point_limit, int) + or isinstance(point_limit, bool) + or not 1 <= point_limit <= RECORDED_SPATIAL_POINT_LIMIT + ): + raise M48RawEvidenceError("M4.8 raw evidence point limit is invalid") + self.repository_root = repository_root.resolve(strict=True) + self.threat_result = threat_result + self.timeline = timeline + self.point_limit = point_limit + self._pack_indices: dict[str, dict[int, _PackFrameBinding]] = {} + self._lock = RLock() + + @classmethod + def from_repository( + cls, + *, + repository_root: Path, + threat_result_root: Path, + expected_source_pack_id: str = M48_EXPECTED_SOURCE_PACK_ID, + point_limit: int = RECORDED_SPATIAL_POINT_LIMIT, + ) -> M48RawEvidenceReader: + """Open the exact sealed M4 result and its exact E10 source generation. + + ``threat_result_root`` is the immutable result generation directory, not + the parent collection. No latest-by-mtime discovery is permitted. + """ + + repository = _strict_directory(repository_root, "repository root") + if expected_source_pack_id != M48_EXPECTED_SOURCE_PACK_ID: + raise M48RawEvidenceError("M4.8 E10 pack id escaped the canonical binding") + threat_root = _strict_directory(threat_result_root, "threat result root") + try: + result = read_threat_replay_result(threat_root) + except (OSError, ValueError, ThreatReplayError) as exc: + raise M48RawEvidenceError("M4.8 threat result is invalid") from exc + _validate_threat_result(result, expected_source_pack_id=expected_source_pack_id) + pack_root = ( + repository / ".runtime/compute-experiments/e10/lidar-packs" / expected_source_pack_id + ) + _validate_e10_pack( + pack_root, + expected_pack_id=expected_source_pack_id, + expected_artifact_sha256=M48_EXPECTED_SOURCE_PACK_SHA256, + ) + try: + timeline = RecordedThreatTimeline(repository_root=repository, result=result) + except (OSError, ValueError, RecordedThreatTimelineError) as exc: + raise M48RawEvidenceError("M4.8 recorded geometry timeline is invalid") from exc + if ( + len(timeline.index.source_times_ns) != M48_EXPECTED_FRAME_COUNT + or timeline.profile.source_id != M48_EXPECTED_SOURCE_ID + or timeline.profile.session_id != M48_EXPECTED_SESSION_ID + or timeline.profile.source_pack_id != expected_source_pack_id + or timeline.profile.source_pack_sha256 != M48_EXPECTED_SOURCE_PACK_SHA256 + ): + raise M48RawEvidenceError("M4.8 recorded geometry binding changed") + return cls( + repository_root=repository, + threat_result=result, + timeline=timeline, + point_limit=point_limit, + ) + + def __call__( + self, + pack: M48ObjectQualityPack, + sequence: int, + ) -> dict[str, object]: + return self.frame(pack=pack, sequence=sequence) + + def frame( + self, + *, + pack: M48ObjectQualityPack, + sequence: int, + ) -> dict[str, object]: + """Return one one-based, clip-bound neutral spatial frame.""" + + if ( + not isinstance(sequence, int) + or isinstance(sequence, bool) + or not 1 <= sequence <= M48_EXPECTED_FRAME_COUNT + ): + raise M48RawEvidenceError("M4.8 raw evidence sequence is invalid") + binding = self._binding_for(pack, sequence) + frame_index = sequence - 1 + try: + temporal = self.timeline.store.temporal_binding_for_index(frame_index) + body_frame = self.timeline.body_frames.body_frame_for_frame(f"frame-{frame_index:06d}") + except (RuntimeError, TypeError, ValueError) as exc: + raise M48RawEvidenceError("M4.8 source frame binding is invalid") from exc + if temporal.frame_index != frame_index or temporal.source_time_ns != binding.source_time_ns: + raise M48RawEvidenceError("M4.8 source time escaped the neutral frame reference") + + points_body: list[list[float]] = [] + if body_frame is not None: + if not temporal.source_available: + raise M48RawEvidenceError("unavailable source produced an M4.8 body frame") + points = self.timeline.store.current_points_for_frame(frame_index) + if points is None: + raise M48RawEvidenceError("qualified M4.8 body frame lacks current LiDAR") + try: + points_body, _ = sample_points_in_body_frame( + points, + body_frame, + point_limit=self.point_limit, + ) + except SpatialEvidenceProjectionError as exc: + raise M48RawEvidenceError("M4.8 body-frame point projection failed") from exc + + profile = self.timeline.profile + return { + "schema_version": M48_RAW_SPATIAL_FRAME_SCHEMA, + "pack_id": pack.result_id, + "clip_id": binding.clip_id, + "sequence": sequence, + "source_time_ns": temporal.source_time_ns, + "source_available": temporal.source_available, + "body_frame_available": body_frame is not None, + "point_cloud_body_xyz_m": points_body, + "rig": { + "profile_id": profile.rig.profile_id, + "length_m": profile.rig.body_length_m, + "width_m": profile.rig.body_width_m, + "lidar_reference": profile.rig.lidar_reference, + "nominal_sensor_height_m": profile.rig.nominal_sensor_height_m, + "physical_mount_claimed": False, + }, + "corridor": { + "profile_id": profile.corridor.profile_id, + "forward_length_m": profile.corridor.forward_length_m, + "rear_margin_m": profile.corridor.rear_margin_m, + "lateral_clearance_m": profile.corridor.lateral_clearance_m, + "half_width_m": ( + profile.rig.body_width_m / 2 + profile.corridor.lateral_clearance_m + ), + "prediction_horizon_seconds": (profile.corridor.prediction_horizon_seconds), + }, + "occupied_voxel_size_m": profile.corridor.occupied_voxel_size_m, + "candidate_identity_included": False, + "graph_boxes_ids_scores_included": False, + "frozen_predictions_included": False, + "strata_included": False, + "authority": dict(_FALSE_AUTHORITY), + } + + def _binding_for( + self, + pack: M48ObjectQualityPack, + sequence: int, + ) -> _PackFrameBinding: + with self._lock: + index = self._pack_indices.get(pack.result_id) + if index is None: + _validate_m47_pack_binding( + repository_root=self.repository_root, + pack=pack, + threat_result=self.threat_result, + ) + index = _index_neutral_frame_references(pack) + self._pack_indices[pack.result_id] = index + binding = index.get(sequence) + if binding is None: + raise M48RawEvidenceError("M4.8 sequence is outside the selected neutral clips") + return binding + + +def _validate_threat_result( + result: ThreatReplayResult, + *, + expected_source_pack_id: str, +) -> None: + identity = result.manifest.get("identity") + metrics = identity.get("metrics") if isinstance(identity, dict) else None + frames = metrics.get("frames") if isinstance(metrics, dict) else None + if ( + result.result_id != M48_EXPECTED_THREAT_RESULT_ID + or result.result_root.name != result.result_id + or result.accepted is not True + or not isinstance(identity, dict) + or identity.get("source_id") != M48_EXPECTED_SOURCE_ID + or identity.get("source_session_id") != M48_EXPECTED_SESSION_ID + or identity.get("source_pack_id") != expected_source_pack_id + or identity.get("source_pack_sha256") != M48_EXPECTED_SOURCE_PACK_SHA256 + or not isinstance(frames, dict) + or frames.get("total") != M48_EXPECTED_FRAME_COUNT + or identity.get("authority") + != { + **_FALSE_AUTHORITY, + "physical_collision_accepted": False, + "ground_truth": False, + } + ): + raise M48RawEvidenceError("M4.8 threat result escaped the canonical source") + + +def _validate_e10_pack( + pack_root: Path, + *, + expected_pack_id: str, + expected_artifact_sha256: str, +) -> Path: + root = _strict_directory(pack_root, "E10 pack root") + if root.name != expected_pack_id: + raise M48RawEvidenceError("E10 pack path escaped its expected identity") + manifest_path = root / "manifest.json" + if ( + manifest_path.is_symlink() + or not manifest_path.is_file() + or manifest_path.resolve(strict=True).parent != root + ): + raise M48RawEvidenceError("E10 manifest path is invalid") + manifest = _read_json(manifest_path, "E10 manifest") + if set(manifest) != _MANIFEST_KEYS: + raise M48RawEvidenceError("E10 manifest fields changed") + identity = _mapping(manifest.get("identity"), "E10 identity") + artifact = _mapping(manifest.get("artifact"), "E10 artifact") + if set(identity) != _IDENTITY_KEYS or set(artifact) != _ARTIFACT_KEYS: + raise M48RawEvidenceError("E10 identity or artifact fields changed") + identity_sha256 = _canonical_sha256(identity) + if ( + manifest.get("schema_version") != _E10_SCHEMA + or manifest.get("pack_id") != expected_pack_id + or manifest.get("identity_sha256") != identity_sha256 + or expected_pack_id != f"e10-lidar-pack-{identity_sha256}" + or manifest.get("classification") != "private-recorded-sensor-replay-input" + or manifest.get("ground_truth") is not False + or identity.get("schema_version") != _E10_SCHEMA + or identity.get("source_id") != M48_EXPECTED_E10_SOURCE_ID + or identity.get("session_id") != M48_EXPECTED_SESSION_ID + or identity.get("frame_count") != M48_EXPECTED_FRAME_COUNT + or identity.get("source_start_frame_index") != 0 + or identity.get("source_end_frame_index") != M48_EXPECTED_FRAME_COUNT - 1 + or artifact.get("path") != _E10_ARTIFACT_NAME + or artifact.get("media_type") != "application/x-npz" + or artifact.get("sha256") != expected_artifact_sha256 + ): + raise M48RawEvidenceError("E10 pack identity changed") + byte_length = artifact.get("byte_length") + if not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 1: + raise M48RawEvidenceError("E10 artifact byte length is invalid") + artifact_path = root / _E10_ARTIFACT_NAME + if ( + artifact_path.is_symlink() + or not artifact_path.is_file() + or artifact_path.resolve(strict=True).parent != root + or artifact_path.stat().st_size != byte_length + or _file_sha256(artifact_path) != expected_artifact_sha256 + ): + raise M48RawEvidenceError("E10 artifact content changed") + return artifact_path.resolve(strict=True) + + +def _validate_m47_pack_binding( + *, + repository_root: Path, + pack: M48ObjectQualityPack, + threat_result: ThreatReplayResult, +) -> None: + identity = _mapping(pack.manifest.get("identity"), "M4.8 pack identity") + source = _mapping(identity.get("source"), "M4.8 pack source") + m47_id = source.get("m47_lab_result_id") + m47_manifest_sha256 = source.get("m47_lab_manifest_sha256") + if ( + source.get("source_id") != M48_EXPECTED_SOURCE_ID + or source.get("source_session_id") != M48_EXPECTED_SESSION_ID + or not isinstance(m47_id, str) + or not isinstance(m47_manifest_sha256, str) + ): + raise M48RawEvidenceError("M4.8 pack source binding changed") + m47_root = repository_root / ".runtime/compute-experiments/m47/reference-graph-labs" / m47_id + manifest_path = m47_root / "manifest.json" + if ( + manifest_path.is_symlink() + or not manifest_path.is_file() + or _file_sha256(manifest_path) != m47_manifest_sha256 + ): + raise M48RawEvidenceError("M4.8 pack M4.7 manifest binding changed") + try: + m47 = read_m47_reference_graph_lab(m47_root) + except (OSError, ValueError, M47ReferenceGraphLabError) as exc: + raise M48RawEvidenceError("M4.8 pack M4.7 LAB is invalid") from exc + m47_source = _mapping(m47.report.get("source"), "M4.7 source") + threat_identity = _mapping(threat_result.manifest.get("identity"), "M4 threat identity") + if ( + m47.manifest.get("accepted") is not True + or m47_source.get("source_id") != M48_EXPECTED_SOURCE_ID + or m47_source.get("source_session_id") != M48_EXPECTED_SESSION_ID + or m47_source.get("visual_result_id") != threat_result.result_id + or m47_source.get("threat_frames_sha256") != threat_identity.get("frames_sha256") + ): + raise M48RawEvidenceError("M4.8 pack escaped its accepted M4.7 visual source") + + +def _index_neutral_frame_references( + pack: M48ObjectQualityPack, +) -> dict[int, _PackFrameBinding]: + index: dict[int, _PackFrameBinding] = {} + for raw in pack.frame_references: + row = _mapping(raw, "M4.8 neutral frame reference") + sequence = row.get("sequence") + source_time_ns = row.get("source_time_ns") + clip_id = row.get("clip_id") + if ( + not isinstance(sequence, int) + or isinstance(sequence, bool) + or not 1 <= sequence <= M48_EXPECTED_FRAME_COUNT + or not isinstance(source_time_ns, int) + or isinstance(source_time_ns, bool) + or source_time_ns < 0 + or not isinstance(clip_id, str) + or not clip_id + or sequence in index + ): + raise M48RawEvidenceError("M4.8 neutral frame references are invalid") + index[sequence] = _PackFrameBinding( + clip_id=clip_id, + source_time_ns=source_time_ns, + ) + if not index: + raise M48RawEvidenceError("M4.8 neutral frame reference set is empty") + return index + + +def _strict_directory(path: Path, label: str) -> Path: + candidate = path.expanduser().absolute() + if candidate.is_symlink(): + raise M48RawEvidenceError(f"{label} must not be a symlink") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise M48RawEvidenceError(f"{label} is unavailable") from exc + if not resolved.is_dir(): + raise M48RawEvidenceError(f"{label} is not a directory") + return resolved + + +def _read_json(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise M48RawEvidenceError(f"{label} is invalid") from exc + if not isinstance(value, dict): + raise M48RawEvidenceError(f"{label} is invalid") + return value + + +def _mapping(value: object, label: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise M48RawEvidenceError(f"{label} is invalid") + return value + + +def _canonical_sha256(value: object) -> str: + return hashlib.sha256( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +__all__ = [ + "M48_EXPECTED_FRAME_COUNT", + "M48_EXPECTED_SESSION_ID", + "M48_EXPECTED_SOURCE_ID", + "M48_EXPECTED_SOURCE_PACK_ID", + "M48_EXPECTED_THREAT_RESULT_ID", + "M48_RAW_SPATIAL_FRAME_SCHEMA", + "M48RawEvidenceError", + "M48RawEvidenceReader", +] diff --git a/src/k1link/laboratory/m48_small_static_regression.py b/src/k1link/laboratory/m48_small_static_regression.py new file mode 100644 index 0000000..42b1849 --- /dev/null +++ b/src/k1link/laboratory/m48_small_static_regression.py @@ -0,0 +1,711 @@ +"""Immutable M4.8 development regression over operator-added missed-object anchors. + +The experiment deliberately stays inside M4.8 and reuses the frozen Worker 006 +prediction pack. It snapshots only operator-added tracklets from reviewed clips, +compares the exact source frame against the already-frozen prediction row, and +publishes a separate append-only result. The assisted correction is never called +independent truth and the result grants no navigation or safety authority. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final + +from k1link.laboratory.m48_object_quality import ( + M48ObjectQualityError, + read_m48_object_quality_pack, +) + +M48_SMALL_STATIC_PROFILE_SCHEMA: Final = ( + "missioncore.m48-small-static-passage-regression-profile/v1" +) +M48_SMALL_STATIC_RESULT_SCHEMA: Final = ( + "missioncore.m48-small-static-passage-regression-result/v1" +) +M48_SMALL_STATIC_REPORT_SCHEMA: Final = ( + "missioncore.m48-small-static-passage-regression-report/v1" +) +M48_SMALL_STATIC_ANCHOR_SCHEMA: Final = ( + "missioncore.m48-assisted-missed-object-anchor/v1" +) +M48_SMALL_STATIC_COMPARISON_SCHEMA: Final = ( + "missioncore.m48-assisted-anchor-comparison/v1" +) +M48_SMALL_STATIC_PREFIX: Final = "m48-small-static-passage-regression-" + +_CORRECTION_SCHEMA: Final = "missioncore.m48-assisted-object-correction-session/v1" +_METHOD_SCHEMA: Final = "missioncore.laboratory-method/v1" +_OBJECT_ID = re.compile(r"^object-[0-9]{2,}$") +_AUTHORITY: Final = { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, +} + + +class M48SmallStaticRegressionError(RuntimeError): + """The assisted development-regression source or result is invalid.""" + + +@dataclass(frozen=True, slots=True) +class M48SmallStaticRegressionResult: + result_id: str + result_root: Path + manifest: dict[str, Any] + report: dict[str, Any] + anchors: tuple[dict[str, Any], ...] + comparisons: tuple[dict[str, Any], ...] + + +def build_m48_small_static_passage_regression( + *, + pack_root: Path, + correction_session_path: Path, + profile_path: Path, + output_root: Path, + run_created_at_utc: str | None = None, +) -> M48SmallStaticRegressionResult: + """Publish one append-only M4.8R development baseline without mutating inputs.""" + + try: + pack = read_m48_object_quality_pack(pack_root) + except M48ObjectQualityError as exc: + raise M48SmallStaticRegressionError("M4.8 frozen prediction pack is invalid") from exc + profile_bytes, profile = _read_profile(profile_path) + correction_bytes, correction = _read_correction(correction_session_path, pack.result_id) + anchors = _assisted_anchors(correction) + if len(anchors) < int(profile["minimum_anchor_count"]): + raise M48SmallStaticRegressionError("M4.8 assisted anchor set is too small") + + prediction_rows: dict[tuple[str, int], dict[str, Any]] = {} + for row in pack.predictions: + clip_id = row.get("clip_id") + sequence = row.get("sequence") + if not isinstance(clip_id, str) or not _integer(sequence): + raise M48SmallStaticRegressionError("M4.8 frozen prediction binding is invalid") + key = (clip_id, int(sequence)) + if key in prediction_rows: + raise M48SmallStaticRegressionError("M4.8 frozen prediction binding collided") + prediction_rows[key] = row + + threshold = float(profile["extent_iou_threshold"]) + comparisons = tuple( + _compare_anchor(anchor, prediction_rows, threshold) + for anchor in anchors + ) + recalled = sum(bool(row["matched_at_threshold"]) for row in comparisons) + recall = recalled / len(comparisons) + passage_count = sum(bool(row["requires_avoidance_or_clearance"]) for row in anchors) + clip_count = len({str(row["clip_id"]) for row in anchors}) + target = float(profile["minimum_assisted_anchor_recall"]) + accepted = recall >= target + created_at = _utc_timestamp(run_created_at_utc or datetime.now(UTC).isoformat()) + correction_sha256 = hashlib.sha256(correction_bytes).hexdigest() + profile_sha256 = hashlib.sha256(profile_bytes).hexdigest() + producer_sha256 = _file_sha256(Path(__file__).resolve()) + pack_identity = _object(pack.manifest.get("identity"), "M4.8 pack identity") + freeze = _object(pack_identity.get("freeze"), "M4.8 pack freeze") + + identity: dict[str, Any] = { + "schema_version": M48_SMALL_STATIC_RESULT_SCHEMA, + "human_lab_id": profile["human_lab_id"], + "run_label": profile["run_label"], + "run_created_at_utc": created_at, + "pipeline_id": profile["pipeline_id"], + "experiment_id": profile["experiment_id"], + "profile_id": profile["profile_id"], + "profile_sha256": profile_sha256, + "producer_sha256": producer_sha256, + "source": { + "source_id": _object( + pack_identity.get("source"), "M4.8 source" + ).get("source_id"), + "source_session_id": _object( + pack_identity.get("source"), "M4.8 source" + ).get("source_session_id"), + "pack_id": pack.result_id, + "pack_identity_sha256": pack.manifest["identity_sha256"], + "prediction_rows_sha256": freeze.get("prediction_rows_sha256"), + "correction_session_id": correction["session_id"], + "correction_revision": correction["revision"], + "correction_updated_at_utc": correction["updated_at_utc"], + "correction_document_sha256": correction_sha256, + "correction_independent_truth": False, + }, + "selection": { + "anchor_selection": profile["anchor_selection"], + "assisted_tracklet_count": len({(row["clip_id"], row["object_id"]) for row in anchors}), + "anchor_count": len(anchors), + "clip_count": clip_count, + "requires_avoidance_or_clearance_count": passage_count, + }, + "authority": dict(_AUTHORITY), + } + identity_sha256 = _canonical_sha256(identity) + result_id = f"{M48_SMALL_STATIC_PREFIX}{identity_sha256}" + method = { + "schema_version": _METHOD_SCHEMA, + "completeness": "complete", + "execution_class": "deterministic", + "pipeline_id": profile["pipeline_id"], + "components": [ + { + "kind": "source", + "name": "M4.8 frozen Worker 006 predictions", + "version": pack.result_id, + "role": "immutable candidate rows from the current M4.8 pipeline", + "identity_sha256": freeze.get("prediction_rows_sha256"), + }, + { + "kind": "source", + "name": "operator-added missed-object anchors", + "version": f"{correction['session_id']}:revision-{correction['revision']}", + "role": "assisted development regression seed; not independent truth", + "identity_sha256": correction_sha256, + }, + { + "kind": "algorithm", + "name": "exact-frame class-free IoU comparator", + "version": profile["profile_id"], + "role": "diagnostic detection recall over operator-added anchors", + "identity_sha256": producer_sha256, + }, + ], + } + metrics = { + "assisted_anchor_count": len(comparisons), + "assisted_tracklet_count": len({(row["clip_id"], row["object_id"]) for row in anchors}), + "anchor_clip_count": clip_count, + "requires_avoidance_or_clearance_count": passage_count, + "worker_recalled_anchor_count": recalled, + "worker_missed_anchor_count": len(comparisons) - recalled, + "assisted_anchor_recall": recall, + "extent_iou_threshold": threshold, + "minimum_assisted_anchor_recall": target, + } + gates = { + "anchor_set_non_empty": len(comparisons) >= int(profile["minimum_anchor_count"]), + "development_anchor_recall_target": accepted, + "independent_truth_available": False, + } + decision = { + "state": ( + "accepted-development-regression-baseline" + if accepted + else "failed-development-regression-baseline" + ), + "summary": ( + f"Worker 006 matched {recalled}/{len(comparisons)} exact-frame assisted anchors " + f"at IoU >= {threshold:.2f}." + ), + "next_action": ( + "Keep the pipeline contract fixed, change only the perception experiment, " + "and publish another immutable M4.8R run against this frozen seed." + ), + } + limitations = [ + "The anchors come from candidate-visible operator correction and are not " + "independent truth.", + "The seed is intentionally biased toward objects the current Worker 006 output missed.", + "A camera rectangle is evidence of a missed visible object, not a measured 3D collider.", + "No physical-live, navigation, command, actuation or collision-safety " + "authority is granted.", + ] + report = { + "schema_version": M48_SMALL_STATIC_REPORT_SCHEMA, + "result_id": result_id, + "source": identity["source"], + "configuration": { + **profile, + "profile_sha256": profile_sha256, + }, + "method": method, + "execution": { + "comparison_node": "mission-core-local-control-plane", + "source_worker_id": "006", + "frozen_prediction_rows_sha256": freeze.get("prediction_rows_sha256"), + "determinism": "exact canonical JSON + exact-frame IoU; no inference rerun", + }, + "metrics": metrics, + "gates": gates, + "decision": decision, + "limitations": limitations, + "authority": dict(_AUTHORITY), + "visual_review": { + "viewer": "missioncore.laboratory-recorded-clip-viewer/v1", + "case_count": len(comparisons), + "camera_anchor_and_worker_boxes": True, + "camera_3d_plan_shared_clock": True, + }, + } + destination = output_root.expanduser().absolute() / result_id + _publish_result( + destination=destination, + identity=identity, + created_at_utc=created_at, + accepted=accepted, + report=report, + anchors=anchors, + comparisons=comparisons, + ) + return read_m48_small_static_passage_regression(destination) + + +def read_m48_small_static_passage_regression( + root: Path, +) -> M48SmallStaticRegressionResult: + candidate = root.expanduser().absolute() + if candidate.is_symlink(): + raise M48SmallStaticRegressionError("M4.8 regression result must not be a symlink") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise M48SmallStaticRegressionError("M4.8 regression result is unavailable") from exc + if not resolved.is_dir() or not resolved.name.startswith(M48_SMALL_STATIC_PREFIX): + raise M48SmallStaticRegressionError("M4.8 regression result path is invalid") + manifest = _read_json(resolved / "manifest.json", maximum=1024 * 1024) + identity = _object(manifest.get("identity"), "M4.8 regression identity") + identity_sha256 = _canonical_sha256(identity) + if ( + manifest.get("schema_version") != M48_SMALL_STATIC_RESULT_SCHEMA + or manifest.get("result_id") != resolved.name + or manifest.get("identity_sha256") != identity_sha256 + or resolved.name != f"{M48_SMALL_STATIC_PREFIX}{identity_sha256}" + or manifest.get("ground_truth") is not False + or manifest.get("authority") != _AUTHORITY + ): + raise M48SmallStaticRegressionError("M4.8 regression identity changed") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or len(artifacts) != 3: + raise M48SmallStaticRegressionError("M4.8 regression artifact inventory changed") + by_path: dict[str, dict[str, Any]] = {} + for raw in artifacts: + descriptor = _object(raw, "M4.8 regression artifact") + path_name = descriptor.get("path") + if not isinstance(path_name, str) or path_name not in { + "anchors.jsonl", "comparisons.jsonl", "report.json" + } or path_name in by_path: + raise M48SmallStaticRegressionError("M4.8 regression artifact path changed") + path = resolved / path_name + if ( + path.is_symlink() + or not path.is_file() + or descriptor.get("byte_length") != path.stat().st_size + or descriptor.get("sha256") != _file_sha256(path) + ): + raise M48SmallStaticRegressionError("M4.8 regression artifact proof changed") + by_path[path_name] = descriptor + report = _read_json(resolved / "report.json", maximum=1024 * 1024) + anchors = tuple(_read_jsonl(resolved / "anchors.jsonl")) + comparisons = tuple(_read_jsonl(resolved / "comparisons.jsonl")) + if ( + report.get("schema_version") != M48_SMALL_STATIC_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or len(anchors) != len(comparisons) + or any(row.get("schema_version") != M48_SMALL_STATIC_ANCHOR_SCHEMA for row in anchors) + or any( + row.get("schema_version") != M48_SMALL_STATIC_COMPARISON_SCHEMA + for row in comparisons + ) + or [row.get("anchor_id") for row in anchors] + != [row.get("anchor_id") for row in comparisons] + ): + raise M48SmallStaticRegressionError("M4.8 regression content changed") + return M48SmallStaticRegressionResult( + result_id=resolved.name, + result_root=resolved, + manifest=manifest, + report=report, + anchors=anchors, + comparisons=comparisons, + ) + + +def _read_profile(path: Path) -> tuple[bytes, dict[str, Any]]: + encoded, profile = _read_json_bytes(path, maximum=64 * 1024, label="M4.8 regression profile") + expected = { + "schema_version", + "profile_id", + "pipeline_id", + "experiment_id", + "human_lab_id", + "run_label", + "anchor_selection", + "extent_iou_threshold", + "minimum_assisted_anchor_recall", + "minimum_anchor_count", + "independent_truth", + } + if set(profile) != expected or profile.get("schema_version") != M48_SMALL_STATIC_PROFILE_SCHEMA: + raise M48SmallStaticRegressionError("M4.8 regression profile contract changed") + if ( + profile.get("human_lab_id") != "M4.8" + or profile.get("anchor_selection") != "operator-added-tracklets-in-reviewed-clips/v1" + or profile.get("independent_truth") is not False + or not _rate(profile.get("extent_iou_threshold")) + or not _rate(profile.get("minimum_assisted_anchor_recall")) + or not _integer(profile.get("minimum_anchor_count")) + or int(profile["minimum_anchor_count"]) < 1 + ): + raise M48SmallStaticRegressionError("M4.8 regression profile is invalid") + for key in ("profile_id", "pipeline_id", "experiment_id", "run_label"): + if not isinstance(profile.get(key), str) or not str(profile[key]).strip(): + raise M48SmallStaticRegressionError("M4.8 regression profile identity is invalid") + return encoded, profile + + +def _read_correction(path: Path, pack_id: str) -> tuple[bytes, dict[str, Any]]: + encoded, correction = _read_json_bytes( + path, + maximum=16 * 1024 * 1024, + label="M4.8 correction snapshot", + ) + assistance = _object(correction.get("assistance"), "M4.8 correction assistance") + if ( + correction.get("schema_version") != _CORRECTION_SCHEMA + or correction.get("pack_id") != pack_id + or correction.get("state") not in {"saved", "frozen"} + or not _integer(correction.get("revision")) + or int(correction["revision"]) < 1 + or not isinstance(correction.get("session_id"), str) + or not isinstance(correction.get("updated_at_utc"), str) + or assistance.get("candidate_predictions_seen") is not True + or assistance.get("independent_truth_eligible") is not False + or correction.get("authority") != _AUTHORITY + or not isinstance(correction.get("clips"), list) + ): + raise M48SmallStaticRegressionError("M4.8 correction snapshot is invalid") + return encoded, correction + + +def _assisted_anchors(correction: dict[str, Any]) -> tuple[dict[str, Any], ...]: + anchors: list[dict[str, Any]] = [] + for clip_raw in correction["clips"]: + clip = _object(clip_raw, "M4.8 correction clip") + if clip.get("review_state") != "reviewed": + continue + clip_id = clip.get("clip_id") + tracklets = clip.get("tracklets") + if not isinstance(clip_id, str) or not isinstance(tracklets, list): + raise M48SmallStaticRegressionError("M4.8 correction clip is invalid") + for tracklet_raw in tracklets: + tracklet = _object(tracklet_raw, "M4.8 correction tracklet") + object_id = tracklet.get("object_id") + if not isinstance(object_id, str) or _OBJECT_ID.fullmatch(object_id) is None: + continue + keyframes = tracklet.get("keyframes") + if not isinstance(keyframes, list) or not keyframes: + raise M48SmallStaticRegressionError("M4.8 assisted tracklet has no keyframes") + for keyframe_raw in keyframes: + keyframe = _object(keyframe_raw, "M4.8 correction keyframe") + sequence = keyframe.get("sequence") + extent = _extent(keyframe.get("extent_xyxy")) + if not _integer(sequence): + raise M48SmallStaticRegressionError("M4.8 assisted anchor sequence is invalid") + state = _state_for_sequence(tracklet, int(sequence)) + anchor_identity = { + "clip_id": clip_id, + "object_id": object_id, + "sequence": int(sequence), + "extent_xyxy": extent, + } + anchors.append({ + "schema_version": M48_SMALL_STATIC_ANCHOR_SCHEMA, + "anchor_id": "anchor-" + _canonical_sha256(anchor_identity)[:24], + **anchor_identity, + "visibility": keyframe.get("visibility"), + "geometry_association": state.get("geometry_association"), + "freshness": state.get("freshness"), + "motion": state.get("motion"), + "threat": state.get("threat"), + "requires_avoidance_or_clearance": bool( + state.get("critical_corridor_obstacle") + ), + "authority": "operator-assisted-development-anchor-not-truth", + }) + anchors.sort(key=lambda row: (str(row["clip_id"]), int(row["sequence"]), str(row["object_id"]))) + if len({str(row["anchor_id"]) for row in anchors}) != len(anchors): + raise M48SmallStaticRegressionError("M4.8 assisted anchor identity collided") + return tuple(anchors) + + +def _state_for_sequence(tracklet: dict[str, Any], sequence: int) -> dict[str, Any]: + segments = tracklet.get("state_segments") + if not isinstance(segments, list): + raise M48SmallStaticRegressionError("M4.8 assisted state segments are invalid") + matches = [ + _object(row, "M4.8 assisted state segment") + for row in segments + if isinstance(row, dict) + and _integer(row.get("start_sequence")) + and _integer(row.get("end_sequence")) + and int(row["start_sequence"]) <= sequence <= int(row["end_sequence"]) + ] + if len(matches) != 1: + raise M48SmallStaticRegressionError("M4.8 assisted anchor state is ambiguous") + return matches[0] + + +def _compare_anchor( + anchor: dict[str, Any], + prediction_rows: dict[tuple[str, int], dict[str, Any]], + threshold: float, +) -> dict[str, Any]: + key = (str(anchor["clip_id"]), int(anchor["sequence"])) + row = prediction_rows.get(key) + if row is None or row.get("terminal_outcome") != "delivered": + raise M48SmallStaticRegressionError("M4.8 assisted anchor lacks delivered prediction row") + objects = row.get("objects") + if not isinstance(objects, list): + raise M48SmallStaticRegressionError("M4.8 prediction objects are invalid") + normalized: list[dict[str, Any]] = [] + for raw in objects: + item = _object(raw, "M4.8 prediction object") + normalized.append({ + "prediction_id": item.get("prediction_id"), + "extent_xyxy": _extent(item.get("extent_xyxy")), + "geometry_association": item.get("geometry_association"), + "freshness": item.get("freshness"), + "motion": item.get("motion"), + "threat": item.get("threat"), + }) + ranked = sorted( + ((_iou(anchor["extent_xyxy"], item["extent_xyxy"]), item) for item in normalized), + key=lambda pair: (pair[0], str(pair[1].get("prediction_id"))), + reverse=True, + ) + best_iou, best = ranked[0] if ranked else (0.0, None) + return { + "schema_version": M48_SMALL_STATIC_COMPARISON_SCHEMA, + "anchor_id": anchor["anchor_id"], + "clip_id": anchor["clip_id"], + "sequence": anchor["sequence"], + "source_time_ns": row.get("source_time_ns"), + "anchor_extent_xyxy": anchor["extent_xyxy"], + "requires_avoidance_or_clearance": anchor["requires_avoidance_or_clearance"], + "worker_candidate_count": len(normalized), + "worker_objects": normalized, + "best_prediction_id": best.get("prediction_id") if best else None, + "best_iou": best_iou, + "extent_iou_threshold": threshold, + "matched_at_threshold": best_iou >= threshold, + "outcome": "recalled" if best_iou >= threshold else "missed-assisted-anchor", + } + + +def _publish_result( + *, + destination: Path, + identity: dict[str, Any], + created_at_utc: str, + accepted: bool, + report: dict[str, Any], + anchors: tuple[dict[str, Any], ...], + comparisons: tuple[dict[str, Any], ...], +) -> None: + parent = destination.parent + if parent.is_symlink(): + raise M48SmallStaticRegressionError("M4.8 regression output root must not be a symlink") + parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if not parent.is_dir(): + raise M48SmallStaticRegressionError("M4.8 regression output root is invalid") + staging = parent / f".{destination.name}.{uuid.uuid4().hex}.tmp" + staging.mkdir(mode=0o700, exist_ok=False) + try: + _write_json(staging / "report.json", report) + _write_jsonl(staging / "anchors.jsonl", anchors) + _write_jsonl(staging / "comparisons.jsonl", comparisons) + artifacts = [ + _artifact( + staging / "anchors.jsonl", + "assisted-regression-anchors", + M48_SMALL_STATIC_ANCHOR_SCHEMA, + ), + _artifact( + staging / "comparisons.jsonl", + "exact-frame-worker-comparisons", + M48_SMALL_STATIC_COMPARISON_SCHEMA, + ), + _artifact( + staging / "report.json", + "m48-small-static-regression-report", + M48_SMALL_STATIC_REPORT_SCHEMA, + ), + ] + manifest = { + "schema_version": M48_SMALL_STATIC_RESULT_SCHEMA, + "result_id": destination.name, + "identity_sha256": _canonical_sha256(identity), + "identity": identity, + "created_at_utc": created_at_utc, + "accepted": accepted, + "ground_truth": False, + "authority": dict(_AUTHORITY), + "artifacts": artifacts, + } + _write_json(staging / "manifest.json", manifest) + if destination.exists(): + existing = { + path.name: _file_sha256(path) + for path in destination.iterdir() + if path.is_file() + } + proposed = { + path.name: _file_sha256(path) + for path in staging.iterdir() + if path.is_file() + } + if existing != proposed: + raise M48SmallStaticRegressionError("immutable M4.8 regression identity collided") + shutil.rmtree(staging) + return + os.replace(staging, destination) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def _artifact(path: Path, role: str, schema_version: str) -> dict[str, object]: + return { + "path": path.name, + "role": role, + "byte_length": path.stat().st_size, + "sha256": _file_sha256(path), + "schema_version": schema_version, + "media_type": "application/x-ndjson" if path.suffix == ".jsonl" else "application/json", + } + + +def _read_json_bytes(path: Path, *, maximum: int, label: str) -> tuple[bytes, dict[str, Any]]: + candidate = path.expanduser().absolute() + if candidate.is_symlink() or not candidate.is_file() or candidate.stat().st_size > maximum: + raise M48SmallStaticRegressionError(f"{label} is unavailable") + try: + encoded = candidate.read_bytes() + value = json.loads(encoded) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise M48SmallStaticRegressionError(f"{label} is unreadable") from exc + return encoded, _object(value, label) + + +def _read_json(path: Path, *, maximum: int) -> dict[str, Any]: + return _read_json_bytes(path, maximum=maximum, label=path.name)[1] + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 8 * 1024 * 1024: + raise M48SmallStaticRegressionError("M4.8 regression rows are unavailable") + rows: list[dict[str, Any]] = [] + try: + with path.open("r", encoding="utf-8") as stream: + for line in stream: + if line.strip(): + rows.append(_object(json.loads(line), "M4.8 regression row")) + except (OSError, json.JSONDecodeError) as exc: + raise M48SmallStaticRegressionError("M4.8 regression rows are unreadable") from exc + return rows + + +def _write_json(path: Path, value: object) -> None: + path.write_bytes(_canonical_json(value) + b"\n") + + +def _write_jsonl(path: Path, rows: tuple[dict[str, Any], ...]) -> None: + path.write_bytes(b"".join(_canonical_json(row) + b"\n" for row in rows)) + + +def _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise M48SmallStaticRegressionError(f"{label} must be an object") + return value + + +def _integer(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _rate(value: object) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and 0.0 < float(value) <= 1.0 + ) + + +def _extent(value: object) -> list[float]: + if ( + not isinstance(value, list) + or len(value) != 4 + or any(not isinstance(item, (int, float)) or isinstance(item, bool) for item in value) + ): + raise M48SmallStaticRegressionError("M4.8 extent is invalid") + extent = [float(item) for item in value] + if not (0.0 <= extent[0] < extent[2] <= 1.0 and 0.0 <= extent[1] < extent[3] <= 1.0): + raise M48SmallStaticRegressionError("M4.8 extent is outside the camera plane") + return extent + + +def _iou(left: list[float], right: list[float]) -> float: + x1 = max(left[0], right[0]) + y1 = max(left[1], right[1]) + x2 = min(left[2], right[2]) + y2 = min(left[3], right[3]) + intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1) + left_area = (left[2] - left[0]) * (left[3] - left[1]) + right_area = (right[2] - right[0]) * (right[3] - right[1]) + union = left_area + right_area - intersection + return intersection / union if union > 0.0 else 0.0 + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +def _canonical_sha256(value: object) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _utc_timestamp(value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise M48SmallStaticRegressionError("M4.8 run creation time is invalid") + text = value.strip() + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise M48SmallStaticRegressionError("M4.8 run creation time is invalid") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise M48SmallStaticRegressionError("M4.8 run creation time must be UTC") + return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +__all__ = [ + "M48_SMALL_STATIC_RESULT_SCHEMA", + "M48SmallStaticRegressionError", + "M48SmallStaticRegressionResult", + "build_m48_small_static_passage_regression", + "read_m48_small_static_passage_regression", +] diff --git a/src/k1link/web/advanced_laboratory_api.py b/src/k1link/web/advanced_laboratory_api.py index 249e3e4..345d841 100644 --- a/src/k1link/web/advanced_laboratory_api.py +++ b/src/k1link/web/advanced_laboratory_api.py @@ -56,6 +56,7 @@ from k1link.compute.e40_perception_product_gate import ( read_e40_perception_product_gate, ) from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry +from k1link.laboratory.evidence_registry import LaboratoryEvidenceVariant from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity from k1link.web.l31_pointpillars_ravnoves_api import latest_l31_identity from k1link.web.l32_pointpillars_camera_review_api import latest_l32_identity @@ -259,7 +260,10 @@ def _advanced_index( specs: tuple[_AdvancedIndexSpec, ...], ) -> dict[str, object]: items: list[dict[str, object]] = [] + selected_work_ids: set[str] = set() for work_id, provider, pattern, document_name, schema_version in specs: + if work_id in selected_work_ids: + continue root = _configured_root(provider) if root is None: continue @@ -273,6 +277,7 @@ def _advanced_index( schema_version=schema_version, ) ) + selected_work_ids.add(work_id) break except (json.JSONDecodeError, OSError, TypeError, ValueError): continue @@ -290,17 +295,18 @@ def _registry_index_specs( return tuple( ( definition.work_id, - _evidence_root_provider(definition, runtime_root_provider), - definition.result_id_pattern, - definition.document_name, - definition.result_schema_version, + _evidence_root_provider(variant, runtime_root_provider), + variant.result_id_pattern, + variant.document_name, + variant.result_schema_version, ) for definition in registry.definitions + for variant in reversed(definition.evidence_variants) ) def _evidence_root_provider( - definition: LaboratoryEvidenceDefinition, + definition: LaboratoryEvidenceDefinition | LaboratoryEvidenceVariant, runtime_root_provider: RootProvider, ) -> RootProvider: def result_root_provider() -> Path | None: diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 19c347c..d6e4049 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -23,15 +23,23 @@ from k1link.compute import ( RecordedPerceptionOverlayMux, RecordedPerceptionOverlayStore, ) +from k1link.compute.pipeline_telemetry import JsonlPipelineTelemetrySink from k1link.laboratory import ( LaboratoryEvidenceRegistry, LaboratoryEvidenceReportService, LaboratoryExecutionRegistry, + LaboratoryRunner, LaboratoryValueReviewRegistry, ) +from k1link.laboratory.m48_raw_evidence import ( + M48_EXPECTED_THREAT_RESULT_ID, + M48RawEvidenceError, + M48RawEvidenceReader, +) from k1link.sessions import ( MaterializedRecording, RecordedCameraFrameService, + RecordedCameraPlaybackSource, RecordedMediaInspector, RecordedMediaManifest, RecordingPreparationQueueFull, @@ -116,6 +124,7 @@ from k1link.web.laboratory_report_api import build_laboratory_report_router from k1link.web.lidar_api import build_lidar_router from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router +from k1link.web.m48_object_quality_api import build_m48_object_quality_router from k1link.web.map_api import ( MapGatewayConfiguration, MapGatewayProxy, @@ -151,6 +160,13 @@ LABORATORY_EXECUTION_REGISTRY = LaboratoryExecutionRegistry.from_file( REPOSITORY_ROOT / "config" / "laboratory-execution.json", LABORATORY_EVIDENCE_REGISTRY, ) +LABORATORY_RUNNER = LaboratoryRunner( + registry=LABORATORY_EXECUTION_REGISTRY, + evidence_registry=LABORATORY_EVIDENCE_REGISTRY, + sink=JsonlPipelineTelemetrySink( + REPOSITORY_ROOT / ".runtime" / "telemetry" / "laboratory-runs.jsonl" + ), +) LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file( REPOSITORY_ROOT / "config" / "laboratory-value-review.json" ) @@ -204,6 +220,20 @@ session_recorded_camera_frame_service = ( if _ffmpeg is not None else None ) +try: + m48_raw_evidence_reader: M48RawEvidenceReader | None = M48RawEvidenceReader.from_repository( + repository_root=REPOSITORY_ROOT, + threat_result_root=( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "m4" + / "replay-threat" + / M48_EXPECTED_THREAT_RESULT_ID + ), + ) +except (M48RawEvidenceError, OSError, ValueError): + m48_raw_evidence_reader = None session_legacy_perception_overlay_store = ( RecordedPerceptionOverlayStore( jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs", @@ -295,6 +325,20 @@ session_recording_preparation_manager = SessionRecordingPreparationManager( ) +def _m48_recorded_camera_playback_source( + session_id: str, +) -> RecordedCameraPlaybackSource: + """Publish the durable replay package before exposing its manifest URL.""" + + if session_recorded_camera_frame_service is None: + raise RuntimeError("recorded camera playback is unavailable") + command = session_store.prepare_replay(session_id, speed=1.0, loop=False) + snapshot = session_recording_preparation_manager.restore_published(command) + if snapshot is None or snapshot.state != "ready" or snapshot.recorded_media is None: + raise RuntimeError("recorded camera playback package is not published") + return session_recorded_camera_frame_service.playback_source(session_id) + + def refresh_observation_catalog() -> tuple[str, ...]: """Discover completed or recoverable local evidence without copying payloads.""" @@ -859,6 +903,47 @@ app.include_router( ), ) ) +app.include_router( + build_m48_object_quality_router( + pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m48" / "object-quality-packs" + ), + workflow_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "laboratory-annotations" / "m48-object-quality" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m48" / "object-truth-seals" + ), + result_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m48" / "object-quality-results" + ), + small_static_result_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "m48" + / "small-static-passage-regression-results" + ), + camera_frame_provider=( + session_recorded_camera_frame_service.extract + if session_recorded_camera_frame_service is not None + else None + ), + camera_playback_provider=( + _m48_recorded_camera_playback_source + if session_recorded_camera_frame_service is not None + else None + ), + spatial_evidence_provider=m48_raw_evidence_reader, + evaluation_runner=LABORATORY_RUNNER, + evaluation_receipt_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "laboratory-run-receipts" + ), + ) +) app.include_router( build_e47_semantic_slam_router( root_provider=lambda: ( diff --git a/src/k1link/web/m48_object_quality_api.py b/src/k1link/web/m48_object_quality_api.py new file mode 100644 index 0000000..ae7e3b6 --- /dev/null +++ b/src/k1link/web/m48_object_quality_api.py @@ -0,0 +1,2777 @@ +"""M4.8 blind evaluation and candidate-assisted correction workflow API.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import re +import secrets +import tempfile +import threading +from collections.abc import Callable, Mapping +from datetime import UTC, datetime +from functools import lru_cache +from pathlib import Path +from typing import Annotated, Any, Final, Literal +from urllib.parse import quote + +from fastapi import APIRouter, Header, HTTPException, Query, Request, Response +from fastapi import Path as ApiPath +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from pydantic import BaseModel, ConfigDict, Field + +from k1link.artifacts import write_json_atomic +from k1link.laboratory import ( + LaboratoryExecutionError, + LaboratoryRunner, + LaboratoryRunRequest, +) +from k1link.laboratory.m48_object_quality import ( + M48_ADJUDICATION_SCHEMA, + M48_MANIFEST_NAME, + M48_REVIEW_SCHEMA, + M48_REVIEWER_PACKAGE_NAME, + M48ObjectQualityError, + M48ObjectQualityPack, + M48ObjectQualityResult, + M48ObjectTruthSeal, + build_m48_object_truth_seal, + read_m48_object_quality_pack, + read_m48_object_quality_result, + read_m48_object_truth_seal, + validate_m48_review_submission, +) +from k1link.laboratory.m48_small_static_regression import ( + M48SmallStaticRegressionError, + M48SmallStaticRegressionResult, + read_m48_small_static_passage_regression, +) +from k1link.sessions import RecordedCameraPlaybackSource + +_PACK_ID = re.compile(r"^m48-object-quality-pack-[a-f0-9]{64}$") +_TRUTH_ID = re.compile(r"^m48-object-truth-seal-[a-f0-9]{64}$") +_RESULT_ID = re.compile(r"^m48-object-quality-result-[a-f0-9]{64}$") +_SMALL_STATIC_RESULT_ID = re.compile( + r"^m48-small-static-passage-regression-[a-f0-9]{64}$" +) +_SMALL_STATIC_ANCHOR_ID = re.compile(r"^anchor-[a-f0-9]{24}$") +_FAILURE_ID = re.compile(r"^m48-failure-[a-f0-9]{64}$") +_REVIEW_SESSION_ID = re.compile(r"^m48-review-session-[a-f0-9]{64}$") +_CORRECTION_SESSION_ID = re.compile(r"^m48-correction-session-[a-f0-9]{64}$") +_ADJUDICATION_SESSION_ID = re.compile(r"^m48-adjudication-session-[a-f0-9]{64}$") +_ACTOR_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,95}$") +_OPERATION_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") + +_REVIEW_SESSION_SCHEMA: Final = "missioncore.m48-object-review-session/v1" +_CORRECTION_SESSION_SCHEMA: Final = "missioncore.m48-assisted-object-correction-session/v1" +_CORRECTION_ARTIFACT_SCHEMA: Final = "missioncore.m48-assisted-object-correction/v1" +_ADJUDICATION_SESSION_SCHEMA: Final = "missioncore.m48-object-adjudication-session/v1" +_REVIEW_CAPABILITY_SCHEMA: Final = "missioncore.m48-object-review-capability/v1" +_CORRECTION_CAPABILITY_SCHEMA: Final = "missioncore.m48-object-correction-capability/v1" +_ADJUDICATION_CAPABILITY_SCHEMA: Final = "missioncore.m48-object-adjudication-capability/v1" +_SOURCE_SCHEMA: Final = "missioncore.m48-neutral-object-review-source/v2" +_CAMERA_PLAYBACK_SCHEMA: Final = "missioncore.laboratory-recorded-clip-camera/v1" +# The replay graph clock is frozen in integer nanoseconds while fMP4 segment +# boundaries are represented in the media timescale and projected through a +# float manifest. Admit only sub-microsecond representation drift; fragment +# identity itself remains an exact SHA-256 match. +_CAMERA_SOURCE_TIME_TOLERANCE_NS: Final = 1_000 +_SPATIAL_FRAME_SCHEMA: Final = "missioncore.m48-neutral-object-review-spatial-frame/v1" +_PACK_STATUS_SCHEMA: Final = "missioncore.m48-object-quality-pack-status/v1" +_PACK_CATALOG_SCHEMA: Final = "missioncore.m48-object-quality-pack-catalog/v1" +_RESULT_VIEW_SCHEMA: Final = "missioncore.m48-object-centric-quality-result-view/v1" +_SMALL_STATIC_RESULT_VIEW_SCHEMA: Final = ( + "missioncore.m48-small-static-passage-regression-result-view/v1" +) +_SMALL_STATIC_CASE_CATALOG_SCHEMA: Final = ( + "missioncore.m48-small-static-passage-regression-case-catalog/v1" +) +_SMALL_STATIC_CASE_VIEW_SCHEMA: Final = ( + "missioncore.m48-small-static-passage-regression-case-view/v1" +) +_FAILURE_ATLAS_VIEW_SCHEMA: Final = "missioncore.m48-object-quality-failure-atlas-view/v1" +_FAILURE_CASE_VIEW_SCHEMA: Final = "missioncore.m48-object-quality-failure-case-view/v1" +_REVIEW_CAPABILITY_HEADER: Final = "X-M48-Review-Capability" +_CORRECTION_CAPABILITY_HEADER: Final = "X-M48-Correction-Capability" +_ADJUDICATION_CAPABILITY_HEADER: Final = "X-M48-Adjudication-Capability" +_VARY: Final = ( + f"{_REVIEW_CAPABILITY_HEADER}, {_CORRECTION_CAPABILITY_HEADER}, " + f"{_ADJUDICATION_CAPABILITY_HEADER}" +) + +_BLINDNESS: Final = { + "candidate_identity_seen": False, + "model_predictions_seen": False, + "model_scores_seen": False, + "semantic_class_task_seen": False, +} +_AUTHORITY: Final = { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, +} + +RootProvider = Callable[[], Path | None] +CameraFrameProvider = Callable[..., Any] +CameraPlaybackProvider = Callable[[str], RecordedCameraPlaybackSource] +SpatialEvidenceProvider = Callable[[M48ObjectQualityPack, int], Mapping[str, Any]] + + +class M48CreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + idempotency_key: str = Field(min_length=1, max_length=128) + + +class M48TrackletKeyframeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + sequence: int = Field(ge=1) + extent_xyxy: list[float] = Field(min_length=4, max_length=4) + visibility: Literal["visible", "partial", "occluded"] + + +class M48TrackletStateSegmentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + start_sequence: int = Field(ge=1) + end_sequence: int = Field(ge=1) + geometry_association: Literal["associated", "unavailable", "ineligible", "unknown"] + freshness: Literal["current", "held", "stale", "unavailable"] + motion: Literal["moving", "static", "unknown", "unsupported"] + threat: Literal["threat", "not-threat", "unknown"] + critical_corridor_obstacle: bool + + +class M48TrackletRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + object_id: str = Field(min_length=2, max_length=96) + first_sequence: int = Field(ge=1) + last_sequence: int = Field(ge=1) + keyframes: list[M48TrackletKeyframeRequest] = Field(min_length=1, max_length=4096) + state_segments: list[M48TrackletStateSegmentRequest] = Field( + min_length=1, + max_length=4096, + ) + notes: str | None = Field(default=None, max_length=1000) + + +class M48ClipReviewRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + clip_id: str = Field(min_length=2, max_length=96) + start_sequence: int = Field(ge=1) + end_sequence: int = Field(ge=1) + review_state: Literal["pending", "reviewed", "adjudicated"] + no_object: bool | None + tracklets: list[M48TrackletRequest] = Field(max_length=1024) + notes: str | None = Field(default=None, max_length=2000) + + +class M48SaveRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=0) + idempotency_key: str = Field(min_length=1, max_length=128) + title: str = Field(min_length=1, max_length=160) + clips: list[M48ClipReviewRequest] = Field(min_length=1, max_length=64) + + +class M48ReviewFreezeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + reviewer_id: str = Field(min_length=2, max_length=96) + independent_attestation: Literal[True] + candidate_identity_not_seen: Literal[True] + model_predictions_not_seen: Literal[True] + semantic_class_task_not_seen: Literal[True] + + +class M48CorrectionFreezeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + reviewer_id: str = Field(min_length=2, max_length=96) + all_clips_corrected: Literal[True] + candidate_predictions_seen: Literal[True] + semantic_class_task_not_seen: Literal[True] + + +class M48AdjudicationFreezeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + adjudicator_id: str = Field(min_length=2, max_length=96) + all_disagreements_resolved: Literal[True] + model_predictions_not_seen: Literal[True] + + +class M48EvaluateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + idempotency_key: str = Field(min_length=1, max_length=128) + + +class _NoStoreCapabilityRoute(APIRoute): + def get_route_handler(self) -> Callable[[Request], Any]: + original = super().get_route_handler() + + async def handler(request: Request) -> Response: + try: + response = await original(request) + except HTTPException as exc: + response = JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=exc.headers, + ) + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["Vary"] = _VARY + return response + + return handler + + +class _WorkflowStore: + def __init__(self, provider: RootProvider) -> None: + self.provider = provider + self.lock = threading.Lock() + + def pack_root(self, pack_id: str, *, create: bool) -> Path | None: + _validate_pack_id(pack_id) + value = self.provider() + if value is None: + if create: + raise HTTPException( + status_code=503, detail="M4.8 workflow storage is not configured" + ) + return None + root = value.expanduser().absolute() + if root.is_symlink(): + raise HTTPException(status_code=503, detail="M4.8 workflow storage is unavailable") + if create: + root.mkdir(mode=0o700, parents=True, exist_ok=True) + if not root.is_dir(): + if create: + raise HTTPException(status_code=503, detail="M4.8 workflow storage is unavailable") + return None + root = root.resolve(strict=True) + child = root / pack_id + if create: + child.mkdir(mode=0o700, exist_ok=True) + if not child.is_dir() or child.is_symlink(): + return None + resolved = child.resolve(strict=True) + if resolved.parent != root: + raise HTTPException(status_code=503, detail="M4.8 workflow storage is unavailable") + return resolved + + def review_path(self, pack_id: str, session_id: str, *, create: bool) -> Path: + if _REVIEW_SESSION_ID.fullmatch(session_id) is None: + raise HTTPException(status_code=404, detail="M4.8 review slot was not found") + root = self.pack_root(pack_id, create=create) + if root is None: + raise HTTPException(status_code=404, detail="M4.8 review slot was not found") + return root / f"{session_id}.json" + + def correction_path(self, pack_id: str, session_id: str, *, create: bool) -> Path: + if _CORRECTION_SESSION_ID.fullmatch(session_id) is None: + raise HTTPException(status_code=404, detail="M4.8 correction session was not found") + root = self.pack_root(pack_id, create=create) + if root is None: + raise HTTPException(status_code=404, detail="M4.8 correction session was not found") + return root / f"{session_id}.json" + + def adjudication_path(self, pack_id: str, session_id: str, *, create: bool) -> Path: + if _ADJUDICATION_SESSION_ID.fullmatch(session_id) is None: + raise HTTPException(status_code=404, detail="M4.8 adjudication was not found") + root = self.pack_root(pack_id, create=create) + if root is None: + raise HTTPException(status_code=404, detail="M4.8 adjudication was not found") + return root / f"{session_id}.json" + + def reviews(self, pack_id: str) -> list[dict[str, Any]]: + root = self.pack_root(pack_id, create=False) + if root is None: + return [] + rows: list[dict[str, Any]] = [] + for path in root.glob("m48-review-session-*.json"): + if path.is_symlink() or _REVIEW_SESSION_ID.fullmatch(path.stem) is None: + continue + try: + rows.append(self._read_review(path, pack_id)) + except (OSError, ValueError): + continue + rows.sort(key=lambda row: int(row["reviewer_slot"])) + return rows + + def corrections(self, pack_id: str) -> list[dict[str, Any]]: + root = self.pack_root(pack_id, create=False) + if root is None: + return [] + rows: list[dict[str, Any]] = [] + for path in root.glob("m48-correction-session-*.json"): + if path.is_symlink() or _CORRECTION_SESSION_ID.fullmatch(path.stem) is None: + continue + try: + rows.append(self._read_correction(path, pack_id)) + except (OSError, ValueError): + continue + rows.sort(key=lambda row: str(row["created_at_utc"])) + return rows + + def adjudications(self, pack_id: str) -> list[dict[str, Any]]: + root = self.pack_root(pack_id, create=False) + if root is None: + return [] + rows: list[dict[str, Any]] = [] + for path in root.glob("m48-adjudication-session-*.json"): + if path.is_symlink() or _ADJUDICATION_SESSION_ID.fullmatch(path.stem) is None: + continue + try: + rows.append(self._read_adjudication(path, pack_id)) + except (OSError, ValueError): + continue + rows.sort(key=lambda row: str(row["created_at_utc"])) + return rows + + def read_review(self, pack_id: str, session_id: str) -> dict[str, Any]: + path = self.review_path(pack_id, session_id, create=False) + if not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="M4.8 review slot was not found") + try: + return self._read_review(path, pack_id) + except (OSError, ValueError) as exc: + raise HTTPException(status_code=409, detail="M4.8 review slot is damaged") from exc + + def read_correction(self, pack_id: str, session_id: str) -> dict[str, Any]: + path = self.correction_path(pack_id, session_id, create=False) + if not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="M4.8 correction session was not found") + try: + return self._read_correction(path, pack_id) + except (OSError, ValueError) as exc: + raise HTTPException( + status_code=409, + detail="M4.8 correction session is damaged", + ) from exc + + def read_adjudication(self, pack_id: str, session_id: str) -> dict[str, Any]: + path = self.adjudication_path(pack_id, session_id, create=False) + if not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="M4.8 adjudication was not found") + try: + return self._read_adjudication(path, pack_id) + except (OSError, ValueError) as exc: + raise HTTPException(status_code=409, detail="M4.8 adjudication is damaged") from exc + + @staticmethod + def _read_review(path: Path, pack_id: str) -> dict[str, Any]: + value = _read_json(path) + if ( + value.get("schema_version") != _REVIEW_SESSION_SCHEMA + or value.get("pack_id") != pack_id + or value.get("session_id") != path.stem + or value.get("blindness") != _BLINDNESS + or value.get("authority") != _AUTHORITY + or value.get("state") not in {"draft", "saved", "frozen"} + or not isinstance(value.get("revision"), int) + or not isinstance(value.get("reviewer_slot"), int) + or not isinstance(value.get("clips"), list) + ): + raise ValueError("invalid M4.8 review session") + return value + + @staticmethod + def _read_correction(path: Path, pack_id: str) -> dict[str, Any]: + value = _read_json(path) + assistance = value.get("assistance") + if ( + value.get("schema_version") != _CORRECTION_SESSION_SCHEMA + or value.get("pack_id") != pack_id + or value.get("session_id") != path.stem + or value.get("authority") != _AUTHORITY + or value.get("state") not in {"draft", "saved", "frozen"} + or not isinstance(value.get("revision"), int) + or not isinstance(value.get("clips"), list) + or not isinstance(assistance, dict) + or assistance.get("mode") != "frozen-candidate-seeded" + or assistance.get("candidate_predictions_seen") is not True + or assistance.get("model_scores_seen") is not False + or assistance.get("semantic_class_task_seen") is not False + or assistance.get("independent_truth_eligible") is not False + ): + raise ValueError("invalid M4.8 correction session") + return value + + @staticmethod + def _read_adjudication(path: Path, pack_id: str) -> dict[str, Any]: + value = _read_json(path) + if ( + value.get("schema_version") != _ADJUDICATION_SESSION_SCHEMA + or value.get("pack_id") != pack_id + or value.get("session_id") != path.stem + or value.get("blindness") != _BLINDNESS + or value.get("authority") != _AUTHORITY + or value.get("state") not in {"draft", "saved", "adjudication-frozen", "evaluated"} + or not isinstance(value.get("revision"), int) + or not isinstance(value.get("clips"), list) + ): + raise ValueError("invalid M4.8 adjudication session") + return value + + +def build_m48_object_quality_router( + *, + pack_root_provider: RootProvider = lambda: None, + workflow_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + result_root_provider: RootProvider = lambda: None, + small_static_result_root_provider: RootProvider = lambda: None, + camera_frame_provider: CameraFrameProvider | None = None, + camera_playback_provider: CameraPlaybackProvider | None = None, + spatial_evidence_provider: SpatialEvidenceProvider | None = None, + evaluation_runner: LaboratoryRunner | None = None, + evaluation_receipt_root_provider: RootProvider = lambda: None, +) -> APIRouter: + """Build the prediction-blind M4.8 pack workflow router.""" + + router = APIRouter( + prefix="/api/v1/laboratory/m48", + tags=["laboratory"], + route_class=_NoStoreCapabilityRoute, + ) + store = _WorkflowStore(workflow_root_provider) + + @lru_cache(maxsize=32) + def neutral_pack_index( + pack_id: str, + ) -> tuple[ + M48ObjectQualityPack, + tuple[dict[str, Any], ...], + dict[tuple[str, int], dict[str, Any]], + ]: + """Validate one content-addressed neutral pack once per process generation.""" + + pack = _resolve_pack(pack_root_provider, pack_id) + clips = tuple(_neutral_source_clips(pack)) + frames = { + (str(clip["clip_id"]), int(frame["sequence"])): frame + for clip in clips + for frame in clip["frames"] + } + if len(frames) != sum(len(clip["frames"]) for clip in clips): + raise HTTPException(status_code=409, detail="M4.8 neutral source identity collided") + return pack, clips, frames + + @lru_cache(maxsize=32) + def camera_playback_projection(pack_id: str) -> dict[str, object] | None: + if camera_playback_provider is None: + return None + pack, clips, _frames = neutral_pack_index(pack_id) + return _camera_playback_projection( + pack, + clips, + provider=camera_playback_provider, + ) + + @lru_cache(maxsize=256) + def spatial_evidence_projection( + pack_id: str, + clip_id: str, + sequence: int, + ) -> dict[str, object]: + """Project one immutable spatial frame once for bounded replay reuse.""" + + if spatial_evidence_provider is None: + raise HTTPException( + status_code=503, + detail="M4.8 prediction-free spatial evidence is unavailable", + ) + pack, _clips, frames = neutral_pack_index(pack_id) + frame = frames.get((clip_id, sequence)) + if frame is None: + raise HTTPException(status_code=404, detail="M4.8 source frame was not found") + raw = spatial_evidence_provider(pack, sequence) + return _sanitize_spatial_evidence( + raw, + pack=pack, + clip_id=clip_id, + frame=frame, + ) + + @router.get("/packs") + def list_packs(limit: int = Query(default=10, ge=1, le=50)) -> dict[str, object]: + candidates = _pack_candidates(pack_root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + pack = _resolve_pack(pack_root_provider, path.name) + if len(items) < limit: + items.append(_pack_projection(pack, store)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": _PACK_CATALOG_SCHEMA, + "configured": _configured_root(pack_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "frozen_predictions_included": False, + "strata_included": False, + "access": "neutral-workflow-status-read-only", + } + + @router.get("/packs/{pack_id}") + def get_pack_status( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + ) -> dict[str, object]: + return _pack_projection(_resolve_pack(pack_root_provider, pack_id), store) + + @router.get("/packs/{pack_id}/source") + def get_source( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + ) -> dict[str, object]: + pack, cached_clips, _frames = neutral_pack_index(pack_id) + clips = copy.deepcopy(cached_clips) + for clip in clips: + for frame in clip["frames"]: + frame["camera_url"] = ( + f"/api/v1/laboratory/m48/packs/{pack_id}/source/clips/" + f"{clip['clip_id']}/frames/{frame['sequence']}/camera" + if camera_frame_provider is not None + else None + ) + frame["spatial_url"] = ( + f"/api/v1/laboratory/m48/packs/{pack_id}/source/clips/" + f"{clip['clip_id']}/frames/{frame['sequence']}/spatial" + if spatial_evidence_provider is not None + else None + ) + return { + "schema_version": _SOURCE_SCHEMA, + "pack_id": pack.result_id, + "state": "prediction-blind-neutral-source-projection", + "contract": _tracklet_contract(), + "camera_playback": copy.deepcopy(camera_playback_projection(pack_id)), + "clips": clips, + "clip_count": len(clips), + "frame_count": sum(len(clip["frames"]) for clip in clips), + "strata_included": False, + "split_included": False, + "candidate_identity_included": False, + "frozen_predictions_included": False, + "model_scores_included": False, + "semantic_class_task_included": False, + "evidence_capabilities": _evidence_capabilities( + camera_available=( + camera_frame_provider is not None or camera_playback_provider is not None + ), + spatial_available=spatial_evidence_provider is not None, + ), + "access": "prediction-free-strata-free-source-read-only", + } + + @router.get( + "/packs/{pack_id}/source/clips/{clip_id}/frames/{sequence}/camera", + ) + def get_camera_frame( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + clip_id: str, + sequence: int, + ) -> Response: + if camera_frame_provider is None: + raise HTTPException(status_code=503, detail="M4.8 camera source is unavailable") + pack, _clips, frames = neutral_pack_index(pack_id) + frame = frames.get((clip_id, sequence)) + source = pack.manifest.get("identity", {}).get("source", {}) + session_id = source.get("source_session_id") if isinstance(source, dict) else None + if frame is None or not isinstance(session_id, str): + raise HTTPException(status_code=404, detail="M4.8 source frame was not found") + try: + camera = camera_frame_provider(session_id, sequence - 1) + except (OSError, RuntimeError) as exc: + raise HTTPException(status_code=409, detail="M4.8 source frame is unavailable") from exc + if ( + not isinstance(getattr(camera, "sha256", None), str) + or _SHA256.fullmatch(camera.sha256) is None + or getattr(camera, "source_fragment_sha256", None) != frame["camera_fragment_sha256"] + or not isinstance(getattr(camera, "payload", None), bytes) + or not isinstance(getattr(camera, "media_type", None), str) + ): + raise HTTPException(status_code=409, detail="M4.8 source frame changed") + return Response( + content=camera.payload, + media_type=camera.media_type, + headers={ + "X-M48-Decoded-Camera-SHA256": camera.sha256, + # The frozen pack binds the fMP4 fragment, not the decoded JPEG bytes. + "X-M48-Source-Fragment-SHA256": camera.source_fragment_sha256, + }, + ) + + @router.get( + "/packs/{pack_id}/source/clips/{clip_id}/frames/{sequence}/spatial", + ) + def get_spatial_evidence( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + clip_id: str, + sequence: int, + ) -> dict[str, object]: + if spatial_evidence_provider is None: + raise HTTPException( + status_code=503, + detail="M4.8 prediction-free spatial evidence is unavailable", + ) + try: + return copy.deepcopy(spatial_evidence_projection(pack_id, clip_id, sequence)) + except HTTPException: + raise + except (OSError, RuntimeError, TypeError, ValueError) as exc: + raise HTTPException( + status_code=409, + detail="M4.8 prediction-free spatial evidence failed validation", + ) from exc + + @router.post("/packs/{pack_id}/corrections") + def create_correction( + response: Response, + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + request: M48CreateRequest, + ) -> dict[str, object]: + """Create one candidate-assisted human correction session over the frozen pack.""" + + pack = _resolve_pack(pack_root_provider, pack_id) + _validate_operation_key(request.idempotency_key) + session_id = ( + "m48-correction-session-" + + hashlib.sha256( + f"{pack_id}\ncorrection\n{request.idempotency_key}".encode() + ).hexdigest() + ) + with store.lock: + path = store.correction_path(pack_id, session_id, create=True) + if path.exists(): + document = store.read_correction(pack_id, session_id) + else: + existing = store.corrections(pack_id) + if existing: + document = existing[0] + path = store.correction_path( + pack_id, + str(document["session_id"]), + create=False, + ) + else: + now = _utc_now() + clips = _seeded_correction_clips(pack) + seed_summary = _correction_seed_summary(pack, clips) + document = { + "schema_version": _CORRECTION_SESSION_SCHEMA, + "pack_id": pack_id, + "session_id": session_id, + "title": "M4.8 Worker 006 assisted correction", + "revision": 0, + "state": "draft", + "created_at_utc": now, + "updated_at_utc": now, + "clips": clips, + "progress": _progress(clips, "reviewed"), + "seed_summary": seed_summary, + "evidence_summary": None, + "assistance": _correction_assistance(), + "authority": dict(_AUTHORITY), + "last_save_idempotency_key": None, + "reviewer_id": None, + "submitted_at_utc": None, + "submission_sha256": None, + "frozen_document_name": None, + } + write_json_atomic(path, document) + capability = _ensure_capability( + path, + schema=_CORRECTION_CAPABILITY_SCHEMA, + owner_id=str(document["session_id"]), + ) + response.headers[_CORRECTION_CAPABILITY_HEADER] = capability + return {**_project_correction(document), "correction_capability": capability} + + @router.get("/packs/{pack_id}/corrections/{session_id}") + def get_correction( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_CORRECTION_SESSION_ID.pattern)], + x_m48_correction_capability: Annotated[ + str | None, + Header(alias=_CORRECTION_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + _resolve_pack(pack_root_provider, pack_id) + path = store.correction_path(pack_id, session_id, create=False) + _require_capability( + path, + x_m48_correction_capability, + _CORRECTION_CAPABILITY_SCHEMA, + ) + return _project_correction(store.read_correction(pack_id, session_id)) + + @router.put("/packs/{pack_id}/corrections/{session_id}") + def save_correction( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_CORRECTION_SESSION_ID.pattern)], + request: M48SaveRequest, + x_m48_correction_capability: Annotated[ + str | None, + Header(alias=_CORRECTION_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + path = store.correction_path(pack_id, session_id, create=False) + _require_capability( + path, + x_m48_correction_capability, + _CORRECTION_CAPABILITY_SCHEMA, + ) + _validate_operation_key(request.idempotency_key) + clips = _normalize_correction_clips(pack, request.clips) + with store.lock: + current = store.read_correction(pack_id, session_id) + if current["state"] == "frozen": + raise HTTPException(status_code=409, detail="M4.8 correction is frozen") + if current.get("last_save_idempotency_key") == request.idempotency_key: + return _project_correction(current) + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="M4.8 correction revision changed") + current.update( + { + "title": request.title.strip(), + "revision": request.expected_revision + 1, + "state": "saved", + "updated_at_utc": _utc_now(), + "clips": clips, + "progress": _progress(clips, "reviewed"), + "last_save_idempotency_key": request.idempotency_key, + } + ) + write_json_atomic(path, current) + return _project_correction(current) + + @router.post("/packs/{pack_id}/corrections/{session_id}/freeze") + def freeze_correction( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_CORRECTION_SESSION_ID.pattern)], + request: M48CorrectionFreezeRequest, + x_m48_correction_capability: Annotated[ + str | None, + Header(alias=_CORRECTION_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + path = store.correction_path(pack_id, session_id, create=False) + _require_capability( + path, + x_m48_correction_capability, + _CORRECTION_CAPABILITY_SCHEMA, + ) + reviewer_id = request.reviewer_id.strip() + _validate_actor_id(reviewer_id, "reviewer") + with store.lock: + current = store.read_correction(pack_id, session_id) + if current["state"] == "frozen": + if current.get("reviewer_id") == reviewer_id: + return _project_correction(current) + raise HTTPException(status_code=409, detail="M4.8 correction is already frozen") + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="M4.8 correction revision changed") + if not current["progress"]["complete"]: + raise HTTPException( + status_code=422, + detail="Every M4.8 clip must be corrected before freeze", + ) + submitted_at = _utc_now() + evidence = _correction_evidence_summary( + _seeded_correction_clips(pack), + current["clips"], + ) + submission = { + "schema_version": _CORRECTION_ARTIFACT_SCHEMA, + "pack_id": pack_id, + "state": "completed-candidate-assisted-correction", + "reviewer_id": reviewer_id, + "assistance": _correction_assistance(), + "clips": copy.deepcopy(current["clips"]), + "evidence_summary": evidence, + "acceptance": { + "all_clips_corrected": True, + "independent": False, + "submitted_at_utc": submitted_at, + }, + "authority": dict(_AUTHORITY), + } + root = store.pack_root(pack_id, create=True) + assert root is not None + frozen_name = f"frozen-correction-{session_id}.json" + write_json_atomic(root / frozen_name, submission) + current.update( + { + "revision": request.expected_revision + 1, + "state": "frozen", + "updated_at_utc": submitted_at, + "reviewer_id": reviewer_id, + "submitted_at_utc": submitted_at, + "submission_sha256": _canonical_sha256(submission), + "frozen_document_name": frozen_name, + "evidence_summary": evidence, + } + ) + write_json_atomic(path, current) + return _project_correction(current) + + @router.post("/packs/{pack_id}/reviews") + def create_review( + response: Response, + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + request: M48CreateRequest, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + _validate_operation_key(request.idempotency_key) + session_id = ( + "m48-review-session-" + + hashlib.sha256(f"{pack_id}\n{request.idempotency_key}".encode()).hexdigest() + ) + with store.lock: + path = store.review_path(pack_id, session_id, create=True) + if path.exists(): + document = store.read_review(pack_id, session_id) + else: + existing = store.reviews(pack_id) + if len(existing) >= 2: + raise HTTPException( + status_code=409, + detail="M4.8 permits exactly two independent reviewer slots", + ) + now = _utc_now() + document = { + "schema_version": _REVIEW_SESSION_SCHEMA, + "pack_id": pack_id, + "session_id": session_id, + "reviewer_slot": len(existing) + 1, + "title": f"M4.8 independent object review {len(existing) + 1}", + "revision": 0, + "state": "draft", + "created_at_utc": now, + "updated_at_utc": now, + "clips": _blank_clips(pack), + "progress": { + "reviewed_clip_count": 0, + "clip_count": len(pack.clips), + "complete": False, + }, + "blindness": dict(_BLINDNESS), + "authority": dict(_AUTHORITY), + "last_save_idempotency_key": None, + "reviewer_id": None, + "submitted_at_utc": None, + "submission_sha256": None, + "frozen_document_name": None, + } + write_json_atomic(path, document) + capability = _ensure_capability( + path, + schema=_REVIEW_CAPABILITY_SCHEMA, + owner_id=session_id, + ) + response.headers[_REVIEW_CAPABILITY_HEADER] = capability + return {**_project_review(document), "review_capability": capability} + + @router.get("/packs/{pack_id}/reviews/{session_id}") + def get_review( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_REVIEW_SESSION_ID.pattern)], + x_m48_review_capability: Annotated[ + str | None, + Header(alias=_REVIEW_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + _resolve_pack(pack_root_provider, pack_id) + path = store.review_path(pack_id, session_id, create=False) + _require_capability(path, x_m48_review_capability, _REVIEW_CAPABILITY_SCHEMA) + return _project_review(store.read_review(pack_id, session_id)) + + @router.put("/packs/{pack_id}/reviews/{session_id}") + def save_review( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_REVIEW_SESSION_ID.pattern)], + request: M48SaveRequest, + x_m48_review_capability: Annotated[ + str | None, + Header(alias=_REVIEW_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + path = store.review_path(pack_id, session_id, create=False) + _require_capability(path, x_m48_review_capability, _REVIEW_CAPABILITY_SCHEMA) + _validate_operation_key(request.idempotency_key) + clips = _normalize_clips( + pack, + request.clips, + complete_state="reviewed", + spatial_evidence_available=spatial_evidence_provider is not None, + ) + with store.lock: + current = store.read_review(pack_id, session_id) + if current["state"] == "frozen": + raise HTTPException(status_code=409, detail="M4.8 review slot is frozen") + if current.get("last_save_idempotency_key") == request.idempotency_key: + return _project_review(current) + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="M4.8 review revision changed") + current.update( + { + "title": request.title.strip(), + "revision": request.expected_revision + 1, + "state": "saved", + "updated_at_utc": _utc_now(), + "clips": clips, + "progress": _progress(clips, "reviewed"), + "last_save_idempotency_key": request.idempotency_key, + } + ) + write_json_atomic(path, current) + return _project_review(current) + + @router.post("/packs/{pack_id}/reviews/{session_id}/freeze") + def freeze_review( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_REVIEW_SESSION_ID.pattern)], + request: M48ReviewFreezeRequest, + x_m48_review_capability: Annotated[ + str | None, + Header(alias=_REVIEW_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + path = store.review_path(pack_id, session_id, create=False) + _require_capability(path, x_m48_review_capability, _REVIEW_CAPABILITY_SCHEMA) + reviewer_id = request.reviewer_id.strip() + _validate_actor_id(reviewer_id, "reviewer") + with store.lock: + current = store.read_review(pack_id, session_id) + if current["state"] == "frozen": + if current.get("reviewer_id") == reviewer_id: + return _project_review(current) + raise HTTPException(status_code=409, detail="M4.8 review slot is already frozen") + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="M4.8 review revision changed") + if not current["progress"]["complete"]: + raise HTTPException( + status_code=422, detail="Every M4.8 clip must be reviewed before freeze" + ) + if any( + row.get("reviewer_id") == reviewer_id + for row in store.reviews(pack_id) + if row["session_id"] != session_id and row["state"] == "frozen" + ): + raise HTTPException(status_code=409, detail="Reviewer identity must be distinct") + submitted_at = _utc_now() + submission = { + "schema_version": M48_REVIEW_SCHEMA, + "pack_id": pack_id, + "state": "completed-independent-no-predictions", + "reviewer_id": reviewer_id, + "review_round": 1, + "blindness": dict(_BLINDNESS), + "clips": copy.deepcopy(current["clips"]), + "acceptance": { + "all_clips_reviewed": True, + "independent": True, + "submitted_at_utc": submitted_at, + }, + } + root = store.pack_root(pack_id, create=True) + assert root is not None + with tempfile.TemporaryDirectory(prefix=".review-freeze-", dir=root) as temporary: + candidate = Path(temporary) / "review.json" + write_json_atomic(candidate, submission) + try: + validate_m48_review_submission( + pack_root=pack.result_root, + review_path=candidate, + ) + except (M48ObjectQualityError, OSError, ValueError) as exc: + raise HTTPException( + status_code=422, + detail="M4.8 review does not satisfy the frozen contract", + ) from exc + frozen_name = f"frozen-review-{session_id}.json" + write_json_atomic(root / frozen_name, submission) + current.update( + { + "revision": request.expected_revision + 1, + "state": "frozen", + "updated_at_utc": submitted_at, + "reviewer_id": reviewer_id, + "submitted_at_utc": submitted_at, + "submission_sha256": _canonical_sha256(submission), + "frozen_document_name": frozen_name, + } + ) + write_json_atomic(path, current) + return _project_review(current) + + @router.post("/packs/{pack_id}/adjudication") + def create_adjudication( + response: Response, + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + request: M48CreateRequest, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + _validate_operation_key(request.idempotency_key) + reviews = _frozen_reviews(store, pack_id) + if len(reviews) != 2: + raise HTTPException( + status_code=409, + detail="M4.8 adjudication unlocks only after two distinct frozen reviews", + ) + session_id = ( + "m48-adjudication-session-" + + hashlib.sha256(f"{pack_id}\n{request.idempotency_key}".encode()).hexdigest() + ) + with store.lock: + path = store.adjudication_path(pack_id, session_id, create=True) + if path.exists(): + document = store.read_adjudication(pack_id, session_id) + else: + existing = store.adjudications(pack_id) + if existing: + raise HTTPException(status_code=409, detail="M4.8 adjudication already exists") + now = _utc_now() + document = { + "schema_version": _ADJUDICATION_SESSION_SCHEMA, + "pack_id": pack_id, + "session_id": session_id, + "title": "M4.8 class-free object adjudication", + "revision": 0, + "state": "draft", + "created_at_utc": now, + "updated_at_utc": now, + "clips": _blank_clips(pack), + "progress": { + "reviewed_clip_count": 0, + "clip_count": len(pack.clips), + "complete": False, + }, + "review_submission_sha256": sorted( + str(row["submission_sha256"]) for row in reviews + ), + "blindness": dict(_BLINDNESS), + "authority": dict(_AUTHORITY), + "last_save_idempotency_key": None, + "adjudicator_id": None, + "sealed_at_utc": None, + "truth_seal_id": None, + "quality_result_id": None, + "evaluated_at_utc": None, + } + write_json_atomic(path, document) + capability = _ensure_capability( + path, + schema=_ADJUDICATION_CAPABILITY_SCHEMA, + owner_id=session_id, + ) + response.headers[_ADJUDICATION_CAPABILITY_HEADER] = capability + return { + **_project_adjudication(document, reviews, store), + "adjudication_capability": capability, + } + + @router.get("/packs/{pack_id}/adjudication/{session_id}") + def get_adjudication( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_ADJUDICATION_SESSION_ID.pattern)], + x_m48_adjudication_capability: Annotated[ + str | None, + Header(alias=_ADJUDICATION_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + _resolve_pack(pack_root_provider, pack_id) + path = store.adjudication_path(pack_id, session_id, create=False) + _require_capability( + path, + x_m48_adjudication_capability, + _ADJUDICATION_CAPABILITY_SCHEMA, + ) + reviews = _frozen_reviews(store, pack_id) + if len(reviews) != 2: + raise HTTPException(status_code=409, detail="M4.8 adjudication is locked") + return _project_adjudication( + store.read_adjudication(pack_id, session_id), + reviews, + store, + ) + + @router.put("/packs/{pack_id}/adjudication/{session_id}") + def save_adjudication( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_ADJUDICATION_SESSION_ID.pattern)], + request: M48SaveRequest, + x_m48_adjudication_capability: Annotated[ + str | None, + Header(alias=_ADJUDICATION_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + path = store.adjudication_path(pack_id, session_id, create=False) + _require_capability(path, x_m48_adjudication_capability, _ADJUDICATION_CAPABILITY_SCHEMA) + if len(_frozen_reviews(store, pack_id)) != 2: + raise HTTPException(status_code=409, detail="M4.8 adjudication is locked") + _validate_operation_key(request.idempotency_key) + clips = _normalize_clips( + pack, + request.clips, + complete_state="adjudicated", + spatial_evidence_available=spatial_evidence_provider is not None, + ) + with store.lock: + current = store.read_adjudication(pack_id, session_id) + if current["state"] in {"adjudication-frozen", "evaluated"}: + raise HTTPException(status_code=409, detail="M4.8 adjudication is frozen") + if current.get("last_save_idempotency_key") == request.idempotency_key: + return _project_adjudication(current, _frozen_reviews(store, pack_id), store) + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="M4.8 adjudication revision changed") + current.update( + { + "title": request.title.strip(), + "revision": request.expected_revision + 1, + "state": "saved", + "updated_at_utc": _utc_now(), + "clips": clips, + "progress": _progress(clips, "adjudicated"), + "last_save_idempotency_key": request.idempotency_key, + } + ) + write_json_atomic(path, current) + return _project_adjudication(current, _frozen_reviews(store, pack_id), store) + + @router.post("/packs/{pack_id}/adjudication/{session_id}/freeze") + def freeze_adjudication( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_ADJUDICATION_SESSION_ID.pattern)], + request: M48AdjudicationFreezeRequest, + x_m48_adjudication_capability: Annotated[ + str | None, + Header(alias=_ADJUDICATION_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + path = store.adjudication_path(pack_id, session_id, create=False) + _require_capability(path, x_m48_adjudication_capability, _ADJUDICATION_CAPABILITY_SCHEMA) + adjudicator_id = request.adjudicator_id.strip() + _validate_actor_id(adjudicator_id, "adjudicator") + with store.lock: + reviews = _frozen_reviews(store, pack_id) + if len(reviews) != 2: + raise HTTPException(status_code=409, detail="M4.8 adjudication is locked") + current = store.read_adjudication(pack_id, session_id) + if current["state"] in {"adjudication-frozen", "evaluated"}: + if current.get("adjudicator_id") == adjudicator_id: + return _project_adjudication(current, reviews, store) + raise HTTPException(status_code=409, detail="M4.8 adjudication is already frozen") + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="M4.8 adjudication revision changed") + if not current["progress"]["complete"]: + raise HTTPException(status_code=422, detail="Every M4.8 clip must be adjudicated") + sealed_at = _utc_now() + review_digests = sorted(str(row["submission_sha256"]) for row in reviews) + document = { + "schema_version": M48_ADJUDICATION_SCHEMA, + "pack_id": pack_id, + "state": "completed-adjudicated", + "adjudicator_id": adjudicator_id, + "review_submission_sha256": review_digests, + "clips": copy.deepcopy(current["clips"]), + "acceptance": { + "all_clips_adjudicated": True, + "all_disagreements_resolved": True, + "sealed_at_utc": sealed_at, + }, + } + truth_root = _mutable_root(truth_root_provider, "M4.8 truth storage") + workflow_root = store.pack_root(pack_id, create=True) + assert workflow_root is not None + review_paths = [_frozen_document_path(workflow_root, row) for row in reviews] + with tempfile.TemporaryDirectory( + prefix=".adjudication-freeze-", dir=workflow_root + ) as temporary: + candidate = Path(temporary) / "adjudication.json" + write_json_atomic(candidate, document) + try: + truth = build_m48_object_truth_seal( + pack_root=pack.result_root, + reviewer_a_path=review_paths[0], + reviewer_b_path=review_paths[1], + adjudication_path=candidate, + output_root=truth_root, + ) + except (M48ObjectQualityError, OSError, ValueError) as exc: + raise HTTPException( + status_code=422, + detail="M4.8 adjudication does not satisfy the frozen contract", + ) from exc + write_json_atomic(workflow_root / f"frozen-adjudication-{session_id}.json", document) + current.update( + { + "revision": request.expected_revision + 1, + "state": "adjudication-frozen", + "updated_at_utc": sealed_at, + "adjudicator_id": adjudicator_id, + "sealed_at_utc": sealed_at, + "truth_seal_id": truth.result_id, + } + ) + write_json_atomic(path, current) + return _project_adjudication(current, reviews, store) + + @router.post("/packs/{pack_id}/adjudication/{session_id}/evaluate") + def evaluate( + pack_id: Annotated[str, ApiPath(pattern=_PACK_ID.pattern)], + session_id: Annotated[str, ApiPath(pattern=_ADJUDICATION_SESSION_ID.pattern)], + request: M48EvaluateRequest, + x_m48_adjudication_capability: Annotated[ + str | None, + Header(alias=_ADJUDICATION_CAPABILITY_HEADER), + ] = None, + ) -> dict[str, object]: + pack = _resolve_pack(pack_root_provider, pack_id) + path = store.adjudication_path(pack_id, session_id, create=False) + _require_capability(path, x_m48_adjudication_capability, _ADJUDICATION_CAPABILITY_SCHEMA) + _validate_operation_key(request.idempotency_key) + with store.lock: + reviews = _frozen_reviews(store, pack_id) + current = store.read_adjudication(pack_id, session_id) + if current["state"] == "evaluated": + if current.get("evaluation_idempotency_key") == request.idempotency_key: + return _project_adjudication(current, reviews, store) + raise HTTPException(status_code=409, detail="M4.8 pack was already evaluated") + if current["state"] != "adjudication-frozen": + raise HTTPException( + status_code=409, detail="Freeze M4.8 adjudication before evaluation" + ) + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="M4.8 adjudication revision changed") + truth_id = current.get("truth_seal_id") + truth_parent = _configured_root(truth_root_provider) + if ( + not isinstance(truth_id, str) + or _TRUTH_ID.fullmatch(truth_id) is None + or truth_parent is None + or not (truth_parent / truth_id).is_dir() + or (truth_parent / truth_id).is_symlink() + ): + raise HTTPException(status_code=409, detail="M4.8 truth seal is unavailable") + if evaluation_runner is None: + raise HTTPException( + status_code=503, + detail="M4.8 canonical evaluation runner is unavailable", + ) + result_root = _mutable_root(result_root_provider, "M4.8 result storage") + receipt_root = _mutable_root( + evaluation_receipt_root_provider, + "laboratory run receipt storage", + ) + truth_path = (truth_parent / truth_id).resolve(strict=True) + run_identity = hashlib.sha256( + "\0".join((pack_id, truth_id, session_id, request.idempotency_key)).encode("utf-8") + ).hexdigest() + try: + run = evaluation_runner.run( + LaboratoryRunRequest( + work_id="m48-object-centric-quality", + run_id=f"m48-evaluation-{run_identity}", + request_id=request.idempotency_key, + contour_id="mission-core-lab", + agent_id="local-control-plane", + node_id="mission-core-control-plane", + source_id=pack_id, + source_package_id=truth_id, + method_id="m48-object-centric-quality/v1", + inputs={ + "pack_root": pack.result_root, + "truth_seal_root": truth_path, + }, + output_root=result_root, + receipt_root=receipt_root, + ) + ) + except ( + LaboratoryExecutionError, + M48ObjectQualityError, + OSError, + ValueError, + ) as exc: + raise HTTPException( + status_code=422, detail="M4.8 evaluation failed closed" + ) from exc + if _RESULT_ID.fullmatch(run.result_id) is None: + raise HTTPException( + status_code=422, + detail="M4.8 evaluation returned invalid identity", + ) + evaluated_at = _utc_now() + current.update( + { + "revision": request.expected_revision + 1, + "state": "evaluated", + "updated_at_utc": evaluated_at, + "quality_result_id": run.result_id, + "evaluation_receipt_id": run.receipt_id, + "evaluated_at_utc": evaluated_at, + "evaluation_idempotency_key": request.idempotency_key, + } + ) + write_json_atomic(path, current) + return _project_adjudication(current, reviews, store) + + @router.get("/results/{result_id}") + def get_result( + result_id: Annotated[str, ApiPath(pattern=_RESULT_ID.pattern)], + ) -> dict[str, object]: + result = _resolve_result(result_root_provider, result_id) + pack, truth = _result_sources( + result, + pack_root_provider=pack_root_provider, + truth_root_provider=truth_root_provider, + ) + return _result_projection(result, pack=pack, truth=truth) + + @router.get("/results/{result_id}/atlas") + def get_failure_atlas( + result_id: Annotated[str, ApiPath(pattern=_RESULT_ID.pattern)], + ) -> dict[str, object]: + result = _resolve_result(result_root_provider, result_id) + _result_sources( + result, + pack_root_provider=pack_root_provider, + truth_root_provider=truth_root_provider, + ) + return { + "schema_version": _FAILURE_ATLAS_VIEW_SCHEMA, + "result_id": result.result_id, + "cases": [copy.deepcopy(row) for row in result.failure_atlas], + "case_count": len(result.failure_atlas), + "prediction_material_release": "post-adjudication-seal-evaluation-only", + "authority": dict(_AUTHORITY), + "access": "evaluated-bounded-failure-atlas-read-only", + } + + @router.get("/results/{result_id}/atlas/cases/{case_id}") + def get_failure_case( + result_id: Annotated[str, ApiPath(pattern=_RESULT_ID.pattern)], + case_id: Annotated[str, ApiPath(pattern=_FAILURE_ID.pattern)], + ) -> dict[str, object]: + result = _resolve_result(result_root_provider, result_id) + pack, truth = _result_sources( + result, + pack_root_provider=pack_root_provider, + truth_root_provider=truth_root_provider, + ) + failure = next( + (row for row in result.failure_atlas if row.get("failure_case_id") == case_id), + None, + ) + if failure is None: + raise HTTPException(status_code=404, detail="M4.8 failure case was not found") + sequence = failure.get("sequence") + clip_id = failure.get("clip_id") + ledger = next( + ( + row + for row in result.frame_ledger + if row.get("sequence") == sequence and row.get("clip_id") == clip_id + ), + None, + ) + reference = next( + ( + row + for row in pack.frame_references + if row.get("sequence") == sequence and row.get("clip_id") == clip_id + ), + None, + ) + prediction = next( + ( + row + for row in pack.predictions + if row.get("sequence") == sequence and row.get("clip_id") == clip_id + ), + None, + ) + truth_row = next( + ( + row + for row in truth.truth_rows + if row.get("sequence") == sequence and row.get("clip_id") == clip_id + ), + None, + ) + if any(value is None for value in (ledger, reference, prediction, truth_row)): + raise HTTPException(status_code=409, detail="M4.8 failure evidence lost its join") + assert isinstance(sequence, int) + assert isinstance(clip_id, str) + assert isinstance(reference, dict) + assert isinstance(prediction, dict) + assert isinstance(truth_row, dict) + return { + "schema_version": _FAILURE_CASE_VIEW_SCHEMA, + "result_id": result.result_id, + "case": copy.deepcopy(failure), + "frame": { + "sequence": sequence, + "source_time_ns": reference["source_time_ns"], + "camera_fragment_sha256": reference["camera_fragment_sha256"], + "camera_url": ( + f"/api/v1/laboratory/m48/packs/{pack.result_id}/source/clips/" + f"{clip_id}/frames/{sequence}/camera" + if camera_frame_provider is not None + else None + ), + "spatial_url": ( + f"/api/v1/laboratory/m48/packs/{pack.result_id}/source/clips/" + f"{clip_id}/frames/{sequence}/spatial" + if spatial_evidence_provider is not None + else None + ), + }, + "truth": [ + _quality_object_projection(row, identity_key="object_id") + for row in truth_row["objects"] + ], + "graph": [ + _quality_object_projection(row, identity_key="prediction_id") + for row in prediction["objects"] + ], + "prediction_material_release": "post-adjudication-seal-evaluation-only", + "authority": dict(_AUTHORITY), + "access": "evaluated-failure-case-read-only", + } + + @router.get("/regressions/small-static/{result_id}") + def get_small_static_regression( + result_id: Annotated[str, ApiPath(pattern=_SMALL_STATIC_RESULT_ID.pattern)], + ) -> dict[str, object]: + result, pack = _resolve_small_static_result( + small_static_result_root_provider, + pack_root_provider, + result_id, + ) + metrics = _object(result.report.get("metrics"), "M4.8 regression metrics") + gates = _object(result.report.get("gates"), "M4.8 regression gates") + decision = _object(result.report.get("decision"), "M4.8 regression decision") + return { + "schema_version": _SMALL_STATIC_RESULT_VIEW_SCHEMA, + "result_id": result.result_id, + "pack_id": pack.result_id, + "created_at_utc": result.manifest.get("created_at_utc"), + "run_label": result.manifest["identity"].get("run_label"), + "pipeline_id": result.manifest["identity"].get("pipeline_id"), + "experiment_id": result.manifest["identity"].get("experiment_id"), + "accepted": result.manifest.get("accepted"), + "metrics": copy.deepcopy(metrics), + "gates": copy.deepcopy(gates), + "decision": copy.deepcopy(decision), + "ground_truth": False, + "independent_truth": False, + "authority": dict(_AUTHORITY), + "access": "assisted-development-regression-read-only", + } + + @router.get("/regressions/small-static/{result_id}/cases") + def get_small_static_regression_cases( + result_id: Annotated[str, ApiPath(pattern=_SMALL_STATIC_RESULT_ID.pattern)], + ) -> dict[str, object]: + result, _pack = _resolve_small_static_result( + small_static_result_root_provider, + pack_root_provider, + result_id, + ) + return { + "schema_version": _SMALL_STATIC_CASE_CATALOG_SCHEMA, + "result_id": result.result_id, + "cases": [ + { + "anchor_id": row["anchor_id"], + "clip_id": row["clip_id"], + "sequence": row["sequence"], + "requires_avoidance_or_clearance": row[ + "requires_avoidance_or_clearance" + ], + "worker_candidate_count": row["worker_candidate_count"], + "best_iou": row["best_iou"], + "matched_at_threshold": row["matched_at_threshold"], + "outcome": row["outcome"], + } + for row in result.comparisons + ], + "case_count": len(result.comparisons), + "ground_truth": False, + "authority": dict(_AUTHORITY), + "access": "assisted-development-regression-read-only", + } + + @router.get("/regressions/small-static/{result_id}/cases/{anchor_id}") + def get_small_static_regression_case( + result_id: Annotated[str, ApiPath(pattern=_SMALL_STATIC_RESULT_ID.pattern)], + anchor_id: Annotated[str, ApiPath(pattern=_SMALL_STATIC_ANCHOR_ID.pattern)], + ) -> dict[str, object]: + result, pack = _resolve_small_static_result( + small_static_result_root_provider, + pack_root_provider, + result_id, + ) + comparison = next( + (row for row in result.comparisons if row.get("anchor_id") == anchor_id), + None, + ) + anchor = next( + (row for row in result.anchors if row.get("anchor_id") == anchor_id), + None, + ) + if comparison is None or anchor is None: + raise HTTPException(status_code=404, detail="M4.8 regression case was not found") + clip_id = str(comparison["clip_id"]) + sequence = int(comparison["sequence"]) + if not any( + row.get("clip_id") == clip_id and row.get("sequence") == sequence + for row in pack.frame_references + ): + raise HTTPException(status_code=409, detail="M4.8 regression frame binding changed") + return { + "schema_version": _SMALL_STATIC_CASE_VIEW_SCHEMA, + "result_id": result.result_id, + "pack_id": pack.result_id, + "anchor": copy.deepcopy(anchor), + "comparison": copy.deepcopy(comparison), + "camera_url": ( + f"/api/v1/laboratory/m48/packs/{pack.result_id}/source/clips/" + f"{clip_id}/frames/{sequence}/camera" + if camera_frame_provider is not None + else None + ), + "spatial_url": ( + f"/api/v1/laboratory/m48/packs/{pack.result_id}/source/clips/" + f"{clip_id}/frames/{sequence}/spatial" + if spatial_evidence_provider is not None + else None + ), + "ground_truth": False, + "authority": dict(_AUTHORITY), + "access": "assisted-development-regression-case-read-only", + } + + return router + + +def _resolve_pack(provider: RootProvider, pack_id: str) -> M48ObjectQualityPack: + _validate_pack_id(pack_id) + root = _configured_root(provider) + if root is None: + raise HTTPException(status_code=404, detail="M4.8 pack was not found") + path = root / pack_id + if path.is_symlink() or not path.is_dir(): + raise HTTPException(status_code=404, detail="M4.8 pack was not found") + try: + resolved = path.resolve(strict=True) + if resolved.parent != root: + raise OSError("pack escaped root") + return read_m48_object_quality_pack(resolved) + except (M48ObjectQualityError, OSError, ValueError): + raise HTTPException(status_code=404, detail="M4.8 pack was not found") from None + + +def _resolve_truth(provider: RootProvider, truth_id: str) -> M48ObjectTruthSeal: + if _TRUTH_ID.fullmatch(truth_id) is None: + raise HTTPException(status_code=404, detail="M4.8 truth seal was not found") + root = _configured_root(provider) + if root is None: + raise HTTPException(status_code=404, detail="M4.8 truth seal was not found") + path = root / truth_id + if path.is_symlink() or not path.is_dir(): + raise HTTPException(status_code=404, detail="M4.8 truth seal was not found") + try: + resolved = path.resolve(strict=True) + if resolved.parent != root: + raise OSError("truth seal escaped root") + return read_m48_object_truth_seal(resolved) + except (M48ObjectQualityError, OSError, ValueError): + raise HTTPException(status_code=404, detail="M4.8 truth seal was not found") from None + + +def _resolve_result(provider: RootProvider, result_id: str) -> M48ObjectQualityResult: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="M4.8 result was not found") + root = _configured_root(provider) + if root is None: + raise HTTPException(status_code=404, detail="M4.8 result was not found") + path = root / result_id + if path.is_symlink() or not path.is_dir(): + raise HTTPException(status_code=404, detail="M4.8 result was not found") + try: + resolved = path.resolve(strict=True) + if resolved.parent != root: + raise OSError("result escaped root") + return read_m48_object_quality_result(resolved) + except (M48ObjectQualityError, OSError, ValueError): + raise HTTPException(status_code=404, detail="M4.8 result was not found") from None + + +def _resolve_small_static_result( + provider: RootProvider, + pack_provider: RootProvider, + result_id: str, +) -> tuple[M48SmallStaticRegressionResult, M48ObjectQualityPack]: + if _SMALL_STATIC_RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="M4.8 regression result was not found") + root = _configured_root(provider) + if root is None: + raise HTTPException(status_code=404, detail="M4.8 regression result was not found") + path = root / result_id + if path.is_symlink() or not path.is_dir(): + raise HTTPException(status_code=404, detail="M4.8 regression result was not found") + try: + resolved = path.resolve(strict=True) + if resolved.parent != root: + raise OSError("regression result escaped root") + result = read_m48_small_static_passage_regression(resolved) + source = _object( + result.manifest["identity"].get("source"), + "M4.8 regression source", + ) + pack_id = source.get("pack_id") + if not isinstance(pack_id, str): + raise ValueError("pack id is invalid") + pack = _resolve_pack(pack_provider, pack_id) + freeze = _object(pack.manifest["identity"].get("freeze"), "M4.8 pack freeze") + if ( + source.get("pack_identity_sha256") != pack.manifest.get("identity_sha256") + or source.get("prediction_rows_sha256") != freeze.get("prediction_rows_sha256") + ): + raise ValueError("regression source changed") + return result, pack + except HTTPException: + raise + except (M48SmallStaticRegressionError, OSError, TypeError, ValueError): + raise HTTPException( + status_code=404, + detail="M4.8 regression result was not found", + ) from None + + +def _result_sources( + result: M48ObjectQualityResult, + *, + pack_root_provider: RootProvider, + truth_root_provider: RootProvider, +) -> tuple[M48ObjectQualityPack, M48ObjectTruthSeal]: + identity = result.manifest.get("identity") + pack_binding = identity.get("pack") if isinstance(identity, dict) else None + truth_binding = identity.get("truth_seal") if isinstance(identity, dict) else None + if not isinstance(pack_binding, dict) or not isinstance(truth_binding, dict): + raise HTTPException(status_code=409, detail="M4.8 result source binding is invalid") + pack_id = pack_binding.get("result_id") + truth_id = truth_binding.get("result_id") + if not isinstance(pack_id, str) or not isinstance(truth_id, str): + raise HTTPException(status_code=409, detail="M4.8 result source binding is invalid") + pack = _resolve_pack(pack_root_provider, pack_id) + truth = _resolve_truth(truth_root_provider, truth_id) + if ( + _file_sha256(pack.result_root / M48_MANIFEST_NAME) != pack_binding.get("manifest_sha256") + or _file_sha256(truth.result_root / M48_MANIFEST_NAME) + != truth_binding.get("manifest_sha256") + or truth.provenance.get("prediction_content_read_by_sealer") is not False + or truth.provenance.get("prediction_content_seen_by_reviewers") is not False + ): + raise HTTPException(status_code=409, detail="M4.8 result source binding changed") + return pack, truth + + +def _result_projection( + result: M48ObjectQualityResult, + *, + pack: M48ObjectQualityPack, + truth: M48ObjectTruthSeal, +) -> dict[str, object]: + metrics = result.report.get("metrics") + acceptance = result.report.get("acceptance") + if not isinstance(metrics, dict) or not isinstance(acceptance, dict): + raise HTTPException(status_code=409, detail="M4.8 result report is invalid") + metric_names = ( + "terminal_outcome_accounting", + "false_free_space_claims", + "obstacle_presence_precision", + "obstacle_presence_recall", + "critical_corridor_obstacle_recall", + "geometry_association_correctness", + "freshness_correctness", + "motion_decision_correctness", + "critical_threat_not_threat", + "unknown_prediction_count", + "failure_case_count", + ) + projected_metrics = {name: metrics.get(name) for name in metric_names} + if any( + not isinstance(value, int | float) or isinstance(value, bool) + for value in projected_metrics.values() + ): + raise HTTPException(status_code=409, detail="M4.8 result metrics are invalid") + gates = acceptance.get("gates") + unknown_causes = metrics.get("unknown_causes") + accepted = acceptance.get("accepted") + if ( + not isinstance(gates, dict) + or not all(isinstance(key, str) and isinstance(value, bool) for key, value in gates.items()) + or not isinstance(unknown_causes, dict) + or not all( + isinstance(key, str) + and isinstance(value, int) + and not isinstance(value, bool) + and value >= 0 + for key, value in unknown_causes.items() + ) + or not isinstance(accepted, bool) + ): + raise HTTPException(status_code=409, detail="M4.8 result acceptance is invalid") + return { + "schema_version": _RESULT_VIEW_SCHEMA, + "result_id": result.result_id, + "pack_id": pack.result_id, + "truth_seal_id": truth.result_id, + "created_at_utc": result.manifest.get("created_at_utc"), + "status": result.report.get("status"), + "accepted": accepted, + "metrics": projected_metrics, + "gates": copy.deepcopy(gates), + "unknown_causes": copy.deepcopy(unknown_causes), + "prediction_material_release": "post-adjudication-seal-evaluation-only", + "authority": dict(_AUTHORITY), + "ground_truth": False, + "access": "evaluated-object-quality-summary-read-only", + } + + +def _quality_object_projection( + value: Mapping[str, Any], + *, + identity_key: Literal["object_id", "prediction_id"], +) -> dict[str, object]: + object_id = value.get(identity_key) + extent = value.get("extent_xyxy") + if not isinstance(object_id, str) or not isinstance(extent, list): + raise HTTPException(status_code=409, detail="M4.8 failure object is invalid") + return { + "object_id": object_id, + "extent_xyxy": copy.deepcopy(extent), + "geometry_association": value.get("geometry_association"), + "freshness": value.get("freshness"), + "motion": value.get("motion"), + "threat": value.get("threat"), + } + + +def _pack_projection(pack: M48ObjectQualityPack, store: _WorkflowStore) -> dict[str, object]: + snapshot = _workflow_snapshot(store, pack.result_id) + return { + "schema_version": _PACK_STATUS_SCHEMA, + "pack_id": pack.result_id, + "created_at_utc": pack.manifest.get("created_at_utc"), + "state": snapshot["state"], + "metrics": { + "clip_count": len(pack.clips), + "frame_count": len(pack.frame_references), + "seed_object_count": sum(len(row.get("objects", [])) for row in pack.predictions), + "correction_state": snapshot["correction_state"], + "correction_reviewed_clip_count": snapshot["correction_reviewed_clip_count"], + "correction_complete": snapshot["correction_complete"], + "review_slot_count": snapshot["review_slot_count"], + "frozen_reviewer_count": snapshot["frozen_reviewer_count"], + "required_frozen_reviewer_count": 2, + }, + "decision": { + "review_collection_ready": True, + "two_distinct_reviews_frozen": snapshot["frozen_reviewer_count"] == 2, + "adjudication_unlocked": snapshot["frozen_reviewer_count"] == 2, + "adjudication_frozen": snapshot["adjudication_frozen"], + "evaluated": snapshot["state"] == "evaluated", + "next_action": snapshot["next_action"], + }, + "truth_seal_id": snapshot["truth_seal_id"], + "quality_result_id": snapshot["quality_result_id"], + "blindness": { + "candidate_identity_included": False, + "frozen_predictions_included": False, + "model_scores_included": False, + "semantic_class_task_included": False, + "strata_included": False, + }, + "authority": dict(_AUTHORITY), + "access": "neutral-workflow-status-read-only", + } + + +def _workflow_snapshot(store: _WorkflowStore, pack_id: str) -> dict[str, object]: + corrections = store.corrections(pack_id) + correction = corrections[0] if len(corrections) == 1 else None + reviews = store.reviews(pack_id) + frozen = [row for row in reviews if row["state"] == "frozen"] + adjudications = store.adjudications(pack_id) + adjudication = adjudications[0] if len(adjudications) == 1 else None + state = "prepared" + next_action = "freeze two distinct prediction-blind reviews" + if len(frozen) == 2: + state = "two-reviewers-frozen" + next_action = "complete and freeze class-free adjudication" + if adjudication is not None and adjudication["state"] == "adjudication-frozen": + state = "adjudication-frozen" + next_action = "run one-shot frozen evaluation" + if adjudication is not None and adjudication["state"] == "evaluated": + state = "evaluated" + next_action = "inspect bounded M4.8 quality gates" + return { + "state": state, + "next_action": next_action, + "correction_state": correction["state"] if correction else "not-started", + "correction_reviewed_clip_count": ( + correction["progress"]["reviewed_clip_count"] if correction else 0 + ), + "correction_complete": bool(correction and correction["progress"]["complete"]), + "review_slot_count": len(reviews), + "frozen_reviewer_count": len(frozen), + "adjudication_frozen": state in {"adjudication-frozen", "evaluated"}, + "truth_seal_id": adjudication.get("truth_seal_id") if adjudication else None, + "quality_result_id": adjudication.get("quality_result_id") if adjudication else None, + } + + +def _neutral_source_clips(pack: M48ObjectQualityPack) -> list[dict[str, Any]]: + package = _read_json(pack.result_root / M48_REVIEWER_PACKAGE_NAME) + if ( + package.get("pack_id") != pack.result_id + or package.get("state") != "prediction-blind-neutral-source-projection" + or package.get("strata_included") is not False + or package.get("frozen_predictions_included") is not False + or package.get("candidate_identity_included") is not False + or not isinstance(package.get("clips"), list) + ): + raise HTTPException(status_code=409, detail="M4.8 neutral source package is invalid") + clips: list[dict[str, Any]] = [] + for raw_clip in package["clips"]: + if not isinstance(raw_clip, dict) or not isinstance(raw_clip.get("frames"), list): + raise HTTPException(status_code=409, detail="M4.8 neutral source package is invalid") + clip_id = raw_clip.get("clip_id") + start = raw_clip.get("start_sequence") + end = raw_clip.get("end_sequence") + frames: list[dict[str, Any]] = [] + for raw_frame in raw_clip["frames"]: + if not isinstance(raw_frame, dict): + raise HTTPException( + status_code=409, detail="M4.8 neutral source package is invalid" + ) + sequence = raw_frame.get("sequence") + source_time_ns = raw_frame.get("source_time_ns") + sha256 = raw_frame.get("camera_fragment_sha256") + if ( + not isinstance(sequence, int) + or not isinstance(source_time_ns, int) + or not isinstance(sha256, str) + or _SHA256.fullmatch(sha256) is None + ): + raise HTTPException( + status_code=409, detail="M4.8 neutral source package is invalid" + ) + frames.append( + { + "sequence": sequence, + "source_time_ns": source_time_ns, + "camera_fragment_sha256": sha256, + } + ) + if ( + not isinstance(clip_id, str) + or not isinstance(start, int) + or not isinstance(end, int) + or [frame["sequence"] for frame in frames] != list(range(start, end + 1)) + ): + raise HTTPException(status_code=409, detail="M4.8 neutral source package is invalid") + clips.append( + { + "clip_id": clip_id, + "start_sequence": start, + "end_sequence": end, + "frames": frames, + } + ) + return clips + + +def _camera_playback_projection( + pack: M48ObjectQualityPack, + clips: tuple[dict[str, Any], ...], + *, + provider: CameraPlaybackProvider, +) -> dict[str, object]: + """Bind the shared buffered player to the exact fragments frozen by M4.8.""" + + identity = pack.manifest.get("identity") + source_identity = identity.get("source") if isinstance(identity, dict) else None + session_id = ( + source_identity.get("source_session_id") if isinstance(source_identity, dict) else None + ) + if not isinstance(session_id, str): + raise HTTPException(status_code=409, detail="M4.8 camera source binding is invalid") + try: + source = provider(session_id) + except (OSError, RuntimeError, ValueError) as exc: + raise HTTPException(status_code=409, detail="M4.8 camera playback is unavailable") from exc + if ( + source.session_id != session_id + or source.synchronization != "host-arrival-best-effort" + or not source.media_type.startswith("video/mp4;") + or source.segment_count < 1 + or len(source.segment_start_times_ns) != source.segment_count + or not _SHA256.fullmatch(source.generation_sha256) + ): + raise HTTPException(status_code=409, detail="M4.8 camera playback contract changed") + for clip in clips: + for frame in clip["frames"]: + sequence = frame["sequence"] + if ( + sequence > source.segment_count + or source.segment_sha256s[sequence - 1] != frame["camera_fragment_sha256"] + ): + raise HTTPException( + status_code=409, + detail="M4.8 frozen camera fragment changed", + ) + if ( + abs(source.segment_start_times_ns[sequence - 1] - frame["source_time_ns"]) + > _CAMERA_SOURCE_TIME_TOLERANCE_NS + ): + raise HTTPException( + status_code=409, + detail="M4.8 frozen camera timeline changed", + ) + encoded_session = quote(source.session_id, safe="") + encoded_artifact = quote(source.artifact_id, safe="") + return { + "schema_version": _CAMERA_PLAYBACK_SCHEMA, + "source_id": source.public_source_id, + "label": "Записанная RIGHT камера", + "manifest_url": ( + f"/api/v1/observation-sessions/{encoded_session}/media/{encoded_artifact}/manifest" + ), + "manifest_generation_sha256": source.generation_sha256, + "byte_length": source.byte_length, + "media_type": "video/mp4", + "timeline_start_seconds": source.timeline_start_seconds, + "timeline_end_seconds": source.timeline_end_seconds, + "segment_count": source.segment_count, + "seekable": True, + "synchronization": "host-arrival-best-effort", + "transport": "recorded-fmp4-manifest", + "fragment_binding": "pack-frozen-sha256-verified", + } + + +def _blank_clips(pack: M48ObjectQualityPack) -> list[dict[str, object]]: + return [ + { + "clip_id": clip["clip_id"], + "start_sequence": clip["start_sequence"], + "end_sequence": clip["end_sequence"], + "review_state": "pending", + "no_object": None, + "tracklets": [], + "notes": None, + } + for clip in pack.clips + ] + + +def _correction_assistance() -> dict[str, object]: + return { + "mode": "frozen-candidate-seeded", + "candidate_predictions_seen": True, + "model_scores_seen": False, + "semantic_class_task_seen": False, + "independent_truth_eligible": False, + } + + +def _seeded_correction_clips(pack: M48ObjectQualityPack) -> list[dict[str, object]]: + """Project immutable Worker 006 predictions into editable one-frame objects.""" + + rows_by_clip: dict[str, list[Mapping[str, Any]]] = {} + for row in pack.predictions: + clip_id = row.get("clip_id") + if not isinstance(clip_id, str): + raise HTTPException(status_code=409, detail="M4.8 prediction clip is invalid") + rows_by_clip.setdefault(clip_id, []).append(row) + result: list[dict[str, object]] = [] + for clip in pack.clips: + clip_id = clip.get("clip_id") + start = clip.get("start_sequence") + end = clip.get("end_sequence") + if not isinstance(clip_id, str) or not isinstance(start, int) or not isinstance(end, int): + raise HTTPException(status_code=409, detail="M4.8 frozen clip is invalid") + tracklets: list[dict[str, object]] = [] + for row in sorted(rows_by_clip.get(clip_id, []), key=lambda item: int(item["sequence"])): + sequence = row.get("sequence") + objects = row.get("objects") + if ( + not isinstance(sequence, int) + or not start <= sequence <= end + or not isinstance(objects, list) + ): + raise HTTPException(status_code=409, detail="M4.8 prediction row is invalid") + for raw in objects: + if not isinstance(raw, dict): + raise HTTPException(status_code=409, detail="M4.8 prediction object is invalid") + prediction_id = raw.get("prediction_id") + extent = raw.get("extent_xyxy") + geometry = raw.get("geometry_association") + freshness = raw.get("freshness") + motion = raw.get("motion") + threat = raw.get("threat") + if ( + not isinstance(prediction_id, str) + or _ACTOR_ID.fullmatch(prediction_id) is None + or not isinstance(extent, list) + or geometry not in {"associated", "unavailable", "ineligible", "unknown"} + or freshness not in {"current", "held", "stale", "unavailable"} + or motion not in {"moving", "static", "unknown", "unsupported"} + or threat not in {"threat", "not-threat", "unknown"} + ): + raise HTTPException(status_code=409, detail="M4.8 prediction object is invalid") + tracklets.append( + { + "object_id": prediction_id, + "first_sequence": sequence, + "last_sequence": sequence, + "keyframes": [ + { + "sequence": sequence, + "extent_xyxy": _normalized_extent(extent), + "visibility": "visible", + } + ], + "state_segments": [ + { + "start_sequence": sequence, + "end_sequence": sequence, + "geometry_association": geometry, + "freshness": freshness, + "motion": motion, + "threat": threat, + "critical_corridor_obstacle": False, + } + ], + "notes": None, + } + ) + object_ids = [str(item["object_id"]) for item in tracklets] + if len(object_ids) != len(set(object_ids)) or len(tracklets) > 1024: + raise HTTPException(status_code=409, detail="M4.8 prediction identity collided") + result.append( + { + "clip_id": clip_id, + "start_sequence": start, + "end_sequence": end, + "review_state": "pending", + "no_object": None, + "tracklets": tracklets, + "notes": None, + } + ) + return result + + +def _correction_seed_summary( + pack: M48ObjectQualityPack, + clips: list[dict[str, object]], +) -> dict[str, object]: + identity = pack.manifest.get("identity") + freeze = identity.get("freeze") if isinstance(identity, dict) else None + digest = freeze.get("prediction_rows_sha256") if isinstance(freeze, dict) else None + if not isinstance(digest, str) or _SHA256.fullmatch(digest) is None: + raise HTTPException(status_code=409, detail="M4.8 frozen prediction binding is invalid") + return { + "worker_id": "006", + "clip_count": len(clips), + "frame_count": len(pack.frame_references), + "object_count": sum(len(clip["tracklets"]) for clip in clips), + "prediction_rows_sha256": digest, + } + + +def _normalize_correction_clips( + pack: M48ObjectQualityPack, + values: list[M48ClipReviewRequest], +) -> list[dict[str, Any]]: + if len(values) != len(pack.clips): + raise HTTPException(status_code=422, detail="M4.8 clip coverage must be complete") + result: list[dict[str, Any]] = [] + for value, source in zip(values, pack.clips, strict=True): + if ( + value.clip_id != source.get("clip_id") + or value.start_sequence != source.get("start_sequence") + or value.end_sequence != source.get("end_sequence") + ): + raise HTTPException(status_code=422, detail="M4.8 clip identity changed") + if value.review_state not in {"pending", "reviewed"}: + raise HTTPException(status_code=422, detail="M4.8 correction state is invalid") + if value.review_state == "pending" and value.no_object is not None: + raise HTTPException( + status_code=422, + detail="Pending M4.8 correction cannot assert no-object state", + ) + if value.review_state == "reviewed" and ( + value.no_object is None or value.no_object != (len(value.tracklets) == 0) + ): + raise HTTPException( + status_code=422, + detail="M4.8 no-object state conflicts with corrected objects", + ) + tracklets = [ + _normalize_tracklet( + item, + clip_start=value.start_sequence, + clip_end=value.end_sequence, + ) + for item in value.tracklets + ] + object_ids = [item["object_id"] for item in tracklets] + if len(object_ids) != len(set(object_ids)): + raise HTTPException(status_code=422, detail="M4.8 tracklet identity is duplicated") + tracklets.sort(key=lambda item: str(item["object_id"])) + result.append( + { + "clip_id": value.clip_id, + "start_sequence": value.start_sequence, + "end_sequence": value.end_sequence, + "review_state": value.review_state, + "no_object": value.no_object, + "tracklets": tracklets, + "notes": _optional_text(value.notes), + } + ) + return result + + +def _correction_evidence_summary( + seeded: list[dict[str, object]], + corrected: list[dict[str, Any]], +) -> dict[str, object]: + seed_index = { + (str(clip["clip_id"]), str(tracklet["object_id"])): tracklet + for clip in seeded + for tracklet in clip["tracklets"] + } + corrected_index = { + (str(clip["clip_id"]), str(tracklet["object_id"])): tracklet + for clip in corrected + for tracklet in clip["tracklets"] + } + retained = seed_index.keys() & corrected_index.keys() + deleted = seed_index.keys() - corrected_index.keys() + added = corrected_index.keys() - seed_index.keys() + unchanged = {key for key in retained if seed_index[key] == corrected_index[key]} + modified = retained - unchanged + seed_count = len(seed_index) + corrected_count = len(corrected_index) + return { + "seed_object_count": seed_count, + "corrected_object_count": corrected_count, + "confirmed_candidate_count": len(retained), + "unchanged_candidate_count": len(unchanged), + "modified_candidate_count": len(modified), + "false_positive_removed_count": len(deleted), + "missed_object_added_count": len(added), + "candidate_confirmation_rate": round(len(retained) / seed_count, 6) if seed_count else 1.0, + "assisted_recall_proxy": round(len(retained) / (len(retained) + len(added)), 6) + if retained or added + else 1.0, + "independent_truth": False, + } + + +def _normalize_clips( + pack: M48ObjectQualityPack, + values: list[M48ClipReviewRequest], + *, + complete_state: Literal["reviewed", "adjudicated"], + spatial_evidence_available: bool, +) -> list[dict[str, Any]]: + if len(values) != len(pack.clips): + raise HTTPException(status_code=422, detail="M4.8 clip coverage must be complete") + result: list[dict[str, Any]] = [] + for value, source in zip(values, pack.clips, strict=True): + if ( + value.clip_id != source.get("clip_id") + or value.start_sequence != source.get("start_sequence") + or value.end_sequence != source.get("end_sequence") + ): + raise HTTPException(status_code=422, detail="M4.8 clip identity changed") + if value.review_state not in {"pending", complete_state}: + raise HTTPException(status_code=422, detail="M4.8 clip state is invalid for this stage") + if value.review_state == "pending": + if value.no_object is not None or value.tracklets: + raise HTTPException( + status_code=422, detail="Pending M4.8 clips cannot carry labels" + ) + elif value.no_object is None or value.no_object != (len(value.tracklets) == 0): + raise HTTPException( + status_code=422, detail="M4.8 no-object state conflicts with tracklets" + ) + tracklets = [ + _normalize_tracklet( + item, + clip_start=value.start_sequence, + clip_end=value.end_sequence, + ) + for item in value.tracklets + ] + if tracklets and not spatial_evidence_available: + _require_unavailable_spatial_states(tracklets) + object_ids = [item["object_id"] for item in tracklets] + if len(object_ids) != len(set(object_ids)): + raise HTTPException(status_code=422, detail="M4.8 tracklet identity is duplicated") + tracklets.sort(key=lambda item: str(item["object_id"])) + result.append( + { + "clip_id": value.clip_id, + "start_sequence": value.start_sequence, + "end_sequence": value.end_sequence, + "review_state": value.review_state, + "no_object": value.no_object, + "tracklets": tracklets, + "notes": _optional_text(value.notes), + } + ) + return result + + +def _normalize_tracklet( + value: M48TrackletRequest, + *, + clip_start: int, + clip_end: int, +) -> dict[str, Any]: + object_id = value.object_id.strip() + if _ACTOR_ID.fullmatch(object_id) is None: + raise HTTPException(status_code=422, detail="M4.8 tracklet id is invalid") + first = value.first_sequence + last = value.last_sequence + if not clip_start <= first <= last <= clip_end: + raise HTTPException(status_code=422, detail="M4.8 tracklet lifetime is outside its clip") + keyframes: list[dict[str, Any]] = [] + previous = first - 1 + for keyframe in value.keyframes: + if keyframe.sequence <= previous or not first <= keyframe.sequence <= last: + raise HTTPException(status_code=422, detail="M4.8 keyframes must be strictly ordered") + previous = keyframe.sequence + keyframes.append( + { + "sequence": keyframe.sequence, + "extent_xyxy": _normalized_extent(keyframe.extent_xyxy), + "visibility": keyframe.visibility, + } + ) + if keyframes[0]["sequence"] != first or keyframes[-1]["sequence"] != last: + raise HTTPException( + status_code=422, detail="M4.8 keyframes must bind the tracklet lifetime" + ) + segments: list[dict[str, Any]] = [] + expected_start = first + for segment in value.state_segments: + if ( + segment.start_sequence != expected_start + or not segment.start_sequence <= segment.end_sequence <= last + ): + raise HTTPException(status_code=422, detail="M4.8 state segments must be contiguous") + segments.append(segment.model_dump()) + expected_start = segment.end_sequence + 1 + if segments[-1]["end_sequence"] != last: + raise HTTPException(status_code=422, detail="M4.8 state segments must cover the tracklet") + return { + "object_id": object_id, + "first_sequence": first, + "last_sequence": last, + "keyframes": keyframes, + "state_segments": segments, + "notes": _optional_text(value.notes), + } + + +def _project_review(value: Mapping[str, Any]) -> dict[str, object]: + return { + "schema_version": _REVIEW_SESSION_SCHEMA, + "pack_id": value["pack_id"], + "session_id": value["session_id"], + "reviewer_slot": value["reviewer_slot"], + "title": value["title"], + "revision": value["revision"], + "state": value["state"], + "created_at_utc": value["created_at_utc"], + "updated_at_utc": value["updated_at_utc"], + "clips": copy.deepcopy(value["clips"]), + "progress": copy.deepcopy(value["progress"]), + "reviewer_id": value.get("reviewer_id"), + "submitted_at_utc": value.get("submitted_at_utc"), + "submission_sha256": value.get("submission_sha256"), + "blindness": dict(_BLINDNESS), + "authority": dict(_AUTHORITY), + "access": "capability-protected-prediction-blind-review", + } + + +def _project_correction(value: Mapping[str, Any]) -> dict[str, object]: + return { + "schema_version": _CORRECTION_SESSION_SCHEMA, + "pack_id": value["pack_id"], + "session_id": value["session_id"], + "title": value["title"], + "revision": value["revision"], + "state": value["state"], + "created_at_utc": value["created_at_utc"], + "updated_at_utc": value["updated_at_utc"], + "clips": copy.deepcopy(value["clips"]), + "progress": copy.deepcopy(value["progress"]), + "seed_summary": copy.deepcopy(value["seed_summary"]), + "evidence_summary": copy.deepcopy(value.get("evidence_summary")), + "reviewer_id": value.get("reviewer_id"), + "submitted_at_utc": value.get("submitted_at_utc"), + "submission_sha256": value.get("submission_sha256"), + "assistance": _correction_assistance(), + "authority": dict(_AUTHORITY), + "access": "capability-protected-candidate-assisted-correction", + } + + +def _project_adjudication( + value: Mapping[str, Any], + reviews: list[dict[str, Any]], + store: _WorkflowStore, +) -> dict[str, object]: + review_inputs: list[dict[str, object]] = [] + root = store.pack_root(str(value["pack_id"]), create=False) + if root is not None: + for row in reviews: + document = _read_json(_frozen_document_path(root, row)) + review_inputs.append( + { + "reviewer_slot": row["reviewer_slot"], + "submission_sha256": row["submission_sha256"], + "clips": copy.deepcopy(document["clips"]), + } + ) + return { + "schema_version": _ADJUDICATION_SESSION_SCHEMA, + "pack_id": value["pack_id"], + "session_id": value["session_id"], + "title": value["title"], + "revision": value["revision"], + "state": value["state"], + "created_at_utc": value["created_at_utc"], + "updated_at_utc": value["updated_at_utc"], + "clips": copy.deepcopy(value["clips"]), + "progress": copy.deepcopy(value["progress"]), + "review_inputs": review_inputs, + "review_submission_sha256": copy.deepcopy(value["review_submission_sha256"]), + "adjudicator_id": value.get("adjudicator_id"), + "sealed_at_utc": value.get("sealed_at_utc"), + "truth_seal_id": value.get("truth_seal_id"), + "quality_result_id": value.get("quality_result_id"), + "evaluation_receipt_id": value.get("evaluation_receipt_id"), + "evaluated_at_utc": value.get("evaluated_at_utc"), + "frozen_predictions_included": False, + "semantic_class_task_included": False, + "blindness": dict(_BLINDNESS), + "authority": dict(_AUTHORITY), + "access": "capability-protected-prediction-blind-adjudication", + } + + +def _frozen_reviews(store: _WorkflowStore, pack_id: str) -> list[dict[str, Any]]: + rows = [row for row in store.reviews(pack_id) if row["state"] == "frozen"] + reviewer_ids = [row.get("reviewer_id") for row in rows] + digests = [row.get("submission_sha256") for row in rows] + if ( + len(rows) > 2 + or len(reviewer_ids) != len(set(reviewer_ids)) + or any(not isinstance(value, str) or _SHA256.fullmatch(value) is None for value in digests) + ): + raise HTTPException(status_code=409, detail="M4.8 frozen review set is invalid") + root = store.pack_root(pack_id, create=False) + if root is None and rows: + raise HTTPException(status_code=409, detail="M4.8 frozen review set is unavailable") + if root is not None: + for row in rows: + document = _read_json(_frozen_document_path(root, row)) + if ( + document.get("schema_version") != M48_REVIEW_SCHEMA + or document.get("pack_id") != pack_id + or document.get("blindness") != _BLINDNESS + or _canonical_sha256(document) != row["submission_sha256"] + ): + raise HTTPException(status_code=409, detail="M4.8 frozen review set changed") + return rows + + +def _frozen_document_path(root: Path, review: Mapping[str, Any]) -> Path: + name = review.get("frozen_document_name") + if not isinstance(name, str) or Path(name).name != name: + raise HTTPException(status_code=409, detail="M4.8 frozen review document is unavailable") + path = root / name + if not path.is_file() or path.is_symlink() or path.resolve(strict=True).parent != root: + raise HTTPException(status_code=409, detail="M4.8 frozen review document is unavailable") + return path + + +def _ensure_capability(path: Path, *, schema: str, owner_id: str) -> str: + sidecar = path.with_suffix(".capability.json") + if sidecar.exists(): + try: + value = _read_json(sidecar) + capability = value.get("capability") + if value.get("schema_version") != schema or value.get("owner_id") != owner_id: + raise ValueError("capability identity changed") + if not isinstance(capability, str): + raise ValueError("capability is missing") + return capability + except (OSError, ValueError) as exc: + raise HTTPException(status_code=409, detail="M4.8 capability is damaged") from exc + capability = secrets.token_urlsafe(32) + write_json_atomic( + sidecar, + {"schema_version": schema, "owner_id": owner_id, "capability": capability}, + ) + sidecar.chmod(0o600) + return capability + + +def _require_capability(path: Path, supplied: str | None, schema: str) -> None: + sidecar = path.with_suffix(".capability.json") + try: + value = _read_json(sidecar) + except (OSError, ValueError): + raise HTTPException(status_code=403, detail="M4.8 capability was rejected") from None + expected = value.get("capability") + if ( + value.get("schema_version") != schema + or not isinstance(supplied, str) + or not isinstance(expected, str) + or not secrets.compare_digest(supplied, expected) + ): + raise HTTPException(status_code=403, detail="M4.8 capability was rejected") + + +def _progress(clips: list[dict[str, Any]], complete_state: str) -> dict[str, object]: + completed = sum(1 for clip in clips if clip["review_state"] == complete_state) + return { + "reviewed_clip_count": completed, + "clip_count": len(clips), + "complete": completed == len(clips), + } + + +def _tracklet_contract() -> dict[str, object]: + return { + "contract_id": "m48-class-free-object-tracklet/v1", + "label_unit": "clip-local-object-tracklet", + "semantic_classes_allowed": False, + "extent": "normalized-xyxy-sparse-keyframes", + "extent_interpolation": "linear-between-bounding-keyframes", + "visibility": ["occluded", "partial", "visible"], + "state_segments": { + "coverage": "contiguous-full-tracklet-lifetime", + "geometry_association": ["associated", "ineligible", "unavailable", "unknown"], + "freshness": ["current", "held", "stale", "unavailable"], + "motion": ["moving", "static", "unknown", "unsupported"], + "threat": ["not-threat", "threat", "unknown"], + "critical_corridor_obstacle": "boolean", + }, + } + + +def _sanitize_spatial_evidence( + value: Mapping[str, Any], + *, + pack: M48ObjectQualityPack, + clip_id: str, + frame: Mapping[str, Any], +) -> dict[str, object]: + """Admit only the canonical current-point/rig/corridor projection.""" + + if not isinstance(value, Mapping): + raise ValueError("spatial evidence must be an object") + allowed = { + "schema_version", + "pack_id", + "clip_id", + "sequence", + "source_time_ns", + "point_cloud_body_xyz_m", + "rig", + "corridor", + "occupied_voxel_size_m", + "source_available", + "body_frame_available", + "candidate_identity_included", + "graph_boxes_ids_scores_included", + "frozen_predictions_included", + "strata_included", + "authority", + } + if set(value) - allowed: + raise ValueError("spatial evidence contains non-canonical fields") + if ( + value.get("schema_version") != _SPATIAL_FRAME_SCHEMA + or value.get("pack_id") != pack.result_id + or value.get("clip_id") != clip_id + or value.get("sequence") != frame["sequence"] + or value.get("source_time_ns") != frame["source_time_ns"] + or not isinstance(value.get("point_cloud_body_xyz_m"), list) + or not isinstance(value.get("rig"), Mapping) + or not isinstance(value.get("corridor"), Mapping) + or not isinstance(value.get("source_available"), bool) + or not isinstance(value.get("body_frame_available"), bool) + or not isinstance(value.get("occupied_voxel_size_m"), int | float) + or value.get("candidate_identity_included") is not False + or value.get("graph_boxes_ids_scores_included") is not False + or value.get("frozen_predictions_included") is not False + or value.get("strata_included") is not False + ): + raise ValueError("spatial evidence identity or shape changed") + for point in value["point_cloud_body_xyz_m"]: + if ( + not isinstance(point, list) + or len(point) != 3 + or any(not isinstance(axis, int | float) for axis in point) + ): + raise ValueError("spatial point cloud is invalid") + _reject_graph_material(value) + # A canonical JSON round-trip both detaches provider-owned containers and rejects NaN/bytes. + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False) + projected = json.loads(encoded) + if not isinstance(projected, dict): + raise ValueError("spatial evidence projection is invalid") + projected.update( + { + "candidate_identity_included": False, + "graph_boxes_ids_scores_included": False, + "frozen_predictions_included": False, + "strata_included": False, + "access": "prediction-free-current-spatial-evidence-read-only", + } + ) + return projected + + +def _reject_graph_material(value: object) -> None: + forbidden = { + "prediction", + "predictions", + "score", + "scores", + "strata", + "split", + "graph", + "boxes", + "box", + "object_id", + "prediction_id", + "metric_obstacles", + } + if isinstance(value, Mapping): + for key, nested in value.items(): + if not isinstance(key, str) or key.lower() in forbidden: + raise ValueError("spatial evidence contains graph material") + _reject_graph_material(nested) + elif isinstance(value, list): + for nested in value: + _reject_graph_material(nested) + + +def _evidence_capabilities(*, camera_available: bool, spatial_available: bool) -> dict[str, object]: + """Declare the current honest review surface without leaking M4 graph output.""" + + return { + "state": ( + "prediction-free-spatial-evidence-available" + if spatial_available + else "camera-bound-raw-spatial-evidence-unavailable" + ), + "camera_epoch_time": camera_available, + "current_point_cloud_body_xyz_m": spatial_available, + "rig": spatial_available, + "virtual_corridor": spatial_available, + "raw_lidar": False, + "graph_output": False, + "graph_boxes_ids_scores": False, + "label_authority": { + "obstacle_presence_and_extent": camera_available, + "geometry_association": spatial_available, + "freshness": spatial_available, + "motion": spatial_available, + "threat": spatial_available, + "critical_corridor_obstacle": spatial_available, + }, + "fail_closed_reason": None + if spatial_available + else ( + "A dedicated prediction-free spatial evidence projection is not wired; " + "the M4 replay frame is intentionally not reused because it contains graph output." + ), + } + + +def _require_unavailable_spatial_states(tracklets: list[dict[str, Any]]) -> None: + """Allow camera extents while failing closed on unavailable spatial evidence.""" + + for tracklet in tracklets: + for segment in tracklet["state_segments"]: + if ( + segment["geometry_association"] != "unavailable" + or segment["freshness"] != "unavailable" + or segment["motion"] != "unsupported" + or segment["threat"] != "unknown" + or segment["critical_corridor_obstacle"] is not False + ): + raise HTTPException( + status_code=409, + detail=( + "M4.8 raw LiDAR/local-surface/pose/corridor evidence is not yet " + "available; spatial-state claims fail closed" + ), + ) + + +def _pack_candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + path + for path in root.iterdir() + if path.is_dir() and not path.is_symlink() and _PACK_ID.fullmatch(path.name) + ), + key=lambda path: path.stat().st_mtime_ns, + reverse=True, + ) + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + root = value.expanduser().absolute() + if root.is_symlink(): + return None + try: + resolved = root.resolve(strict=True) + except OSError: + return None + return resolved if resolved.is_dir() else None + + +def _mutable_root(provider: RootProvider, label: str) -> Path: + value = provider() + if value is None: + raise HTTPException(status_code=503, detail=f"{label} is not configured") + root = value.expanduser().absolute() + if root.is_symlink(): + raise HTTPException(status_code=503, detail=f"{label} is unavailable") + root.mkdir(mode=0o700, parents=True, exist_ok=True) + resolved = root.resolve(strict=True) + if not resolved.is_dir(): + raise HTTPException(status_code=503, detail=f"{label} is unavailable") + return resolved + + +def _read_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("JSON document must be an object") + return value + + +def _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise HTTPException(status_code=409, detail=f"{label} is invalid") + return value + + +def _canonical_sha256(value: object) -> str: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _normalized_extent(values: list[float]) -> list[float]: + extent = [float(item) for item in values] + if ( + any(not 0.0 <= item <= 1.0 for item in extent) + or extent[0] >= extent[2] + or extent[1] >= extent[3] + ): + raise HTTPException(status_code=422, detail="M4.8 extent must be normalized xyxy") + return extent + + +def _optional_text(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _validate_pack_id(value: str) -> None: + if _PACK_ID.fullmatch(value) is None: + raise HTTPException(status_code=404, detail="M4.8 pack was not found") + + +def _validate_actor_id(value: str, label: str) -> None: + if _ACTOR_ID.fullmatch(value) is None: + raise HTTPException(status_code=422, detail=f"M4.8 {label} id is invalid") + + +def _validate_operation_key(value: str) -> None: + if _OPERATION_KEY.fullmatch(value) is None: + raise HTTPException(status_code=422, detail="M4.8 idempotency key is invalid") + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") diff --git a/tests/test_advanced_laboratory_api.py b/tests/test_advanced_laboratory_api.py index 0798297..a2a7b48 100644 --- a/tests/test_advanced_laboratory_api.py +++ b/tests/test_advanced_laboratory_api.py @@ -9,7 +9,11 @@ from fastapi.routing import APIRoute from pytest import MonkeyPatch import k1link.web.advanced_laboratory_api as advanced_api -from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry +from k1link.laboratory import ( + LaboratoryEvidenceDefinition, + LaboratoryEvidenceRegistry, + LaboratoryEvidenceVariant, +) from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router @@ -74,6 +78,79 @@ def test_advanced_index_is_empty_when_not_configured() -> None: } +def test_advanced_index_projects_one_most_mature_lifecycle_phase(tmp_path: Path) -> None: + variants = ( + LaboratoryEvidenceVariant( + phase="review", + runtime_relative_root=PurePosixPath("packs"), + result_id_prefix="quality-pack", + document_name="manifest.json", + result_schema_version="missioncore.quality-pack/v1", + ), + LaboratoryEvidenceVariant( + phase="result", + runtime_relative_root=PurePosixPath("results"), + result_id_prefix="quality-result", + document_name="manifest.json", + result_schema_version="missioncore.quality-result/v1", + ), + ) + registry = LaboratoryEvidenceRegistry( + definitions=( + LaboratoryEvidenceDefinition( + work_id="quality-lab", + runtime_relative_root=variants[-1].runtime_relative_root, + result_id_prefix=variants[-1].result_id_prefix, + document_name=variants[-1].document_name, + result_schema_version=variants[-1].result_schema_version, + lifecycle_variants=variants, + ), + ) + ) + + def publish(variant: LaboratoryEvidenceVariant, digest: str, created_at: str) -> str: + result_id = f"{variant.result_id_prefix}-{digest}" + result_root = variant.result_root(tmp_path) / result_id + result_root.mkdir(parents=True) + (result_root / variant.document_name).write_text( + json.dumps( + { + "schema_version": variant.result_schema_version, + "result_id": result_id, + "identity_sha256": digest, + "identity": { + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + }, + "created_at_utc": created_at, + } + ), + encoding="utf-8", + ) + return result_id + + pack_id = publish(variants[0], "a" * 64, "2026-08-24T10:00:00Z") + router = build_advanced_laboratory_router( + evidence_registry=registry, + evidence_runtime_root_provider=lambda: tmp_path, + ) + route = _endpoint(router, "/api/v1/laboratory/advanced-index") + assert route()["items"][0]["result_id"] == pack_id # type: ignore[index,operator] + + result_id = publish(variants[1], "b" * 64, "2026-08-24T11:00:00Z") + index = route() # type: ignore[operator] + assert index["items"] == [ # type: ignore[index] + { + "work_id": "quality-lab", + "result_id": result_id, + "created_at_utc": "2026-08-24T11:00:00Z", + "access": "read-only", + } + ] + + def test_advanced_index_includes_valid_l31_identity( tmp_path: Path, monkeypatch: MonkeyPatch, diff --git a/tests/test_laboratory_evidence_registry.py b/tests/test_laboratory_evidence_registry.py index 395acb5..92eafd3 100644 --- a/tests/test_laboratory_evidence_registry.py +++ b/tests/test_laboratory_evidence_registry.py @@ -127,7 +127,7 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None: repository_root / "config" / "laboratories" ) - assert len(registry.definitions) == 34 + assert len(registry.definitions) == 36 assert {item.work_id for item in registry.definitions} >= { "e31-source-binding", "e46j-raw-fisheye-realtime", @@ -139,4 +139,15 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None: "l34f-adjudicated-reference", "m4-replay-threat", "m47-reference-graph-shadow", + "m48-object-centric-quality", + "m48-small-static-passage-regression", } + m48 = next( + item for item in registry.definitions + if item.work_id == "m48-object-centric-quality" + ) + assert [variant.phase for variant in m48.evidence_variants] == ["review", "result"] + assert [variant.result_id_prefix for variant in m48.evidence_variants] == [ + "m48-object-quality-pack", + "m48-object-quality-result", + ] diff --git a/tests/test_laboratory_execution.py b/tests/test_laboratory_execution.py index 286d322..18e26aa 100644 --- a/tests/test_laboratory_execution.py +++ b/tests/test_laboratory_execution.py @@ -4,6 +4,7 @@ import hashlib import json from dataclasses import replace from pathlib import Path +from types import SimpleNamespace import pytest @@ -90,6 +91,8 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: evidence, execution = _registries() assert {row.work_id for row in execution.definitions} == { + "m48-small-static-passage-regression", + "m48-object-centric-quality", "m4-replay-threat", "e33-worker-shadow", "e35-degradation-recovery", @@ -97,6 +100,12 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: "e47-semantic-slam-shadow", } by_work_id = {row.work_id: row for row in execution.definitions} + assert by_work_id["m48-small-static-passage-regression"].evidence_contract == ( + "missioncore.m48-small-static-passage-regression-result/v1" + ) + assert by_work_id["m48-object-centric-quality"].evidence_contract == ( + "missioncore.m48-object-centric-quality-result/v1" + ) assert by_work_id["e47-semantic-slam-shadow"].lifecycle == "experimental" assert by_work_id["e47-semantic-slam-shadow"].isolation == "bounded-adapter" assert all( @@ -193,6 +202,65 @@ def test_runner_rejects_undeclared_input_before_adapter(tmp_path: Path) -> None: assert called is False +def test_m48_evaluation_uses_registered_adapter_and_common_receipt( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + evidence, execution = _registries() + pack_root = tmp_path / "pack" + truth_root = tmp_path / "truth" + pack_root.mkdir() + truth_root.mkdir() + adapter_result = _evidence_result( + tmp_path / "results", + work_id="m48-object-centric-quality", + ) + + def score(**kwargs: Path) -> SimpleNamespace: + assert kwargs == { + "pack_root": pack_root, + "truth_seal_root": truth_root, + "output_root": tmp_path / "results", + } + return SimpleNamespace( + result_root=adapter_result.result_root, + result_id=adapter_result.result_id, + ) + + monkeypatch.setattr( + "k1link.laboratory.m48_object_quality.score_m48_object_quality", + score, + ) + runner = LaboratoryRunner( + registry=execution, + evidence_registry=evidence, + sink=JsonlPipelineTelemetrySink(tmp_path / "pipeline.jsonl"), + ) + request = LaboratoryRunRequest( + work_id="m48-object-centric-quality", + run_id="m48-evaluation-fixture", + request_id="evaluate-once", + contour_id="mission-core-lab", + agent_id="local-control-plane", + node_id="fixture-node", + source_id="m48-pack-fixture", + source_package_id="m48-truth-fixture", + method_id="m48-object-centric-quality/v1", + inputs={"pack_root": pack_root, "truth_seal_root": truth_root}, + output_root=tmp_path / "results", + receipt_root=tmp_path / "receipts", + ) + + result = runner.run(request) + + assert result.result_id == adapter_result.result_id + assert result.receipt["adapter_id"] == "canonical.m48-object-centric-quality/v1" + assert result.receipt["contracts"]["evidence"] == ( + "missioncore.m48-object-centric-quality-result/v1" + ) + assert (result.receipt_root / "receipt.json").is_file() + + def _canonical_json(value: object) -> bytes: return json.dumps( value, diff --git a/tests/test_m48_object_quality.py b/tests/test_m48_object_quality.py new file mode 100644 index 0000000..49f13da --- /dev/null +++ b/tests/test_m48_object_quality.py @@ -0,0 +1,613 @@ +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +import k1link.laboratory.m48_object_quality as m48 +from k1link.laboratory.evidence_registry import LaboratoryEvidenceRegistry +from k1link.laboratory.evidence_report import verify_laboratory_evidence_result + + +def _write_json(path: Path, value: object) -> None: + path.write_text(json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n") + + +def _frame_catalog() -> list[dict[str, object]]: + return [ + { + "sequence": sequence, + "source_time_ns": (sequence - 1) * 100_000_000, + "camera_fragment_sha256": hashlib.sha256(f"frame-{sequence}".encode()).hexdigest(), + } + for sequence in range(1, 4490) + ] + + +def _clips() -> list[dict[str, object]]: + rows = [] + for index in range(20): + start = 1 + index * 100 + split = "development" if index < 10 else "validation" + split_index = index if index < 10 else index - 10 + rows.append( + { + "clip_id": f"clip-{index:02d}", + "component_id": f"component-{split}-{split_index // 2:02d}", + "route_block": f"route-{split}-{split_index // 3:02d}", + "time_block": f"time-{split}-{split_index // 2:02d}", + "split": split, + "start_sequence": start, + "end_sequence": start + 50, + } + ) + return rows + + +def _clip_fixture_state(clip_id: str) -> dict[str, object]: + local_index = int(clip_id.rsplit("-", 1)[1]) % 10 + if local_index == 0: + return { + "extent_xyxy": [0.2, 0.2, 0.22, 0.22], + "geometry_association": "unknown", + "motion": "unsupported", + "threat": "unknown", + "unknown_causes": [ + "insufficient-geometry-support", + "threat-evidence-insufficient", + ], + } + if local_index == 1: + return { + "extent_xyxy": [0.01, 0.2, 0.2, 0.4], + "geometry_association": "associated", + "motion": "static", + "threat": "not-threat", + "unknown_causes": [], + } + if local_index == 2: + return { + "extent_xyxy": [0.1, 0.1, 0.3, 0.4], + "geometry_association": "associated", + "motion": "moving", + "threat": "threat", + "unknown_causes": [], + } + return { + "extent_xyxy": [0.1, 0.1, 0.3, 0.4], + "geometry_association": "associated", + "motion": "static", + "threat": "not-threat", + "unknown_causes": [], + } + + +def _preparation_provenance() -> dict[str, object]: + return { + "schema_version": m48.M48_PREPARATION_PROVENANCE_SCHEMA, + "adapter": { + "module": "k1link.laboratory.m48_ravnoves00_pack", + "sha256": "1" * 64, + }, + "selection": { + "selection_id": "m48-ravnoves00-balanced-connected-clips/v1", + "sha256": "2" * 64, + }, + "camera_index": { + "source_session_id": "20260720T065719Z_viewer_live", + "sha256": "3" * 64, + "byte_length": 1234, + "frame_count": 4489, + }, + "graph": { + "result_id": "m47-reference-graph-" + "4" * 64, + "manifest_sha256": "5" * 64, + "frames_sha256": "6" * 64, + }, + "threat": { + "result_id": "m4-threat-replay-" + "7" * 64, + "manifest_sha256": "8" * 64, + "frames_sha256": "9" * 64, + }, + "geometry": { + "result_id": "m4-geometry-replay-" + "a" * 64, + "manifest_sha256": "b" * 64, + "frames_sha256": "c" * 64, + }, + } + + +def _prediction_rows( + clips: list[dict[str, object]], + *, + unsafe_free_space: bool, + unsafe_free_space_split: str | None = None, +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for clip in clips: + fixture = _clip_fixture_state(str(clip["clip_id"])) + unsafe = unsafe_free_space and ( + unsafe_free_space_split is None or clip["split"] == unsafe_free_space_split + ) + for sequence in range(int(clip["start_sequence"]), int(clip["end_sequence"]) + 1): + objects = [ + { + "prediction_id": f"prediction-{sequence}", + "extent_xyxy": fixture["extent_xyxy"], + "geometry_association": fixture["geometry_association"], + "freshness": "current", + "motion": fixture["motion"], + "threat": fixture["threat"], + "unknown_causes": fixture["unknown_causes"], + } + ] + rows.append( + { + "sequence": sequence, + "source_time_ns": (sequence - 1) * 100_000_000, + "terminal_outcome": "delivered", + "terminal_reason": None, + "free_space_claimed": unsafe, + "objects": objects, + } + ) + return rows + + +def _fake_m47(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / f"m47-reference-graph-lab-{'a' * 64}" + root.mkdir() + manifest = { + "schema_version": "missioncore.reference-perception-graph-lab/v2", + "accepted": True, + "ground_truth": False, + } + _write_json(root / "manifest.json", manifest) + report = { + "source": { + "source_id": "RAVNOVES00", + "source_session_id": "20260720T065719Z_viewer_live", + "graph_result_id": "m47-reference-graph-" + "b" * 64, + }, + "method": { + "graph_id": "reference-perception-graph/v2", + "run_mode": "lossless-replay", + "canonical_payload_sha256": "c" * 64, + }, + "decision": { + "state": "accepted-reference-graph-replay", + "next_gate": "independent-object-centric-detection-quality", + }, + "acceptance": {"accepted": True}, + "authority": { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, + "ground_truth": False, + }, + } + monkeypatch.setattr( + m48, + "read_m47_reference_graph_lab", + lambda _: SimpleNamespace( + result_id=root.name, + result_root=root, + manifest=manifest, + report=report, + ), + ) + + +def _review_clips(clips: tuple[dict[str, Any], ...], *, state: str) -> list[dict[str, Any]]: + rows = [] + for clip in clips: + fixture = _clip_fixture_state(str(clip["clip_id"])) + start = int(clip["start_sequence"]) + end = int(clip["end_sequence"]) + rows.append( + { + "clip_id": clip["clip_id"], + "start_sequence": start, + "end_sequence": end, + "review_state": state, + "no_object": False, + "tracklets": [ + { + "object_id": "object-1", + "first_sequence": start, + "last_sequence": end, + "keyframes": [ + { + "sequence": start, + "extent_xyxy": fixture["extent_xyxy"], + "visibility": "visible", + }, + { + "sequence": end, + "extent_xyxy": fixture["extent_xyxy"], + "visibility": "partial", + }, + ], + "state_segments": [ + { + "start_sequence": start, + "end_sequence": end, + "geometry_association": fixture["geometry_association"], + "freshness": "current", + "motion": fixture["motion"], + "threat": fixture["threat"], + "critical_corridor_obstacle": True, + } + ], + "notes": None, + } + ], + "notes": None, + } + ) + return rows + + +def _review(pack: m48.M48ObjectQualityPack, reviewer_id: str) -> dict[str, Any]: + return { + "schema_version": m48.M48_REVIEW_SCHEMA, + "pack_id": pack.result_id, + "state": "completed-independent-no-predictions", + "reviewer_id": reviewer_id, + "review_round": 1, + "blindness": { + "candidate_identity_seen": False, + "model_predictions_seen": False, + "model_scores_seen": False, + "semantic_class_task_seen": False, + }, + "clips": _review_clips(pack.clips, state="reviewed"), + "acceptance": { + "all_clips_reviewed": True, + "independent": True, + "submitted_at_utc": "2026-08-24T11:00:00Z", + }, + } + + +def _build_generations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + unsafe_free_space: bool = False, + unsafe_free_space_split: str | None = None, +) -> tuple[m48.M48ObjectQualityPack, m48.M48ObjectTruthSeal]: + _fake_m47(tmp_path, monkeypatch) + clips = _clips() + pack = m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=clips, + predictions=_prediction_rows( + clips, + unsafe_free_space=unsafe_free_space, + unsafe_free_space_split=unsafe_free_space_split, + ), + preparation_provenance=_preparation_provenance(), + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs", + ) + review_a = _review(pack, "reviewer-a") + review_b = _review(pack, "reviewer-b") + review_a_path = tmp_path / "review-a.json" + review_b_path = tmp_path / "review-b.json" + _write_json(review_a_path, review_a) + _write_json(review_b_path, review_b) + adjudication = { + "schema_version": m48.M48_ADJUDICATION_SCHEMA, + "pack_id": pack.result_id, + "state": "completed-adjudicated", + "adjudicator_id": "adjudicator-1", + "review_submission_sha256": sorted( + (m48._canonical_sha256(review_a), m48._canonical_sha256(review_b)) + ), + "clips": _review_clips(pack.clips, state="adjudicated"), + "acceptance": { + "all_clips_adjudicated": True, + "all_disagreements_resolved": True, + "sealed_at_utc": "2026-08-24T12:00:00Z", + }, + } + adjudication_path = tmp_path / "adjudication.json" + _write_json(adjudication_path, adjudication) + truth = m48.build_m48_object_truth_seal( + pack_root=pack.result_root, + reviewer_a_path=review_a_path, + reviewer_b_path=review_b_path, + adjudication_path=adjudication_path, + output_root=tmp_path / "truth", + ) + return pack, truth + + +def test_m48_pack_is_neutral_tracklet_review_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pack, truth = _build_generations(tmp_path, monkeypatch) + + reviewer_package = json.loads( + (pack.result_root / "reviewer-package.json").read_text(encoding="utf-8") + ) + assert reviewer_package["strata_included"] is False + assert reviewer_package["frozen_predictions_included"] is False + assert all( + "strata" not in clip and "selection_hypotheses" not in clip + for clip in reviewer_package["clips"] + ) + assert { + hypothesis + for clip in pack.clips + if clip["split"] == "validation" + for hypothesis in clip["selection_hypotheses"] + } == set(pack.manifest["identity"]["profile"]["required_validation_hypotheses"]) + review_template = json.loads( + (pack.result_root / "review-template.json").read_text(encoding="utf-8") + ) + assert "clips" in review_template and "frames" not in review_template + assert "tracklets" in review_template["clips"][0] + assert len(truth.truth_rows) == len(pack.frame_references) + assert truth.truth_rows[0]["objects"][0]["visibility"] == "visible" + assert truth.truth_rows[50]["objects"][0]["visibility"] == "partial" + + +def test_m48_perfect_class_free_result_passes_all_gates_and_registry( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pack, truth = _build_generations(tmp_path, monkeypatch) + result = m48.score_m48_object_quality( + pack_root=pack.result_root, + truth_seal_root=truth.result_root, + output_root=tmp_path / "results", + ) + + assert result.report["acceptance"]["accepted"] is True + assert all(result.report["acceptance"]["gates"].values()) + assert result.report["acceptance"]["scope"] == "validation-only" + assert result.report["metrics"] == result.report["metrics_by_split"]["validation"] + assert result.report["method"]["semantic_class_scored"] is False + assert result.report["decision"]["next_gate"] == ("m4.9-recorded-realtime-release-candidate") + repository_root = Path(__file__).resolve().parents[1] + registry = LaboratoryEvidenceRegistry.from_directory(repository_root / "config/laboratories") + definitions = {definition.work_id: definition for definition in registry.definitions} + pack_proof = verify_laboratory_evidence_result( + definitions["m48-object-centric-quality"], pack.result_root + ) + result_proof = verify_laboratory_evidence_result( + definitions["m48-object-centric-quality"], result.result_root + ) + assert pack_proof["artifact_count"] == 7 + assert result_proof["artifact_count"] == 3 + + +def test_m48_review_rejects_semantic_class_and_same_reviewer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _fake_m47(tmp_path, monkeypatch) + clips = _clips() + pack = m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=clips, + predictions=_prediction_rows(clips, unsafe_free_space=False), + preparation_provenance=_preparation_provenance(), + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs", + ) + review_a = _review(pack, "reviewer-a") + review_a["clips"][0]["tracklets"][0]["category"] = "car" + review_a_path = tmp_path / "review-a.json" + _write_json(review_a_path, review_a) + with pytest.raises(m48.M48ObjectQualityError, match="fields"): + m48.validate_m48_review_submission(pack_root=pack.result_root, review_path=review_a_path) + + review_a = _review(pack, "reviewer-a") + review_b = copy.deepcopy(review_a) + review_a_path = tmp_path / "review-a-clean.json" + review_b_path = tmp_path / "review-b-same.json" + _write_json(review_a_path, review_a) + _write_json(review_b_path, review_b) + adjudication = { + "schema_version": m48.M48_ADJUDICATION_SCHEMA, + "pack_id": pack.result_id, + "state": "completed-adjudicated", + "adjudicator_id": "adjudicator-1", + "review_submission_sha256": [m48._canonical_sha256(review_a)] * 2, + "clips": _review_clips(pack.clips, state="adjudicated"), + "acceptance": { + "all_clips_adjudicated": True, + "all_disagreements_resolved": True, + "sealed_at_utc": "2026-08-24T12:00:00Z", + }, + } + adjudication_path = tmp_path / "adjudication.json" + _write_json(adjudication_path, adjudication) + with pytest.raises(m48.M48ObjectQualityError, match="must differ"): + m48.build_m48_object_truth_seal( + pack_root=pack.result_root, + reviewer_a_path=review_a_path, + reviewer_b_path=review_b_path, + adjudication_path=adjudication_path, + output_root=tmp_path / "truth", + ) + + +def test_m48_unsafe_free_space_fails_with_bounded_atlas( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pack, truth = _build_generations(tmp_path, monkeypatch, unsafe_free_space=True) + result = m48.score_m48_object_quality( + pack_root=pack.result_root, + truth_seal_root=truth.result_root, + output_root=tmp_path / "results", + ) + + assert result.report["acceptance"]["accepted"] is False + assert result.report["acceptance"]["gates"]["false_free_space_claims"] is False + assert result.report["acceptance"]["gates"]["critical_corridor_obstacle_recall"] is True + assert result.failure_atlas + assert any("false-free-space-claim" in row["causes"] for row in result.failure_atlas) + assert result.report["decision"]["next_gate"] == ( + "bounded-cause-remediation-on-failed-m48-clusters" + ) + + +def test_m48_development_failures_are_reported_but_cannot_fail_release( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pack, truth = _build_generations( + tmp_path, + monkeypatch, + unsafe_free_space=True, + unsafe_free_space_split="development", + ) + result = m48.score_m48_object_quality( + pack_root=pack.result_root, + truth_seal_root=truth.result_root, + output_root=tmp_path / "results", + ) + + assert result.report["acceptance"]["accepted"] is True + assert result.report["metrics_by_split"]["development"]["false_free_space_claims"] > 0 + assert result.report["metrics"]["false_free_space_claims"] == 0 + assert all(result.report["acceptance"]["gates"].values()) + assert any(row["split"] == "development" for row in result.failure_atlas) + + +def test_m48_rejects_incomplete_clip_contract( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _fake_m47(tmp_path, monkeypatch) + clips = _clips()[:19] + with pytest.raises(m48.M48ObjectQualityError, match="20–30"): + m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=clips, + predictions=_prediction_rows(clips, unsafe_free_space=False), + preparation_provenance=_preparation_provenance(), + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs", + ) + + +def test_m48_rejects_cross_split_and_vacuous_grouping( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _fake_m47(tmp_path, monkeypatch) + clips = _clips() + clips[10]["route_block"] = clips[0]["route_block"] + with pytest.raises(m48.M48ObjectQualityError, match="route_block crosses"): + m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=clips, + predictions=_prediction_rows(clips, unsafe_free_space=False), + preparation_provenance=_preparation_provenance(), + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs-cross-split", + ) + + clips = _clips() + for index, clip in enumerate(clips): + clip["component_id"] = f"unique-component-{index:02d}" + with pytest.raises(m48.M48ObjectQualityError, match="non-vacuous"): + m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=clips, + predictions=_prediction_rows(clips, unsafe_free_space=False), + preparation_provenance=_preparation_provenance(), + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs-vacuous", + ) + + +def test_m48_profile_config_matches_executable_contract() -> None: + repository_root = Path(__file__).resolve().parents[1] + document = json.loads( + (repository_root / "config/perception/m48-object-quality-v1.json").read_text( + encoding="utf-8" + ) + ) + profile = m48.DEFAULT_M48_OBJECT_QUALITY_PROFILE + + assert document["schema_version"] == m48.M48_PROFILE_SCHEMA + assert document["profile_id"] == profile.profile_id + assert document["clip_contract"]["minimum_clip_count"] == profile.minimum_clip_count + assert document["clip_contract"]["maximum_clip_count"] == profile.maximum_clip_count + assert document["review_contract"]["review_unit"] == "clip-local-object-tracklet" + assert document["review_contract"]["semantic_class_labels_allowed"] is False + assert document["review_contract"]["selection_hypotheses_visible_to_reviewers"] is False + assert document["clip_contract"]["release_gate_split"] == "validation" + assert document["clip_contract"]["required_validation_hypotheses"] == sorted( + m48.DEFAULT_M48_OBJECT_QUALITY_PROFILE.to_dict()["required_validation_hypotheses"] + ) + assert document["release_thresholds"]["obstacle_presence_precision"] == ( + profile.obstacle_presence_precision + ) + assert document["release_thresholds"]["critical_corridor_obstacle_recall"] == ( + profile.critical_corridor_obstacle_recall + ) + + +def test_m48_pack_identity_is_stable_across_clip_and_prediction_input_order( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _fake_m47(tmp_path, monkeypatch) + clips = _clips() + predictions = _prediction_rows(clips, unsafe_free_space=False) + first = m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=clips, + predictions=predictions, + preparation_provenance=_preparation_provenance(), + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs-a", + ) + second = m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=reversed(clips), + predictions=reversed(predictions), + preparation_provenance=_preparation_provenance(), + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs-b", + ) + + assert first.result_id == second.result_id + assert first.manifest["identity_sha256"] == second.manifest["identity_sha256"] + + changed_provenance = _preparation_provenance() + changed_provenance["camera_index"]["sha256"] = "d" * 64 + third = m48.build_m48_object_quality_pack( + m47_lab_root=tmp_path / "ignored-m47", + frame_catalog=_frame_catalog(), + clips=clips, + predictions=predictions, + preparation_provenance=changed_provenance, + frozen_at_utc="2026-08-24T10:00:00Z", + output_root=tmp_path / "packs-c", + ) + assert third.result_id != first.result_id + assert third.manifest["identity"]["preparation"]["camera_index"]["sha256"] == ("d" * 64) diff --git a/tests/test_m48_object_quality_api.py b/tests/test_m48_object_quality_api.py new file mode 100644 index 0000000..00b45a3 --- /dev/null +++ b/tests/test_m48_object_quality_api.py @@ -0,0 +1,1123 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import k1link.web.m48_object_quality_api as api +from k1link.laboratory.m48_object_quality import ( + M48_ADJUDICATION_SCHEMA, + M48_REVIEW_SCHEMA, + M48_REVIEWER_PACKAGE_NAME, + M48ObjectQualityPack, +) + + +def _fixture( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + spatial: bool = False, + camera_fragment_sha256: str | None = None, + playback_time_offset_ns: int = 0, +) -> tuple[TestClient, str, dict[str, list[dict[str, Any]]]]: + pack_id = f"m48-object-quality-pack-{'a' * 64}" + pack_root = tmp_path / "packs" / pack_id + pack_root.mkdir(parents=True) + camera_payload = b"synthetic-camera-frame" + camera_sha = hashlib.sha256(camera_payload).hexdigest() + fragment_sha = hashlib.sha256(b"synthetic-fmp4-fragment").hexdigest() + clips = ( + { + "schema_version": "missioncore.m48-connected-clip/v1", + "clip_id": "neutral-clip-01", + "component_id": "hidden-component", + "split": "hidden-validation-stratum", + "strata": ["hidden-stratum"], + "start_sequence": 1, + "end_sequence": 2, + }, + ) + references = ( + { + "clip_id": "neutral-clip-01", + "component_id": "hidden-component", + "split": "hidden-validation-stratum", + "sequence": sequence, + "source_time_ns": sequence * 1_000_000, + "camera_fragment_sha256": fragment_sha, + } + for sequence in (1, 2) + ) + references = tuple(references) + reviewer_package = { + "schema_version": "missioncore.m48-neutral-reviewer-package/v1", + "pack_id": pack_id, + "state": "prediction-blind-neutral-source-projection", + "strata_included": False, + "frozen_predictions_included": False, + "candidate_identity_included": False, + "semantic_class_task_included": False, + "clips": [ + { + "clip_id": "neutral-clip-01", + "start_sequence": 1, + "end_sequence": 2, + "frames": [ + { + "sequence": sequence, + "source_time_ns": sequence * 1_000_000, + "camera_fragment_sha256": fragment_sha, + } + for sequence in (1, 2) + ], + } + ], + } + (pack_root / M48_REVIEWER_PACKAGE_NAME).write_text( + json.dumps(reviewer_package), + encoding="utf-8", + ) + (pack_root / "manifest.json").write_text('{"sealed":"pack"}', encoding="utf-8") + pack = M48ObjectQualityPack( + result_id=pack_id, + result_root=pack_root, + manifest={ + "identity_sha256": "a" * 64, + "created_at_utc": "2026-08-23T20:00:00Z", + "identity": { + "source": {"source_session_id": "recorded-session"}, + "freeze": {"prediction_rows_sha256": "8" * 64}, + }, + }, + report={"status": "prepared-predictions-frozen-labels-unavailable"}, + clips=clips, + frame_references=references, + predictions=( + { + "clip_id": "neutral-clip-01", + "sequence": 1, + "source_time_ns": 1_000_000, + "objects": [ + { + "prediction_id": "secret-object", + "extent_xyxy": [0.1, 0.1, 0.3, 0.4], + "geometry_association": "associated", + "freshness": "current", + "motion": "moving", + "threat": "threat", + }, + { + "prediction_id": "secret-object-2", + "extent_xyxy": [0.5, 0.2, 0.7, 0.5], + "geometry_association": "unknown", + "freshness": "current", + "motion": "unsupported", + "threat": "unknown", + }, + ], + }, + ), + ) + pack_reads: list[dict[str, Any]] = [] + + def read_pack(path: Path) -> M48ObjectQualityPack: + pack_reads.append({"path": str(path)}) + return pack + + monkeypatch.setattr(api, "read_m48_object_quality_pack", read_pack) + + regression_result_id = f"m48-small-static-passage-regression-{'f' * 64}" + regression_root = tmp_path / "small-static" / regression_result_id + regression_root.mkdir(parents=True) + regression_anchor_id = f"anchor-{'1' * 24}" + regression_anchor = { + "anchor_id": regression_anchor_id, + "clip_id": "neutral-clip-01", + "object_id": "object-01", + "sequence": 1, + "extent_xyxy": [0.2, 0.2, 0.3, 0.4], + "visibility": "visible", + "geometry_association": "unknown", + "freshness": "current", + "motion": "static", + "threat": "not-threat", + "requires_avoidance_or_clearance": True, + } + regression_comparison = { + "anchor_id": regression_anchor_id, + "clip_id": "neutral-clip-01", + "sequence": 1, + "source_time_ns": 1_000_000, + "anchor_extent_xyxy": [0.2, 0.2, 0.3, 0.4], + "requires_avoidance_or_clearance": True, + "worker_candidate_count": 2, + "worker_objects": list(pack.predictions[0]["objects"]), + "best_prediction_id": None, + "best_iou": 0.0, + "extent_iou_threshold": 0.5, + "matched_at_threshold": False, + "outcome": "missed-assisted-anchor", + } + regression_result = SimpleNamespace( + result_id=regression_result_id, + result_root=regression_root, + manifest={ + "created_at_utc": "2026-08-24T12:00:00Z", + "accepted": False, + "identity": { + "run_label": "M4.8R1", + "pipeline_id": "m48-class-free-object-quality/v1", + "experiment_id": "m48-small-static-passage-regression/v1", + "source": { + "pack_id": pack_id, + "pack_identity_sha256": "a" * 64, + "prediction_rows_sha256": "8" * 64, + }, + }, + }, + report={ + "metrics": { + "assisted_anchor_count": 1, + "assisted_tracklet_count": 1, + "anchor_clip_count": 1, + "requires_avoidance_or_clearance_count": 1, + "worker_recalled_anchor_count": 0, + "worker_missed_anchor_count": 1, + "assisted_anchor_recall": 0.0, + "extent_iou_threshold": 0.5, + "minimum_assisted_anchor_recall": 0.9, + }, + "gates": { + "anchor_set_non_empty": True, + "development_anchor_recall_target": False, + "independent_truth_available": False, + }, + "decision": { + "state": "failed-development-regression-baseline", + "summary": "fixture", + "next_action": "iterate", + }, + }, + anchors=(regression_anchor,), + comparisons=(regression_comparison,), + ) + monkeypatch.setattr( + api, + "read_m48_small_static_passage_regression", + lambda _: regression_result, + ) + + observations: dict[str, list[dict[str, Any]]] = { + "reviews": [], + "adjudications": [], + "runs": [], + "playback": [], + "spatial": [], + "packs": pack_reads, + } + truth_id = f"m48-object-truth-seal-{'b' * 64}" + truth_root = tmp_path / "truth" / truth_id + truth_root.mkdir(parents=True) + (truth_root / "manifest.json").write_text('{"sealed":"truth"}', encoding="utf-8") + truth = SimpleNamespace( + result_id=truth_id, + result_root=truth_root, + manifest={"identity": {"pack": {"result_id": pack_id}}}, + report={"status": "sealed-adjudicated-independent-object-truth"}, + truth_rows=( + { + "clip_id": "neutral-clip-01", + "sequence": 1, + "source_time_ns": 1_000_000, + "objects": [ + { + "object_id": "truth-object", + "extent_xyxy": [0.1, 0.1, 0.3, 0.4], + "geometry_association": "associated", + "freshness": "current", + "motion": "moving", + "threat": "threat", + } + ], + }, + ), + provenance={ + "prediction_content_read_by_sealer": False, + "prediction_content_seen_by_reviewers": False, + }, + ) + result_id = f"m48-object-quality-result-{'c' * 64}" + result_root = tmp_path / "results" / result_id + result_root.mkdir(parents=True) + failure_id = f"m48-failure-{'d' * 64}" + result = SimpleNamespace( + result_id=result_id, + result_root=result_root, + manifest={ + "created_at_utc": "2026-08-24T01:00:00Z", + "identity": { + "pack": { + "result_id": pack_id, + "manifest_sha256": hashlib.sha256( + (pack_root / "manifest.json").read_bytes() + ).hexdigest(), + }, + "truth_seal": { + "result_id": truth_id, + "manifest_sha256": hashlib.sha256( + (truth_root / "manifest.json").read_bytes() + ).hexdigest(), + }, + }, + }, + report={ + "status": "failed-object-centric-source-quality", + "metrics": { + "terminal_outcome_accounting": 1.0, + "false_free_space_claims": 0, + "obstacle_presence_precision": 0.5, + "obstacle_presence_recall": 0.5, + "critical_corridor_obstacle_recall": 0.5, + "geometry_association_correctness": 1.0, + "freshness_correctness": 1.0, + "motion_decision_correctness": 1.0, + "critical_threat_not_threat": 0, + "unknown_prediction_count": 0, + "failure_case_count": 1, + "unknown_causes": {}, + }, + "acceptance": { + "accepted": False, + "gates": {"obstacle_presence_recall": False}, + }, + }, + frame_ledger=( + { + "clip_id": "neutral-clip-01", + "sequence": 1, + "source_time_ns": 1_000_000, + }, + ), + failure_atlas=( + { + "failure_case_id": failure_id, + "clip_id": "neutral-clip-01", + "sequence": 1, + "severity": "high", + "causes": ["presence-false-negative"], + }, + ), + ) + monkeypatch.setattr(api, "read_m48_object_truth_seal", lambda _: truth) + monkeypatch.setattr(api, "read_m48_object_quality_result", lambda _: result) + + def validate_review(*, pack_root: Path, review_path: Path) -> dict[str, Any]: + assert pack_root == pack.result_root + document = json.loads(review_path.read_text(encoding="utf-8")) + assert document["schema_version"] == M48_REVIEW_SCHEMA + assert "predictions" not in document + assert "frozen_predictions" not in document + observations["reviews"].append(document) + return document + + def build_truth(**kwargs: Any) -> SimpleNamespace: + document = json.loads(Path(kwargs["adjudication_path"]).read_text(encoding="utf-8")) + assert document["schema_version"] == M48_ADJUDICATION_SCHEMA + assert "predictions" not in document + assert "frozen_predictions" not in document + observations["adjudications"].append(document) + assert Path(kwargs["output_root"]) == truth_root.parent + return truth + + monkeypatch.setattr(api, "validate_m48_review_submission", validate_review) + monkeypatch.setattr(api, "build_m48_object_truth_seal", build_truth) + + class _EvaluationRunner: + def run(self, request: Any) -> SimpleNamespace: + assert request.work_id == "m48-object-centric-quality" + assert request.inputs == { + "pack_root": pack.result_root, + "truth_seal_root": truth_root, + } + assert request.output_root == result_root.parent + assert request.receipt_root == tmp_path / "receipts" + observations["runs"].append( + { + "work_id": request.work_id, + "run_id": request.run_id, + "request_id": request.request_id, + } + ) + return SimpleNamespace( + result_id=result.result_id, + receipt_id=f"laboratory-run-receipt-{'e' * 64}", + ) + + def camera(session_id: str, frame_index: int) -> SimpleNamespace: + assert session_id == "recorded-session" + assert frame_index in {0, 1} + return SimpleNamespace( + payload=camera_payload, + media_type="image/jpeg", + sha256=camera_sha, + source_fragment_sha256=camera_fragment_sha256 or fragment_sha, + ) + + def camera_playback(session_id: str) -> SimpleNamespace: + assert session_id == "recorded-session" + observations["playback"].append({"session_id": session_id}) + return SimpleNamespace( + session_id=session_id, + public_source_id="recorded.camera.synthetic", + artifact_id="recorded-video-synthetic", + synchronization="host-arrival-best-effort", + generation_sha256="9" * 64, + timeline_start_seconds=0.001, + timeline_end_seconds=0.003, + byte_length=1234, + media_type='video/mp4; codecs="avc1.640028"', + segment_count=2, + segment_sha256s=(fragment_sha, fragment_sha), + segment_start_times_ns=( + 1_000_000 + playback_time_offset_ns, + 2_000_000 + playback_time_offset_ns, + ), + ) + + def spatial_frame(pack_value: M48ObjectQualityPack, sequence: int) -> dict[str, Any]: + assert pack_value is pack + observations["spatial"].append({"sequence": sequence}) + return { + "schema_version": "missioncore.m48-neutral-object-review-spatial-frame/v1", + "pack_id": pack_id, + "clip_id": "neutral-clip-01", + "sequence": sequence, + "source_time_ns": sequence * 1_000_000, + "source_available": True, + "body_frame_available": True, + "point_cloud_body_xyz_m": [[1.0, 0.0, 0.2]], + "rig": { + "profile_id": "rig/v1", + "length_m": 1.0, + "width_m": 0.6, + "lidar_reference": "body", + "nominal_sensor_height_m": 0.8, + "physical_mount_claimed": False, + }, + "corridor": { + "profile_id": "corridor/v1", + "forward_length_m": 8.0, + "rear_margin_m": 0.2, + "lateral_clearance_m": 0.3, + "half_width_m": 0.6, + "prediction_horizon_seconds": 2.0, + }, + "occupied_voxel_size_m": 0.2, + "candidate_identity_included": False, + "graph_boxes_ids_scores_included": False, + "frozen_predictions_included": False, + "strata_included": False, + "authority": { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, + }, + } + + application = FastAPI() + application.include_router( + api.build_m48_object_quality_router( + pack_root_provider=lambda: pack_root.parent, + workflow_root_provider=lambda: tmp_path / "workflow", + truth_root_provider=lambda: tmp_path / "truth", + result_root_provider=lambda: tmp_path / "results", + small_static_result_root_provider=lambda: tmp_path / "small-static", + camera_frame_provider=camera, + camera_playback_provider=camera_playback, # type: ignore[arg-type] + spatial_evidence_provider=spatial_frame if spatial else None, + evaluation_runner=_EvaluationRunner(), # type: ignore[arg-type] + evaluation_receipt_root_provider=lambda: tmp_path / "receipts", + ) + ) + return TestClient(application), pack_id, observations + + +def _review_clip(state: str) -> dict[str, Any]: + return { + "clip_id": "neutral-clip-01", + "start_sequence": 1, + "end_sequence": 2, + "review_state": state, + "no_object": True, + "tracklets": [], + "notes": None, + } + + +def _object_tracklet(*, spatial_claims: bool) -> dict[str, Any]: + return { + "object_id": "object-01", + "first_sequence": 1, + "last_sequence": 2, + "keyframes": [ + { + "sequence": 1, + "extent_xyxy": [0.1, 0.1, 0.3, 0.4], + "visibility": "visible", + }, + { + "sequence": 2, + "extent_xyxy": [0.2, 0.1, 0.4, 0.4], + "visibility": "visible", + }, + ], + "state_segments": [ + { + "start_sequence": 1, + "end_sequence": 2, + "geometry_association": "associated" if spatial_claims else "unavailable", + "freshness": "current" if spatial_claims else "unavailable", + "motion": "moving" if spatial_claims else "unsupported", + "threat": "threat" if spatial_claims else "unknown", + "critical_corridor_obstacle": spatial_claims, + } + ], + "notes": None, + } + + +def _create_review(client: TestClient, pack_id: str, key: str) -> tuple[str, str]: + response = client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews", + json={"idempotency_key": key}, + ) + assert response.status_code == 200 + body = response.json() + capability = response.headers["x-m48-review-capability"] + assert capability == body["review_capability"] + return body["session_id"], capability + + +def _save_review( + client: TestClient, + pack_id: str, + session_id: str, + capability: str, + key: str, +) -> dict[str, Any]: + response = client.put( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews/{session_id}", + headers={"X-M48-Review-Capability": capability}, + json={ + "expected_revision": 0, + "idempotency_key": key, + "title": "Independent class-free review", + "clips": [_review_clip("reviewed")], + }, + ) + assert response.status_code == 200 + return response.json() + + +def _freeze_review( + client: TestClient, + pack_id: str, + session_id: str, + capability: str, + reviewer_id: str, +) -> Any: + return client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews/{session_id}/freeze", + headers={"X-M48-Review-Capability": capability}, + json={ + "expected_revision": 1, + "reviewer_id": reviewer_id, + "independent_attestation": True, + "candidate_identity_not_seen": True, + "model_predictions_not_seen": True, + "semantic_class_task_not_seen": True, + }, + ) + + +def test_m48_source_is_prediction_free_strata_free_and_declares_raw_evidence_gap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, observations = _fixture(tmp_path, monkeypatch) + + response = client.get(f"/api/v1/laboratory/m48/packs/{pack_id}/source") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert set(response.headers["vary"].split(", ")) == { + "X-M48-Review-Capability", + "X-M48-Correction-Capability", + "X-M48-Adjudication-Capability", + } + source = response.json() + assert source["schema_version"] == "missioncore.m48-neutral-object-review-source/v2" + assert source["camera_playback"] == { + "schema_version": "missioncore.laboratory-recorded-clip-camera/v1", + "source_id": "recorded.camera.synthetic", + "label": "Записанная RIGHT камера", + "manifest_url": ( + "/api/v1/observation-sessions/recorded-session/media/recorded-video-synthetic/manifest" + ), + "manifest_generation_sha256": "9" * 64, + "byte_length": 1234, + "media_type": "video/mp4", + "timeline_start_seconds": 0.001, + "timeline_end_seconds": 0.003, + "segment_count": 2, + "seekable": True, + "synchronization": "host-arrival-best-effort", + "transport": "recorded-fmp4-manifest", + "fragment_binding": "pack-frozen-sha256-verified", + } + assert source["strata_included"] is False + assert source["split_included"] is False + assert source["frozen_predictions_included"] is False + assert source["contract"]["semantic_classes_allowed"] is False + assert source["evidence_capabilities"] == { + "state": "camera-bound-raw-spatial-evidence-unavailable", + "camera_epoch_time": True, + "current_point_cloud_body_xyz_m": False, + "rig": False, + "virtual_corridor": False, + "raw_lidar": False, + "graph_output": False, + "graph_boxes_ids_scores": False, + "label_authority": { + "obstacle_presence_and_extent": True, + "geometry_association": False, + "freshness": False, + "motion": False, + "threat": False, + "critical_corridor_obstacle": False, + }, + "fail_closed_reason": source["evidence_capabilities"]["fail_closed_reason"], + } + serialized = json.dumps(source) + assert "hidden-component" not in serialized + assert "hidden-validation-stratum" not in serialized + assert "hidden-stratum" not in serialized + assert "secret-object" not in serialized + + repeated = client.get(f"/api/v1/laboratory/m48/packs/{pack_id}/source") + assert repeated.status_code == 200 + assert observations["playback"] == [{"session_id": "recorded-session"}] + + camera = client.get(source["clips"][0]["frames"][0]["camera_url"]) + assert camera.status_code == 200 + assert camera.content == b"synthetic-camera-frame" + assert camera.headers["cache-control"] == "no-store" + assert ( + camera.headers["x-m48-decoded-camera-sha256"] + == hashlib.sha256(b"synthetic-camera-frame").hexdigest() + ) + assert ( + camera.headers["x-m48-source-fragment-sha256"] + == hashlib.sha256(b"synthetic-fmp4-fragment").hexdigest() + ) + second_camera = client.get(source["clips"][0]["frames"][1]["camera_url"]) + assert second_camera.status_code == 200 + assert len(observations["packs"]) == 1 + + +def test_m48_assisted_correction_seeds_worker_boxes_and_freezes_human_delta( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, _ = _fixture(tmp_path, monkeypatch, spatial=True) + + created_response = client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/corrections", + json={"idempotency_key": "worker-006-correction"}, + ) + + assert created_response.status_code == 200 + created = created_response.json() + capability = created_response.headers["x-m48-correction-capability"] + assert capability == created["correction_capability"] + assert created["assistance"] == { + "mode": "frozen-candidate-seeded", + "candidate_predictions_seen": True, + "model_scores_seen": False, + "semantic_class_task_seen": False, + "independent_truth_eligible": False, + } + assert created["seed_summary"]["worker_id"] == "006" + assert created["seed_summary"]["object_count"] == 2 + clip = created["clips"][0] + assert clip["review_state"] == "pending" + assert [row["object_id"] for row in clip["tracklets"]] == [ + "secret-object", + "secret-object-2", + ] + reopened = client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/corrections", + json={"idempotency_key": "worker-006-reopen-other-tab"}, + ) + assert reopened.status_code == 200 + assert reopened.json()["session_id"] == created["session_id"] + assert reopened.headers["x-m48-correction-capability"] == capability + + retained = clip["tracklets"][0] + retained["keyframes"][0]["extent_xyxy"] = [0.12, 0.1, 0.32, 0.4] + added = { + "object_id": "object-01", + "first_sequence": 2, + "last_sequence": 2, + "keyframes": [ + { + "sequence": 2, + "extent_xyxy": [0.7, 0.1, 0.8, 0.3], + "visibility": "visible", + } + ], + "state_segments": [ + { + "start_sequence": 2, + "end_sequence": 2, + "geometry_association": "unknown", + "freshness": "unavailable", + "motion": "unknown", + "threat": "unknown", + "critical_corridor_obstacle": False, + } + ], + "notes": None, + } + clip.update( + { + "review_state": "reviewed", + "no_object": False, + "tracklets": [retained, added], + } + ) + saved_response = client.put( + f"/api/v1/laboratory/m48/packs/{pack_id}/corrections/{created['session_id']}", + headers={"X-M48-Correction-Capability": capability}, + json={ + "expected_revision": 0, + "idempotency_key": "worker-006-save-1", + "title": "Worker 006 corrected evidence", + "clips": [clip], + }, + ) + assert saved_response.status_code == 200 + assert saved_response.json()["progress"]["complete"] is True + + frozen_response = client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/corrections/{created['session_id']}/freeze", + headers={"X-M48-Correction-Capability": capability}, + json={ + "expected_revision": 1, + "reviewer_id": "reviewer-assisted", + "all_clips_corrected": True, + "candidate_predictions_seen": True, + "semantic_class_task_not_seen": True, + }, + ) + assert frozen_response.status_code == 200 + frozen = frozen_response.json() + assert frozen["state"] == "frozen" + assert frozen["evidence_summary"] == { + "seed_object_count": 2, + "corrected_object_count": 2, + "confirmed_candidate_count": 1, + "unchanged_candidate_count": 0, + "modified_candidate_count": 1, + "false_positive_removed_count": 1, + "missed_object_added_count": 1, + "candidate_confirmation_rate": 0.5, + "assisted_recall_proxy": 0.5, + "independent_truth": False, + } + + +def test_m48_camera_playback_admits_only_sub_microsecond_media_clock_drift( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + admitted, pack_id, _ = _fixture( + tmp_path / "admitted", + monkeypatch, + playback_time_offset_ns=111, + ) + response = admitted.get(f"/api/v1/laboratory/m48/packs/{pack_id}/source") + assert response.status_code == 200 + + rejected, rejected_pack_id, _ = _fixture( + tmp_path / "rejected", + monkeypatch, + playback_time_offset_ns=1_001, + ) + response = rejected.get(f"/api/v1/laboratory/m48/packs/{rejected_pack_id}/source") + assert response.status_code == 409 + assert response.json()["detail"] == "M4.8 frozen camera timeline changed" + + +def test_m48_camera_delivery_fails_closed_when_frozen_fragment_changed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, _ = _fixture( + tmp_path, + monkeypatch, + camera_fragment_sha256="f" * 64, + ) + source = client.get(f"/api/v1/laboratory/m48/packs/{pack_id}/source").json() + + camera = client.get(source["clips"][0]["frames"][0]["camera_url"]) + + assert camera.status_code == 409 + assert camera.json()["detail"] == "M4.8 source frame changed" + + +def test_m48_review_slots_are_capability_isolated_revisioned_and_immutable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, observations = _fixture(tmp_path, monkeypatch) + first_id, first_capability = _create_review(client, pack_id, "slot-a") + second_id, second_capability = _create_review(client, pack_id, "slot-b") + + denied = client.get( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews/{first_id}", + headers={"X-M48-Review-Capability": second_capability}, + ) + assert denied.status_code == 403 + assert denied.headers["cache-control"] == "no-store" + assert "X-M48-Review-Capability" in denied.headers["vary"] + third = client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews", + json={"idempotency_key": "slot-c"}, + ) + assert third.status_code == 409 + + saved = _save_review(client, pack_id, first_id, first_capability, "save-a") + assert saved["revision"] == 1 + duplicate = _save_review(client, pack_id, first_id, first_capability, "save-a") + assert duplicate["revision"] == 1 + stale = client.put( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews/{first_id}", + headers={"X-M48-Review-Capability": first_capability}, + json={ + "expected_revision": 0, + "idempotency_key": "stale-save", + "title": "Stale write", + "clips": [_review_clip("reviewed")], + }, + ) + assert stale.status_code == 409 + + frozen = _freeze_review( + client, + pack_id, + first_id, + first_capability, + "reviewer-alpha", + ) + assert frozen.status_code == 200 + assert frozen.json()["state"] == "frozen" + assert frozen.json()["revision"] == 2 + assert observations["reviews"][0]["blindness"]["model_predictions_seen"] is False + + after_freeze = client.put( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews/{first_id}", + headers={"X-M48-Review-Capability": first_capability}, + json={ + "expected_revision": 2, + "idempotency_key": "after-freeze", + "title": "Forbidden mutation", + "clips": [_review_clip("reviewed")], + }, + ) + assert after_freeze.status_code == 409 + + _save_review(client, pack_id, second_id, second_capability, "save-b") + same_identity = _freeze_review( + client, + pack_id, + second_id, + second_capability, + "reviewer-alpha", + ) + assert same_identity.status_code == 409 + + +def test_m48_adjudication_unlocks_after_two_distinct_freezes_then_evaluates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, observations = _fixture(tmp_path, monkeypatch) + first_id, first_capability = _create_review(client, pack_id, "slot-a") + _save_review(client, pack_id, first_id, first_capability, "save-a") + assert ( + _freeze_review( + client, + pack_id, + first_id, + first_capability, + "reviewer-alpha", + ).status_code + == 200 + ) + + locked = client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/adjudication", + json={"idempotency_key": "adjudication"}, + ) + assert locked.status_code == 409 + + second_id, second_capability = _create_review(client, pack_id, "slot-b") + _save_review(client, pack_id, second_id, second_capability, "save-b") + assert ( + _freeze_review( + client, + pack_id, + second_id, + second_capability, + "reviewer-beta", + ).status_code + == 200 + ) + + status = client.get(f"/api/v1/laboratory/m48/packs/{pack_id}").json() + assert status["state"] == "two-reviewers-frozen" + assert status["decision"]["adjudication_unlocked"] is True + assert "reviewer-alpha" not in json.dumps(status) + assert "reviewer-beta" not in json.dumps(status) + + created = client.post( + f"/api/v1/laboratory/m48/packs/{pack_id}/adjudication", + json={"idempotency_key": "adjudication"}, + ) + assert created.status_code == 200 + adjudication = created.json() + adjudication_id = adjudication["session_id"] + capability = created.headers["x-m48-adjudication-capability"] + assert len(adjudication["review_inputs"]) == 2 + assert "reviewer-alpha" not in json.dumps(adjudication["review_inputs"]) + + denied = client.get( + f"/api/v1/laboratory/m48/packs/{pack_id}/adjudication/{adjudication_id}", + headers={"X-M48-Adjudication-Capability": "wrong"}, + ) + assert denied.status_code == 403 + + saved = client.put( + f"/api/v1/laboratory/m48/packs/{pack_id}/adjudication/{adjudication_id}", + headers={"X-M48-Adjudication-Capability": capability}, + json={ + "expected_revision": 0, + "idempotency_key": "save-adjudication", + "title": "Resolved class-free truth", + "clips": [_review_clip("adjudicated")], + }, + ) + assert saved.status_code == 200 + assert saved.json()["revision"] == 1 + + frozen = client.post( + (f"/api/v1/laboratory/m48/packs/{pack_id}/adjudication/{adjudication_id}/freeze"), + headers={"X-M48-Adjudication-Capability": capability}, + json={ + "expected_revision": 1, + "adjudicator_id": "adjudicator-gamma", + "all_disagreements_resolved": True, + "model_predictions_not_seen": True, + }, + ) + assert frozen.status_code == 200 + assert frozen.json()["state"] == "adjudication-frozen" + assert frozen.json()["revision"] == 2 + assert observations["adjudications"][0]["review_submission_sha256"] == sorted( + observations["adjudications"][0]["review_submission_sha256"] + ) + + sealed_status = client.get(f"/api/v1/laboratory/m48/packs/{pack_id}").json() + assert sealed_status["state"] == "adjudication-frozen" + + evaluated = client.post( + (f"/api/v1/laboratory/m48/packs/{pack_id}/adjudication/{adjudication_id}/evaluate"), + headers={"X-M48-Adjudication-Capability": capability}, + json={"expected_revision": 2, "idempotency_key": "evaluate-once"}, + ) + assert evaluated.status_code == 200 + assert evaluated.json()["state"] == "evaluated" + assert evaluated.json()["revision"] == 3 + assert evaluated.json()["quality_result_id"] == f"m48-object-quality-result-{'c' * 64}" + assert evaluated.json()["evaluation_receipt_id"] == (f"laboratory-run-receipt-{'e' * 64}") + assert observations["runs"] == [ + { + "work_id": "m48-object-centric-quality", + "run_id": observations["runs"][0]["run_id"], + "request_id": "evaluate-once", + } + ] + assert observations["runs"][0]["run_id"].startswith("m48-evaluation-") + + final_status = client.get(f"/api/v1/laboratory/m48/packs/{pack_id}").json() + assert final_status["state"] == "evaluated" + assert final_status["decision"]["evaluated"] is True + assert "secret-object" not in json.dumps(final_status) + + +def test_m48_spatial_claims_fail_closed_without_raw_review_projection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, _ = _fixture(tmp_path, monkeypatch) + session_id, capability = _create_review(client, pack_id, "slot-with-object") + clip = _review_clip("reviewed") + clip.update({"no_object": False, "tracklets": [_object_tracklet(spatial_claims=True)]}) + + response = client.put( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews/{session_id}", + headers={"X-M48-Review-Capability": capability}, + json={ + "expected_revision": 0, + "idempotency_key": "spatial-claim", + "title": "Unsupported spatial claim", + "clips": [clip], + }, + ) + + assert response.status_code == 409 + assert "fail closed" in response.json()["detail"] + + +def test_m48_canonical_spatial_projection_enables_class_free_tracklet_states( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, observations = _fixture(tmp_path, monkeypatch, spatial=True) + source = client.get(f"/api/v1/laboratory/m48/packs/{pack_id}/source").json() + assert source["evidence_capabilities"]["state"] == ( + "prediction-free-spatial-evidence-available" + ) + spatial_url = source["clips"][0]["frames"][0]["spatial_url"] + assert spatial_url.endswith("/spatial") + + spatial = client.get(spatial_url) + assert spatial.status_code == 200 + assert spatial.headers["cache-control"] == "no-store" + body = spatial.json() + assert body["schema_version"] == ("missioncore.m48-neutral-object-review-spatial-frame/v1") + assert body["point_cloud_body_xyz_m"] == [[1.0, 0.0, 0.2]] + assert body["graph_boxes_ids_scores_included"] is False + assert "objects" not in body + + repeated = client.get(spatial_url) + assert repeated.status_code == 200 + assert repeated.json() == body + assert observations["spatial"] == [{"sequence": 1}] + + session_id, capability = _create_review(client, pack_id, "spatial-review") + clip = _review_clip("reviewed") + clip.update( + { + "no_object": False, + "tracklets": [_object_tracklet(spatial_claims=True)], + } + ) + saved = client.put( + f"/api/v1/laboratory/m48/packs/{pack_id}/reviews/{session_id}", + headers={"X-M48-Review-Capability": capability}, + json={ + "expected_revision": 0, + "idempotency_key": "spatial-save", + "title": "Spatially grounded class-free review", + "clips": [clip], + }, + ) + assert saved.status_code == 200 + assert saved.json()["clips"][0]["tracklets"][0]["object_id"] == "object-01" + + +def test_m48_evaluated_result_and_failure_atlas_release_graph_only_post_seal( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, _, _ = _fixture(tmp_path, monkeypatch) + result_id = f"m48-object-quality-result-{'c' * 64}" + failure_id = f"m48-failure-{'d' * 64}" + + summary = client.get(f"/api/v1/laboratory/m48/results/{result_id}") + assert summary.status_code == 200 + assert summary.headers["cache-control"] == "no-store" + assert summary.json()["schema_version"] == ( + "missioncore.m48-object-centric-quality-result-view/v1" + ) + assert summary.json()["accepted"] is False + assert summary.json()["prediction_material_release"] == ( + "post-adjudication-seal-evaluation-only" + ) + + atlas = client.get(f"/api/v1/laboratory/m48/results/{result_id}/atlas") + assert atlas.status_code == 200 + assert atlas.json()["cases"][0]["failure_case_id"] == failure_id + + case = client.get(f"/api/v1/laboratory/m48/results/{result_id}/atlas/cases/{failure_id}") + assert case.status_code == 200 + body = case.json() + assert ( + body["frame"]["camera_fragment_sha256"] + == hashlib.sha256(b"synthetic-fmp4-fragment").hexdigest() + ) + assert body["truth"][0]["object_id"] == "truth-object" + assert body["graph"][0]["object_id"] == "secret-object" + assert body["prediction_material_release"] == ("post-adjudication-seal-evaluation-only") + + +def test_m48_small_static_regression_is_separate_read_only_assisted_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, pack_id, _ = _fixture(tmp_path, monkeypatch, spatial=True) + result_id = f"m48-small-static-passage-regression-{'f' * 64}" + anchor_id = f"anchor-{'1' * 24}" + + summary = client.get( + f"/api/v1/laboratory/m48/regressions/small-static/{result_id}" + ) + assert summary.status_code == 200 + assert summary.headers["cache-control"] == "no-store" + assert summary.json()["pack_id"] == pack_id + assert summary.json()["run_label"] == "M4.8R1" + assert summary.json()["pipeline_id"] == "m48-class-free-object-quality/v1" + assert summary.json()["experiment_id"] == ( + "m48-small-static-passage-regression/v1" + ) + assert summary.json()["independent_truth"] is False + assert summary.json()["metrics"]["worker_missed_anchor_count"] == 1 + + catalog = client.get( + f"/api/v1/laboratory/m48/regressions/small-static/{result_id}/cases" + ) + assert catalog.status_code == 200 + assert catalog.json()["case_count"] == 1 + assert catalog.json()["cases"][0]["outcome"] == "missed-assisted-anchor" + + case = client.get( + f"/api/v1/laboratory/m48/regressions/small-static/{result_id}/cases/{anchor_id}" + ) + assert case.status_code == 200 + body = case.json() + assert body["anchor"]["object_id"] == "object-01" + assert body["comparison"]["worker_candidate_count"] == 2 + assert body["ground_truth"] is False + assert body["camera_url"].endswith("/frames/1/camera") + assert body["spatial_url"].endswith("/frames/1/spatial") diff --git a/tests/test_m48_ravnoves00_pack.py b/tests/test_m48_ravnoves00_pack.py new file mode 100644 index 0000000..1dc086f --- /dev/null +++ b/tests/test_m48_ravnoves00_pack.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +import hashlib +import json +from itertools import pairwise +from pathlib import Path +from types import SimpleNamespace + +from k1link.laboratory.m47_reference_graph import M47_REFERENCE_GRAPH_LAB_SCHEMA +from k1link.laboratory.m48_object_quality import read_m48_object_quality_pack +from k1link.laboratory.m48_ravnoves00_pack import ( + M48_FRAME_COUNT, + M48_SELECTION_SCHEMA, + _prediction_objects, + prepare_m48_ravnoves00_pack, +) + + +def _canonical_json(value: object) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _write_json(path: Path, value: object) -> None: + path.write_text(_canonical_json(value) + "\n", encoding="utf-8") + + +def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> str: + raw = "".join(_canonical_json(row) + "\n" for row in rows).encode() + path.write_bytes(raw) + return hashlib.sha256(raw).hexdigest() + + +def _recursive_keys(value: object) -> set[str]: + if isinstance(value, dict): + return set(value) | {key for item in value.values() for key in _recursive_keys(item)} + if isinstance(value, list): + return {key for item in value for key in _recursive_keys(item)} + return set() + + +def test_prediction_projection_is_class_free_and_conservative() -> None: + rows = _prediction_objects( + [ + { + "proposal_id": "proposal-0-1", + "bbox_xyxy": [80.0, 60.0, 400.0, 300.0], + "occupied_support": False, + "threat_decision": "unknown", + "semantic_hint": "person", + "objectness": 0.99, + }, + { + "proposal_id": "proposal-0-2", + "bbox_xyxy": [400.0, 300.0, 720.0, 540.0], + "occupied_support": True, + "threat_decision": "threat", + "semantic_hint": "car", + "objectness": 0.98, + }, + ], + geometry_observations=[ + { + "proposal_ids": ["proposal-0-1"], + "currentness": "current", + "metric_geometry": None, + }, + { + "proposal_ids": ["proposal-0-2"], + "currentness": "current", + "metric_geometry": {"centroid_xyz_m": [1.0, 2.0, 3.0]}, + }, + ], + metric_obstacles=[ + { + "centroid_map_xyz_m": [1.0, 2.0, 3.0], + "motion": "moving", + "assessment": {"decision": "threat"}, + } + ], + ) + + assert rows == [ + { + "prediction_id": "proposal-0-1", + "extent_xyxy": [0.1, 0.1, 0.5, 0.5], + "geometry_association": "unknown", + "freshness": "current", + "motion": "unsupported", + "threat": "unknown", + "unknown_causes": [ + "insufficient-geometry-support", + "threat-evidence-insufficient", + ], + }, + { + "prediction_id": "proposal-0-2", + "extent_xyxy": [0.5, 0.5, 0.9, 0.9], + "geometry_association": "associated", + "freshness": "current", + "motion": "moving", + "threat": "threat", + "unknown_causes": [], + }, + ] + assert "semantic_hint" not in _canonical_json(rows) + assert "objectness" not in _canonical_json(rows) + + +def test_real_selection_contract_is_balanced_and_prediction_blind() -> None: + repository_root = Path(__file__).resolve().parents[1] + document = json.loads( + (repository_root / "config/perception/m48-object-quality-selection-v1.json").read_text() + ) + clips = document["clips"] + + assert document["schema_version"] == M48_SELECTION_SCHEMA + assert len(clips) == 24 + assert {clip["split"] for clip in clips} == {"development", "validation"} + assert all("strata" not in clip and "hypotheses" not in clip for clip in clips) + assert document["selection_hypothesis_profile"] == { + "derivation": "exact-frozen-prediction-rows-before-independent-truth", + "small_obstacle_max_normalized_area": 0.001, + "fisheye_edge_margin_normalized": 0.08, + "sparse_scene_max_median_prediction_count": 2.0, + } + for field in ("component_id", "route_block", "time_block"): + group_splits: dict[str, set[str]] = {} + for clip in clips: + group_splits.setdefault(clip[field], set()).add(clip["split"]) + assert all(len(splits) == 1 for splits in group_splits.values()) + assert len(group_splits) < len(clips) + assert all(left["end_sequence"] < right["start_sequence"] for left, right in pairwise(clips)) + forbidden = {"label", "labels", "truth", "review", "adjudication"} + assert forbidden.isdisjoint(document) + + +def test_prepare_pack_binds_all_source_ledgers_and_freezes_selected_frames( + tmp_path: Path, + monkeypatch, +) -> None: + repository_root = Path(__file__).resolve().parents[1] + graph_root = tmp_path / ("m47-reference-graph-" + "a" * 64) + threat_root = tmp_path / ("m4-threat-replay-" + "b" * 64) + geometry_root = tmp_path / ("m4-geometry-replay-" + "e" * 64) + lab_root = tmp_path / ("m47-reference-graph-lab-" + "c" * 64) + graph_root.mkdir() + threat_root.mkdir() + geometry_root.mkdir() + lab_root.mkdir() + _write_json(lab_root / "manifest.json", {"fixture": True}) + + graph_rows: list[dict[str, object]] = [] + threat_rows: list[dict[str, object]] = [] + geometry_rows: list[dict[str, object]] = [] + camera_rows: list[dict[str, object]] = [] + for frame_index in range(M48_FRAME_COUNT): + source_time_ns = 35_421_857_292 + frame_index * 100_000_000 + graph_rows.append( + { + "sequence": frame_index, + "obstacle_map": { + "schema_version": "missioncore.local-obstacle-map/v1", + "frame_id": f"frame-{frame_index:06d}", + "free_space_claimed": False, + }, + "threats": [], + } + ) + threat_rows.append( + { + "schema_version": "missioncore.perception-threat-replay-frame/v2", + "sequence": frame_index, + "frame_id": f"frame-{frame_index:06d}", + "source_time_ns": source_time_ns, + "source_available": True, + "camera_proposals": [ + { + "proposal_id": f"proposal-{frame_index}-0", + "bbox_xyxy": [0.0, 0.0, 20.0, 20.0], + "occupied_support": True, + "threat_decision": "threat" if frame_index % 2 == 0 else "not-threat", + }, + { + "proposal_id": f"proposal-{frame_index}-1", + "bbox_xyxy": [80.0, 60.0, 400.0, 300.0], + "occupied_support": False, + "threat_decision": "unknown", + }, + ], + "metric_obstacles": [ + { + "centroid_map_xyz_m": [1.0, 2.0, 3.0], + "motion": "moving" if frame_index % 2 == 0 else "stationary", + "assessment": { + "decision": "threat" if frame_index % 2 == 0 else "not-threat" + }, + } + ], + } + ) + geometry_rows.append( + { + "schema_version": "missioncore.perception-geometry-replay-frame/v1", + "sequence": frame_index, + "frame_id": f"frame-{frame_index:06d}", + "source_available": True, + "observations": [ + { + "proposal_ids": [f"proposal-{frame_index}-0"], + "currentness": "current", + "metric_geometry": {"centroid_xyz_m": [1.0, 2.0, 3.0]}, + }, + { + "proposal_ids": [f"proposal-{frame_index}-1"], + "currentness": "current", + "metric_geometry": None, + }, + ], + } + ) + camera_rows.append( + { + "schema_version": "missioncore.camera-recording-index/v1", + "sequence": frame_index + 1, + "kind": "media", + "session_monotonic_ns": frame_index + 1, + "sha256": hashlib.sha256(f"camera-{frame_index}".encode()).hexdigest(), + } + ) + graph_sha256 = _write_jsonl(graph_root / "frames.jsonl", graph_rows) + threat_sha256 = _write_jsonl(threat_root / "frames.jsonl", threat_rows) + geometry_sha256 = _write_jsonl(geometry_root / "frames.jsonl", geometry_rows) + camera_index = tmp_path / "index.jsonl" + _write_jsonl(camera_index, camera_rows) + _write_json( + graph_root / "manifest.json", + { + "schema_version": "missioncore.reference-perception-graph-manifest/v1", + "result_id": graph_root.name, + "accepted": True, + "graph_id": "reference-perception-graph/v2", + "run_mode": "lossless-replay", + "files": { + "frames.jsonl": { + "bytes": (graph_root / "frames.jsonl").stat().st_size, + "sha256": graph_sha256, + } + }, + }, + ) + _write_json( + threat_root / "manifest.json", + { + "schema_version": "missioncore.perception-threat-replay-result/v2", + "result_id": threat_root.name, + "accepted": True, + "identity": { + "source_session_id": "20260720T065719Z_viewer_live", + "frames_sha256": threat_sha256, + "geometry_result_id": geometry_root.name, + "geometry_frames_sha256": geometry_sha256, + }, + }, + ) + _write_json( + geometry_root / "manifest.json", + { + "schema_version": "missioncore.perception-geometry-replay-result/v1", + "identity": { + "accepted": True, + "source_pack_id": ( + "e10-lidar-pack-" + "576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b" + ), + "frames_sha256": geometry_sha256, + }, + }, + ) + authority = { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, + } + lab = SimpleNamespace( + result_id=lab_root.name, + result_root=lab_root, + manifest={ + "schema_version": M47_REFERENCE_GRAPH_LAB_SCHEMA, + "accepted": True, + "ground_truth": False, + }, + report={ + "source": { + "graph_result_id": graph_root.name, + "visual_result_id": threat_root.name, + "threat_frames_sha256": threat_sha256, + "source_id": "RAVNOVES00", + "source_session_id": "20260720T065719Z_viewer_live", + }, + "method": { + "graph_id": "reference-perception-graph/v2", + "run_mode": "lossless-replay", + "canonical_payload_sha256": "d" * 64, + }, + "decision": { + "state": "accepted-reference-graph-replay", + "next_gate": "independent-object-centric-detection-quality", + }, + "acceptance": {"accepted": True}, + "authority": authority, + }, + ) + monkeypatch.setattr( + "k1link.laboratory.m48_ravnoves00_pack.read_m47_reference_graph_lab", + lambda _: lab, + ) + monkeypatch.setattr( + "k1link.laboratory.m48_object_quality.read_m47_reference_graph_lab", + lambda _: lab, + ) + + result = prepare_m48_ravnoves00_pack( + m47_lab_root=lab_root, + graph_result_root=graph_root, + threat_result_root=threat_root, + geometry_result_root=geometry_root, + camera_index_path=camera_index, + selection_path=(repository_root / "config/perception/m48-object-quality-selection-v1.json"), + frozen_at_utc="2026-08-24T00:00:00Z", + output_root=tmp_path / "runtime/m48/object-quality-packs", + ) + + assert read_m48_object_quality_pack(result.result_root) == result + assert result.report["metrics"]["clip_count"] == 24 + assert result.report["metrics"]["frame_count"] == 24 * 61 + assert len(result.predictions) == 24 * 61 + assert result.manifest["identity"]["preparation"]["adapter"]["sha256"] == ( + hashlib.sha256( + (repository_root / "src/k1link/laboratory/m48_ravnoves00_pack.py").read_bytes() + ).hexdigest() + ) + assert result.manifest["identity"]["preparation"]["selection"]["sha256"] == ( + hashlib.sha256( + ( + repository_root / "config/perception/m48-object-quality-selection-v1.json" + ).read_bytes() + ).hexdigest() + ) + reviewer_package = json.loads((result.result_root / "reviewer-package.json").read_text()) + reviewer_keys = _recursive_keys(reviewer_package) + assert "strata" not in reviewer_keys + assert "prediction_id" not in reviewer_keys + assert "semantic_hint" not in reviewer_keys diff --git a/tests/test_m48_raw_evidence.py b/tests/test_m48_raw_evidence.py new file mode 100644 index 0000000..f045d24 --- /dev/null +++ b/tests/test_m48_raw_evidence.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import numpy as np +import pytest + +from k1link.laboratory import m48_raw_evidence as raw_module +from k1link.laboratory.m48_object_quality import M48ObjectQualityPack +from k1link.laboratory.m48_raw_evidence import ( + M48_EXPECTED_FRAME_COUNT, + M48_EXPECTED_SESSION_ID, + M48_EXPECTED_SOURCE_ID, + M48_RAW_SPATIAL_FRAME_SCHEMA, + M48RawEvidenceError, + M48RawEvidenceReader, +) +from k1link.perception.geometry import RecordedFrameTemporalBinding +from k1link.perception.threat import ReplayBodyFrame, load_replay_threat_profile + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m4-replay-threat-v3.json" + + +def _canonical_sha256(value: object) -> str: + return hashlib.sha256( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + ).hexdigest() + + +def _sealed_e10_pack(tmp_path: Path) -> tuple[Path, str, str]: + payload = b"sealed-e10-lidar-pack" + artifact_sha256 = hashlib.sha256(payload).hexdigest() + identity = { + "available_lidar_frames": 3928, + "calibration_sha256": "1" * 64, + "camera_slot": "camera_1", + "e6_profile_sha256": "2" * 64, + "e6_result_id": "e6-fixture", + "frame_count": M48_EXPECTED_FRAME_COUNT, + "input_sha256": "3" * 64, + "job_id": "recorded-camera-fixture", + "point_count": 5, + "producer_sha256": "4" * 64, + "projection": { + "height": 600, + "model": "kb4", + "source_coordinates": "k1-map", + "target_camera": "sensor.camera.right", + "width": 800, + }, + "schema_version": "missioncore.e10-lidar-replay-pack/v1", + "semantic_timeline_result_id": "result-fixture", + "session_id": M48_EXPECTED_SESSION_ID, + "source_end_frame_index": M48_EXPECTED_FRAME_COUNT - 1, + "source_id": "sensor.camera.right", + "source_start_frame_index": 0, + "temporal_binding": "accepted-e6-nearest-host-arrival-best-effort", + "temporal_policy": { + "binding": "nearest-host-arrival-best-effort", + "clock_source": "recorded-host-monotonic-arrival", + "maximum_lidar_camera_delta_ms": 100.0, + "maximum_pose_point_delta_ms": 100.0, + }, + "timeline_end_seconds": 484.0, + "timeline_start_seconds": 35.0, + } + identity_sha256 = _canonical_sha256(identity) + pack_id = f"e10-lidar-pack-{identity_sha256}" + root = tmp_path / pack_id + root.mkdir() + (root / "lidar-pack.npz").write_bytes(payload) + manifest = { + "artifact": { + "byte_length": len(payload), + "media_type": "application/x-npz", + "path": "lidar-pack.npz", + "sha256": artifact_sha256, + }, + "classification": "private-recorded-sensor-replay-input", + "created_at_utc": "2026-07-22T06:05:22.515Z", + "ground_truth": False, + "identity": identity, + "identity_sha256": identity_sha256, + "pack_id": pack_id, + "schema_version": "missioncore.e10-lidar-replay-pack/v1", + } + (root / "manifest.json").write_text( + json.dumps(manifest, sort_keys=True, separators=(",", ":")), + encoding="utf-8", + ) + return root, pack_id, artifact_sha256 + + +def test_e10_pack_validation_binds_identity_path_length_and_sha256(tmp_path: Path) -> None: + root, pack_id, artifact_sha256 = _sealed_e10_pack(tmp_path) + + artifact = raw_module._validate_e10_pack( + root, + expected_pack_id=pack_id, + expected_artifact_sha256=artifact_sha256, + ) + + assert artifact == (root / "lidar-pack.npz").resolve() + + (root / "lidar-pack.npz").write_bytes(b"tampered") + with pytest.raises(M48RawEvidenceError, match="artifact content changed"): + raw_module._validate_e10_pack( + root, + expected_pack_id=pack_id, + expected_artifact_sha256=artifact_sha256, + ) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda manifest: manifest["identity"].update(session_id="other"), "identity changed"), + ( + lambda manifest: manifest["artifact"].update(path="../lidar-pack.npz"), + "identity changed", + ), + (lambda manifest: manifest.update(pack_id="e10-lidar-pack-wrong"), "identity changed"), + ], +) +def test_e10_pack_validation_rejects_manifest_escape( + tmp_path: Path, + mutation: object, + message: str, +) -> None: + root, pack_id, artifact_sha256 = _sealed_e10_pack(tmp_path) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert callable(mutation) + mutation(manifest) + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(M48RawEvidenceError, match=message): + raw_module._validate_e10_pack( + root, + expected_pack_id=pack_id, + expected_artifact_sha256=artifact_sha256, + ) + + +@dataclass +class _Store: + source_time_ns: int = 2_000_000_000 + source_available: bool = True + + def temporal_binding_for_index(self, frame_index: int) -> RecordedFrameTemporalBinding: + return RecordedFrameTemporalBinding( + frame_index=frame_index, + source_time_ns=self.source_time_ns, + source_available=self.source_available, + lidar_camera_delta_ms=1.0 if self.source_available else None, + pose_point_delta_ms=1.0 if self.source_available else None, + ) + + def current_points_for_frame(self, frame_index: int) -> np.ndarray: + del frame_index + return np.asarray( + [ + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [3.0, 0.0, 0.0], + [4.0, 0.0, 0.0], + [5.0, 0.0, 0.0], + ], + dtype=np.float64, + ) + + +@dataclass +class _BodyFrames: + available: bool = True + + def body_frame_for_frame(self, frame_id: str) -> ReplayBodyFrame | None: + if not self.available: + return None + return ReplayBodyFrame( + frame_id=frame_id, + origin_map_xyz_m=(1.0, 0.0, 0.0), + basis_map_from_body=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + sensor_height_m=1.25, + surface_slope_deg=0.0, + forward_source="fixture", + camera_forward_alignment_deg=0.0, + ) + + +class _PredictionTrapPack: + result_id = "m48-object-quality-pack-" + "a" * 64 + result_root = REPOSITORY_ROOT + manifest = { + "identity": { + "source": { + "source_id": M48_EXPECTED_SOURCE_ID, + "source_session_id": M48_EXPECTED_SESSION_ID, + } + } + } + report: dict[str, object] = {} + clips: tuple[dict[str, object], ...] = () + frame_references = ( + { + "clip_id": "clip-01", + "sequence": 2, + "source_time_ns": 2_000_000_000, + }, + ) + + @property + def predictions(self) -> object: + raise AssertionError("neutral raw reader opened frozen predictions") + + +def _reader( + tmp_path: Path, + *, + store: _Store | None = None, + body_frames: _BodyFrames | None = None, +) -> M48RawEvidenceReader: + profile = load_replay_threat_profile(PROFILE_PATH) + timeline = SimpleNamespace( + store=store or _Store(), + body_frames=body_frames or _BodyFrames(), + profile=profile, + ) + threat = SimpleNamespace( + result_id="m4-threat-replay-fixture", + result_root=tmp_path, + manifest={"identity": {"frames_sha256": "f" * 64}}, + ) + return M48RawEvidenceReader( + repository_root=tmp_path, + threat_result=threat, + timeline=timeline, + point_limit=2, + ) + + +def test_raw_reader_is_one_based_bounded_body_frame_and_prediction_free( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(raw_module, "_validate_m47_pack_binding", lambda **_: None) + reader = _reader(tmp_path) + pack = cast(M48ObjectQualityPack, _PredictionTrapPack()) + + frame = reader(pack, 2) + + assert set(frame) == { + "schema_version", + "pack_id", + "clip_id", + "sequence", + "source_time_ns", + "source_available", + "body_frame_available", + "point_cloud_body_xyz_m", + "rig", + "corridor", + "occupied_voxel_size_m", + "candidate_identity_included", + "graph_boxes_ids_scores_included", + "frozen_predictions_included", + "strata_included", + "authority", + } + assert frame["schema_version"] == M48_RAW_SPATIAL_FRAME_SCHEMA + assert frame["clip_id"] == "clip-01" + assert frame["sequence"] == 2 + assert frame["source_available"] is True + assert frame["body_frame_available"] is True + assert frame["point_cloud_body_xyz_m"] == [[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]] + assert len(cast(list[object], frame["point_cloud_body_xyz_m"])) <= 2 + assert frame["candidate_identity_included"] is False + assert frame["graph_boxes_ids_scores_included"] is False + assert frame["frozen_predictions_included"] is False + assert frame["strata_included"] is False + assert "metric_obstacles" not in frame + assert "camera_proposals" not in frame + assert "decision_counts" not in frame + assert "body_frame" not in frame + + +def test_raw_reader_fails_closed_on_clip_or_source_time_escape( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(raw_module, "_validate_m47_pack_binding", lambda **_: None) + pack = cast(M48ObjectQualityPack, _PredictionTrapPack()) + reader = _reader(tmp_path) + + with pytest.raises(M48RawEvidenceError, match="outside the selected neutral clips"): + reader(pack, 1) + + mismatched = _reader(tmp_path, store=_Store(source_time_ns=2_000_000_001)) + with pytest.raises(M48RawEvidenceError, match="source time escaped"): + mismatched(pack, 2) + + +def test_raw_reader_emits_empty_cloud_when_body_frame_is_unavailable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(raw_module, "_validate_m47_pack_binding", lambda **_: None) + reader = _reader( + tmp_path, + store=_Store(source_available=False), + body_frames=_BodyFrames(available=False), + ) + pack = cast(M48ObjectQualityPack, _PredictionTrapPack()) + + frame = reader.frame(pack=pack, sequence=2) + + assert frame["source_available"] is False + assert frame["body_frame_available"] is False + assert frame["point_cloud_body_xyz_m"] == [] + assert isinstance(frame["rig"], dict) + assert isinstance(frame["corridor"], dict) + + +@pytest.mark.parametrize("point_limit", [0, 4097, True]) +def test_raw_reader_rejects_unbounded_point_limits( + tmp_path: Path, + point_limit: int, +) -> None: + profile = load_replay_threat_profile(PROFILE_PATH) + timeline = SimpleNamespace(store=_Store(), body_frames=_BodyFrames(), profile=profile) + threat = SimpleNamespace(result_id="fixture", result_root=tmp_path, manifest={}) + + with pytest.raises(M48RawEvidenceError, match="point limit"): + M48RawEvidenceReader( + repository_root=tmp_path, + threat_result=threat, + timeline=timeline, + point_limit=point_limit, + ) diff --git a/tests/test_m48_small_static_regression.py b/tests/test_m48_small_static_regression.py new file mode 100644 index 0000000..dc08179 --- /dev/null +++ b/tests/test_m48_small_static_regression.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import k1link.laboratory.m48_small_static_regression as regression +from k1link.laboratory.evidence_registry import LaboratoryEvidenceRegistry +from k1link.laboratory.evidence_report import verify_laboratory_evidence_result + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +AUTHORITY = { + "mode": "replay-simulated", + "physical_live": False, + "commands_enabled": False, + "actuation_allowed": False, + "navigation_or_safety_accepted": False, +} + + +def _canonical(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + + +def _write_json(path: Path, value: object) -> None: + path.write_bytes(_canonical(value) + b"\n") + + +def _correction(pack_id: str) -> dict[str, object]: + def tracklet(object_id: str, extent: list[float], *, passage: bool) -> dict[str, object]: + return { + "object_id": object_id, + "first_sequence": 10, + "last_sequence": 10, + "keyframes": [{ + "sequence": 10, + "extent_xyxy": extent, + "visibility": "visible", + }], + "state_segments": [{ + "start_sequence": 10, + "end_sequence": 10, + "geometry_association": "unknown", + "freshness": "current", + "motion": "static", + "threat": "not-threat", + "critical_corridor_obstacle": passage, + }], + "notes": None, + } + + return { + "schema_version": "missioncore.m48-assisted-object-correction-session/v1", + "pack_id": pack_id, + "session_id": "m48-correction-session-" + "b" * 64, + "title": "fixture", + "revision": 7, + "state": "saved", + "created_at_utc": "2026-08-24T10:00:00Z", + "updated_at_utc": "2026-08-24T11:00:00Z", + "clips": [{ + "clip_id": "m48-clip-01", + "start_sequence": 1, + "end_sequence": 20, + "review_state": "reviewed", + "no_object": False, + "tracklets": [ + tracklet("object-01", [0.1, 0.1, 0.2, 0.2], passage=True), + tracklet("object-02", [0.7, 0.7, 0.8, 0.8], passage=False), + { + **tracklet("object-03", [0.3, 0.3, 0.4, 0.4], passage=True), + "object_id": "proposal-10-0", + }, + ], + "notes": None, + }], + "progress": {"reviewed_clip_count": 1, "clip_count": 1, "complete": True}, + "seed_summary": { + "worker_id": "006", + "clip_count": 1, + "frame_count": 1, + "object_count": 1, + "prediction_rows_sha256": "c" * 64, + }, + "evidence_summary": None, + "assistance": { + "mode": "frozen-candidate-seeded", + "candidate_predictions_seen": True, + "model_scores_seen": False, + "semantic_class_task_seen": False, + "independent_truth_eligible": False, + }, + "authority": AUTHORITY, + "last_save_idempotency_key": "save-7", + "reviewer_id": None, + "submitted_at_utc": None, + "submission_sha256": None, + "frozen_document_name": None, + } + + +def test_builds_separate_assisted_baseline_without_truth_claim( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pack_id = "m48-object-quality-pack-" + "a" * 64 + pack_root = tmp_path / pack_id + pack_root.mkdir() + pack = SimpleNamespace( + result_id=pack_id, + result_root=pack_root, + manifest={ + "identity_sha256": "a" * 64, + "identity": { + "source": { + "source_id": "RAVNOVES00", + "source_session_id": "source-session", + }, + "freeze": {"prediction_rows_sha256": "c" * 64}, + }, + }, + predictions=({ + "schema_version": "missioncore.m48-frozen-prediction-row/v1", + "clip_id": "m48-clip-01", + "sequence": 10, + "source_time_ns": 100, + "terminal_outcome": "delivered", + "terminal_reason": None, + "free_space_claimed": False, + "objects": [{ + "prediction_id": "proposal-10-0", + "extent_xyxy": [0.1, 0.1, 0.2, 0.2], + "geometry_association": "unknown", + "freshness": "current", + "motion": "static", + "threat": "not-threat", + }], + },), + ) + monkeypatch.setattr(regression, "read_m48_object_quality_pack", lambda _: pack) + correction_path = tmp_path / "correction.json" + _write_json(correction_path, _correction(pack_id)) + profile_path = REPOSITORY_ROOT / "config/perception/m48-small-static-passage-regression-v1.json" + + result = regression.build_m48_small_static_passage_regression( + pack_root=pack_root, + correction_session_path=correction_path, + profile_path=profile_path, + output_root=tmp_path / "results", + run_created_at_utc="2026-08-24T12:00:00Z", + ) + + assert result.report["metrics"]["assisted_anchor_count"] == 2 + assert result.report["metrics"]["worker_recalled_anchor_count"] == 1 + assert result.report["metrics"]["worker_missed_anchor_count"] == 1 + assert result.report["metrics"]["assisted_anchor_recall"] == 0.5 + assert result.manifest["accepted"] is False + assert result.manifest["ground_truth"] is False + assert result.manifest["identity"]["human_lab_id"] == "M4.8" + assert result.manifest["identity"]["experiment_id"] == ( + "m48-small-static-passage-regression/v1" + ) + assert result.report["method"]["execution_class"] == "deterministic" + assert all( + row["authority"] == "operator-assisted-development-anchor-not-truth" + for row in result.anchors + ) + + registry = LaboratoryEvidenceRegistry.from_directory( + REPOSITORY_ROOT / "config/laboratories" + ) + definition = next( + row + for row in registry.definitions + if row.work_id == "m48-small-static-passage-regression" + ) + proof = verify_laboratory_evidence_result(definition, result.result_root) + assert proof["result_id"] == result.result_id + assert proof["artifact_count"] == 3 + + +def test_reader_rejects_changed_comparison_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pack_id = "m48-object-quality-pack-" + "a" * 64 + pack_root = tmp_path / pack_id + pack_root.mkdir() + pack = SimpleNamespace( + result_id=pack_id, + result_root=pack_root, + manifest={ + "identity_sha256": "a" * 64, + "identity": { + "source": {"source_id": "RAVNOVES00", "source_session_id": "source"}, + "freeze": {"prediction_rows_sha256": "c" * 64}, + }, + }, + predictions=({ + "clip_id": "m48-clip-01", + "sequence": 10, + "source_time_ns": 100, + "terminal_outcome": "delivered", + "objects": [], + },), + ) + monkeypatch.setattr(regression, "read_m48_object_quality_pack", lambda _: pack) + correction_path = tmp_path / "correction.json" + document = _correction(pack_id) + document["clips"][0]["tracklets"] = document["clips"][0]["tracklets"][:1] + _write_json(correction_path, document) + result = regression.build_m48_small_static_passage_regression( + pack_root=pack_root, + correction_session_path=correction_path, + profile_path=( + REPOSITORY_ROOT + / "config/perception/m48-small-static-passage-regression-v1.json" + ), + output_root=tmp_path / "results", + run_created_at_utc="2026-08-24T12:00:00Z", + ) + comparison_path = result.result_root / "comparisons.jsonl" + comparison_path.write_bytes(comparison_path.read_bytes() + b"{}\n") + + with pytest.raises( + regression.M48SmallStaticRegressionError, + match="artifact proof", + ): + regression.read_m48_small_static_passage_regression(result.result_root)