From 12c8a2d74ce62cb501c43be3127e424f897de085 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 5 Aug 2026 07:48:58 +0300 Subject: [PATCH] feat(api): publish immutable perception lab evidence --- src/k1link/web/advanced_laboratory_api.py | 299 +++-- src/k1link/web/app.py | 716 +++++++++--- src/k1link/web/e46_blind_review_api.py | 610 ++++++++++ .../e46a_ai_engineering_preannotation_api.py | 383 ++++++ src/k1link/web/e46b_temporal_motion_api.py | 133 +++ .../web/e46c_full_replay_world_tracks_api.py | 396 +++++++ .../web/e46d_temporal_failure_audit_api.py | 114 ++ src/k1link/web/e46e_ready_stack_api.py | 149 +++ src/k1link/web/e46f_dashcam_bakeoff_api.py | 230 ++++ .../e46g_rectified_detector_bakeoff_api.py | 185 +++ .../e46h_full_rectified_front_replay_api.py | 224 ++++ .../e46i_grounding_dino_full_replay_api.py | 194 ++++ .../web/e46j_raw_fisheye_realtime_api.py | 196 ++++ .../web/l32_pointpillars_camera_review_api.py | 323 ++++++ .../l33_camera_first_detector_review_api.py | 394 +++++++ src/k1link/web/l34_annotation_api.py | 1031 +++++++++++++++++ .../web/l34_right_yolox_truth_island_api.py | 450 +++++++ .../l34a_assisted_yolox_error_audit_api.py | 207 ++++ .../web/l34b_nested_box_consolidation_api.py | 202 ++++ src/k1link/web/l34c_tile_seam_stitch_api.py | 206 ++++ .../web/l34d_cumulative_postprocessing_api.py | 207 ++++ .../web/l34e_self_review_diagnostic_api.py | 219 ++++ src/k1link/web/l34f_adjudication_api.py | 601 ++++++++++ tests/test_advanced_laboratory_api.py | 251 +++- tests/test_e46_blind_review_api.py | 352 ++++++ tests/test_e46c_full_replay_video_overlay.py | 126 ++ tests/test_e46e_ready_stack_api.py | 90 ++ tests/test_e46f_dashcam_bakeoff_api.py | 155 +++ ...est_e46g_rectified_detector_bakeoff_api.py | 130 +++ ...st_e46h_full_rectified_front_replay_api.py | 130 +++ ...est_e46i_grounding_dino_full_replay_api.py | 146 +++ tests/test_e46j_raw_fisheye_realtime_api.py | 149 +++ ...st_l33_camera_first_detector_review_api.py | 156 +++ .../test_l34_right_yolox_truth_island_api.py | 593 ++++++++++ tests/test_l34d_blind_annotation_api.py | 214 ++++ tests/test_l34e_self_review_diagnostic_api.py | 123 ++ tests/test_l34f_adjudication_api.py | 240 ++++ 37 files changed, 10259 insertions(+), 265 deletions(-) create mode 100644 src/k1link/web/e46_blind_review_api.py create mode 100644 src/k1link/web/e46a_ai_engineering_preannotation_api.py create mode 100644 src/k1link/web/e46b_temporal_motion_api.py create mode 100644 src/k1link/web/e46c_full_replay_world_tracks_api.py create mode 100644 src/k1link/web/e46d_temporal_failure_audit_api.py create mode 100644 src/k1link/web/e46e_ready_stack_api.py create mode 100644 src/k1link/web/e46f_dashcam_bakeoff_api.py create mode 100644 src/k1link/web/e46g_rectified_detector_bakeoff_api.py create mode 100644 src/k1link/web/e46h_full_rectified_front_replay_api.py create mode 100644 src/k1link/web/e46i_grounding_dino_full_replay_api.py create mode 100644 src/k1link/web/e46j_raw_fisheye_realtime_api.py create mode 100644 src/k1link/web/l32_pointpillars_camera_review_api.py create mode 100644 src/k1link/web/l33_camera_first_detector_review_api.py create mode 100644 src/k1link/web/l34_annotation_api.py create mode 100644 src/k1link/web/l34_right_yolox_truth_island_api.py create mode 100644 src/k1link/web/l34a_assisted_yolox_error_audit_api.py create mode 100644 src/k1link/web/l34b_nested_box_consolidation_api.py create mode 100644 src/k1link/web/l34c_tile_seam_stitch_api.py create mode 100644 src/k1link/web/l34d_cumulative_postprocessing_api.py create mode 100644 src/k1link/web/l34e_self_review_diagnostic_api.py create mode 100644 src/k1link/web/l34f_adjudication_api.py create mode 100644 tests/test_e46_blind_review_api.py create mode 100644 tests/test_e46c_full_replay_video_overlay.py create mode 100644 tests/test_e46e_ready_stack_api.py create mode 100644 tests/test_e46f_dashcam_bakeoff_api.py create mode 100644 tests/test_e46g_rectified_detector_bakeoff_api.py create mode 100644 tests/test_e46h_full_rectified_front_replay_api.py create mode 100644 tests/test_e46i_grounding_dino_full_replay_api.py create mode 100644 tests/test_e46j_raw_fisheye_realtime_api.py create mode 100644 tests/test_l33_camera_first_detector_review_api.py create mode 100644 tests/test_l34_right_yolox_truth_island_api.py create mode 100644 tests/test_l34d_blind_annotation_api.py create mode 100644 tests/test_l34e_self_review_diagnostic_api.py create mode 100644 tests/test_l34f_adjudication_api.py diff --git a/src/k1link/web/advanced_laboratory_api.py b/src/k1link/web/advanced_laboratory_api.py index 6984699..acbcc56 100644 --- a/src/k1link/web/advanced_laboratory_api.py +++ b/src/k1link/web/advanced_laboratory_api.py @@ -56,14 +56,11 @@ from k1link.compute.e40_perception_product_gate import ( read_e40_perception_product_gate, ) 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 +from k1link.web.l33_camera_first_detector_review_api import latest_l33_identity -LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = ( - "missioncore.laboratory-advanced-catalog/v1" -) -LABORATORY_ADVANCED_INDEX_SCHEMA: Final = ( - "missioncore.laboratory-advanced-index/v1" -) +LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = "missioncore.laboratory-advanced-catalog/v1" +LABORATORY_ADVANCED_INDEX_SCHEMA: Final = "missioncore.laboratory-advanced-index/v1" _INDEX_DOCUMENT_MAX_BYTES: Final = 64 * 1024 _E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$") @@ -75,6 +72,24 @@ _E37_RESULT_ID = re.compile(r"^e37-ravnoves-acceptance-[a-f0-9]{64}$") _E38_RESULT_ID = re.compile(r"^e38-perception-baseline-[a-f0-9]{64}$") _E39_RESULT_ID = re.compile(r"^e39-perception-refinement-[a-f0-9]{64}$") _E40_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$") +_E46_RESULT_ID = re.compile(r"^e46-detector-truth-island-[a-f0-9]{64}$") +_E46A_RESULT_ID = re.compile(r"^e46a-ai-engineering-preannotation-[a-f0-9]{64}$") +_E46B_RESULT_ID = re.compile(r"^e46b-temporal-motion-[a-f0-9]{64}$") +_E46C_RESULT_ID = re.compile(r"^e46c-full-replay-world-tracks-[a-f0-9]{64}$") +_E46D_RESULT_ID = re.compile(r"^e46d-temporal-failure-audit-[a-f0-9]{64}$") +_E46E_RESULT_ID = re.compile(r"^e46e-ready-stack-[a-f0-9]{64}$") +_E46F_RESULT_ID = re.compile(r"^e46f-dashcam-bakeoff-[a-f0-9]{64}$") +_E46G_RESULT_ID = re.compile(r"^e46g-rectified-detector-bakeoff-[a-f0-9]{64}$") +_E46H_RESULT_ID = re.compile(r"^e46h-full-rectified-front-replay-[a-f0-9]{64}$") +_E46I_RESULT_ID = re.compile(r"^e46i-grounding-dino-full-replay-[a-f0-9]{64}$") +_E46J_RESULT_ID = re.compile(r"^e46j-raw-fisheye-realtime-[a-f0-9]{64}$") +_L34_RESULT_ID = re.compile(r"^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$") +_L34A_RESULT_ID = re.compile(r"^l34a-assisted-yolox-error-audit-[a-f0-9]{64}$") +_L34B_RESULT_ID = re.compile(r"^l34b-nested-box-consolidation-shadow-[a-f0-9]{64}$") +_L34C_RESULT_ID = re.compile(r"^l34c-tile-seam-stitch-shadow-[a-f0-9]{64}$") +_L34D_RESULT_ID = re.compile(r"^l34d-cumulative-postprocessing-candidate-[a-f0-9]{64}$") +_L34E_RESULT_ID = re.compile(r"^l34e-self-review-diagnostic-[a-f0-9]{64}$") +_L34F_RESULT_ID = re.compile(r"^l34f-adjudicated-reference-[a-f0-9]{64}$") RootProvider = Callable[[], Path | None] @@ -328,12 +343,8 @@ def _project_e31(result: E31SourceQualification) -> dict[str, object]: "correspondence_count": offset.get("correspondence_count"), "supported_fraction": offset.get("baseline_supported_fraction"), "semantic_self_sample_count": semantic_mask.get("source_sample_count"), - "semantic_self_collateral_count": semantic_mask.get( - "collateral_item_count" - ), - "exact_geometry_correction_count": len( - self_mask.get("exact_correction_item_ids", []) - ), + "semantic_self_collateral_count": semantic_mask.get("collateral_item_count"), + "exact_geometry_correction_count": len(self_mask.get("exact_correction_item_ids", [])), "geometry_point_mask_status": geometry_mask.get("status"), }, "limitations": copy.deepcopy(result.report.get("limitations")), @@ -375,13 +386,9 @@ def _project_e32(result: E32TrackGeometryReplay) -> dict[str, object]: "semantic_masked": objects.get("semantic_masked"), "geometry_published": objects.get("geometry_published"), "observations_arbitrated": ownership.get("observations_arbitrated"), - "overlapping_claims_removed": ownership.get( - "overlapping_claims_removed" - ), + "overlapping_claims_removed": ownership.get("overlapping_claims_removed"), "qualified_points_published": point_rows.get("e32_published"), - "qualified_points_withheld": point_rows.get( - "unqualified_ranges_withheld" - ), + "qualified_points_withheld": point_rows.get("unqualified_ranges_withheld"), "frame_processing_p95_ms": frame_processing.get("p95"), "build_elapsed_ms": runtime.get("build_elapsed_ms"), }, @@ -444,8 +451,7 @@ def _project_e33( "source_frames": accounting.get("source_frames"), "source_available_frames": linked_frames.get("source_available"), "source_unavailable_frames": ( - int(accounting["source_frames"]) - - int(linked_frames["source_available"]) + int(accounting["source_frames"]) - int(linked_frames["source_available"]) ), "delivered_frames": accounting.get("delivered"), "input_superseded": accounting.get("input_superseded"), @@ -460,9 +466,7 @@ def _project_e33( "result_deadline_ms": deadlines.get("result_ms"), "process_rss_p95_mib": process_rss.get("p95"), "process_rss_max_mib": process_rss.get("maximum"), - "process_rss_limit_mib": profile_acceptance.get( - "maximum_process_rss_mib" - ), + "process_rss_limit_mib": profile_acceptance.get("maximum_process_rss_mib"), "gpu_utilization_p95_percent": gpu_utilization.get("p95"), "gpu_visible": resources.get("gpu_visible"), "work_queue_capacity": work_queue.get("capacity"), @@ -507,15 +511,9 @@ def _project_e34(result: E34TemporalOccupiedReplay) -> dict[str, object]: "configuration": { "voxel_size_m": layer.get("voxel_size_m"), "occupied_ttl_seconds": layer.get("occupied_ttl_seconds"), - "maximum_active_components": layer.get( - "maximum_active_components" - ), - "maximum_cells_per_component": layer.get( - "maximum_cells_per_component" - ), - "geometry_maximum_gap_seconds": association.get( - "geometry_maximum_gap_seconds" - ), + "maximum_active_components": layer.get("maximum_active_components"), + "maximum_cells_per_component": layer.get("maximum_cells_per_component"), + "geometry_maximum_gap_seconds": association.get("geometry_maximum_gap_seconds"), "geometry_maximum_centroid_distance_m": association.get( "geometry_maximum_centroid_distance_m" ), @@ -529,44 +527,26 @@ def _project_e34(result: E34TemporalOccupiedReplay) -> dict[str, object]: "created_components": components.get("created"), "camera_created_components": components.get("camera_created"), "geometry_created_components": components.get("geometry_created"), - "geometry_reassociated_components": components.get( - "geometry_reassociated_components" - ), - "exact_camera_associations": components.get( - "exact_camera_associations" - ), - "geometry_spatial_reassociations": components.get( - "geometry_spatial_reassociations" - ), + "geometry_reassociated_components": components.get("geometry_reassociated_components"), + "exact_camera_associations": components.get("exact_camera_associations"), + "geometry_spatial_reassociations": components.get("geometry_spatial_reassociations"), "held_publications": components.get("held_publications"), "expired_components": components.get("expired"), "peak_active_components": components.get("peak_active"), - "maximum_observed_span_seconds": components.get( - "maximum_observed_span_seconds" - ), + "maximum_observed_span_seconds": components.get("maximum_observed_span_seconds"), "continuity_fraction": components.get("continuity_fraction"), "geometry_only_reassociation_fraction": components.get( "geometry_only_reassociation_fraction" ), - "e32_current_point_rows": occupancy.get( - "e32_current_point_rows" - ), - "e34_consumed_current_point_rows": occupancy.get( - "e34_consumed_current_point_rows" - ), + "e32_current_point_rows": occupancy.get("e32_current_point_rows"), + "e34_consumed_current_point_rows": occupancy.get("e34_consumed_current_point_rows"), "current_cell_rows": occupancy.get("current_cell_rows"), "held_cell_rows": occupancy.get("held_cell_rows"), "stored_cell_rows": occupancy.get("stored_cell_rows"), "free_cell_rows": occupancy.get("free_cell_rows"), - "peak_cells_per_component": occupancy.get( - "peak_cells_per_component" - ), - "maximum_held_age_seconds": aging.get( - "maximum_held_age_seconds" - ), - "maximum_expiry_delay_seconds": aging.get( - "maximum_expiry_delay_seconds" - ), + "peak_cells_per_component": occupancy.get("peak_cells_per_component"), + "maximum_held_age_seconds": aging.get("maximum_held_age_seconds"), + "maximum_expiry_delay_seconds": aging.get("maximum_expiry_delay_seconds"), "maximum_replay_materialization_delay_seconds": aging.get( "maximum_replay_materialization_delay_seconds" ), @@ -611,19 +591,13 @@ def _project_e35(result: E35DegradationReplay) -> dict[str, object]: "pipeline_id": identity.get("pipeline"), "coordinate_frame": identity.get("coordinate_frame"), "configuration": { - "maximum_recovery_seconds": acceptance_profile.get( - "maximum_recovery_seconds" - ), - "scenario_count": acceptance_profile.get( - "expected_scenario_count" - ), + "maximum_recovery_seconds": acceptance_profile.get("maximum_recovery_seconds"), + "scenario_count": acceptance_profile.get("expected_scenario_count"), }, "metrics": { "source_frames": accounting.get("source_frames"), "variant_count": accounting.get("variant_count"), - "variant_frame_outcomes": accounting.get( - "variant_frame_outcomes" - ), + "variant_frame_outcomes": accounting.get("variant_frame_outcomes"), "injection_records": accounting.get("injection_records"), "variant_frame_processing_p95_ms": processing.get("p95"), "build_elapsed_ms": runtime.get("build_elapsed_ms"), @@ -669,18 +643,12 @@ def _project_e37(result: E37AcceptanceContract) -> dict[str, object]: "accounting_fraction": metrics.get("accounting_fraction"), "false_free_claims": metrics.get("false_free_claims"), }, - "dimension_distributions": copy.deepcopy( - result.contract.get("dimension_distributions") - ), - "severity_distribution": copy.deepcopy( - result.contract.get("severity_distribution") - ), + "dimension_distributions": copy.deepcopy(result.contract.get("dimension_distributions")), + "severity_distribution": copy.deepcopy(result.contract.get("severity_distribution")), "label_provenance": { "engineering_items": provenance.get("engineering_items"), "human_exception_items": provenance.get("human_exception_items"), - "independent_ground_truth": provenance.get( - "independent_ground_truth" - ), + "independent_ground_truth": provenance.get("independent_ground_truth"), }, "split": copy.deepcopy(result.contract.get("split")), "targets": copy.deepcopy(result.contract.get("targets")), @@ -827,8 +795,28 @@ def build_advanced_laboratory_router( e38_root_provider: RootProvider = lambda: None, e39_root_provider: RootProvider = lambda: None, e40_root_provider: RootProvider = lambda: None, + e46_root_provider: RootProvider = lambda: None, + e46a_root_provider: RootProvider = lambda: None, + e46b_root_provider: RootProvider = lambda: None, + e46c_root_provider: RootProvider = lambda: None, + e46d_root_provider: RootProvider = lambda: None, + e46e_root_provider: RootProvider = lambda: None, + e46f_root_provider: RootProvider = lambda: None, + e46g_root_provider: RootProvider = lambda: None, + e46h_root_provider: RootProvider = lambda: None, + e46i_root_provider: RootProvider = lambda: None, + e46j_root_provider: RootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + l34a_root_provider: RootProvider = lambda: None, + l34b_root_provider: RootProvider = lambda: None, + l34c_root_provider: RootProvider = lambda: None, + l34d_root_provider: RootProvider = lambda: None, + l34e_root_provider: RootProvider = lambda: None, + l34f_root_provider: RootProvider = lambda: None, l3_visual_root_provider: RootProvider = lambda: None, l31_ravnoves_root_provider: RootProvider = lambda: None, + l32_camera_review_root_provider: RootProvider = lambda: None, + l33_camera_first_review_root_provider: RootProvider = lambda: None, ) -> APIRouter: router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) @@ -899,23 +887,161 @@ def build_advanced_laboratory_router( "manifest.json", "missioncore.e40-perception-product-gate/v1", ), + ( + "e46-detector-truth-island", + e46_root_provider, + _E46_RESULT_ID, + "manifest.json", + "missioncore.e46-detector-truth-island/v1", + ), + ( + "e46a-ai-engineering-preannotation", + e46a_root_provider, + _E46A_RESULT_ID, + "manifest.json", + "missioncore.e46a-ai-engineering-preannotation/v1", + ), + ( + "e46b-temporal-motion", + e46b_root_provider, + _E46B_RESULT_ID, + "manifest.json", + "missioncore.e46b-temporal-motion/v1", + ), + ( + "e46c-full-replay-world-tracks", + e46c_root_provider, + _E46C_RESULT_ID, + "manifest.json", + "missioncore.e46c-full-replay-world-tracks/v1", + ), + ( + "e46d-temporal-failure-audit", + e46d_root_provider, + _E46D_RESULT_ID, + "manifest.json", + "missioncore.e46d-temporal-failure-audit/v1", + ), + ( + "e46e-ready-stack", + e46e_root_provider, + _E46E_RESULT_ID, + "manifest.json", + "missioncore.e46e-ready-stack-result/v1", + ), + ( + "e46f-dashcam-bakeoff", + e46f_root_provider, + _E46F_RESULT_ID, + "manifest.json", + "missioncore.e46f-dashcam-bakeoff-result/v1", + ), + ( + "e46g-rectified-detector-bakeoff", + e46g_root_provider, + _E46G_RESULT_ID, + "manifest.json", + "missioncore.e46g-rectified-detector-bakeoff-result/v1", + ), + ( + "e46h-full-rectified-front-replay", + e46h_root_provider, + _E46H_RESULT_ID, + "manifest.json", + "missioncore.e46h-full-rectified-front-replay-result/v1", + ), + ( + "e46i-grounding-dino-full-replay", + e46i_root_provider, + _E46I_RESULT_ID, + "manifest.json", + "missioncore.e46i-grounding-dino-full-replay-result/v1", + ), + ( + "e46j-raw-fisheye-realtime", + e46j_root_provider, + _E46J_RESULT_ID, + "manifest.json", + "missioncore.e46j-raw-fisheye-realtime-result/v1", + ), + ( + "l34-right-yolox-truth-island-freeze", + l34_root_provider, + _L34_RESULT_ID, + "manifest.json", + "missioncore.l34-right-yolox-truth-island-freeze/v1", + ), + ( + "l34a-assisted-yolox-error-audit", + l34a_root_provider, + _L34A_RESULT_ID, + "manifest.json", + "missioncore.l34a-assisted-yolox-error-audit/v1", + ), + ( + "l34b-nested-box-consolidation-shadow", + l34b_root_provider, + _L34B_RESULT_ID, + "manifest.json", + "missioncore.l34b-nested-box-consolidation-shadow/v1", + ), + ( + "l34c-tile-seam-stitch-shadow", + l34c_root_provider, + _L34C_RESULT_ID, + "manifest.json", + "missioncore.l34c-tile-seam-stitch-shadow/v1", + ), + ( + "l34d-cumulative-postprocessing-candidate", + l34d_root_provider, + _L34D_RESULT_ID, + "manifest.json", + "missioncore.l34d-cumulative-postprocessing-candidate/v1", + ), + ( + "l34e-self-review-diagnostic", + l34e_root_provider, + _L34E_RESULT_ID, + "manifest.json", + "missioncore.l34e-self-review-diagnostic/v1", + ), + ( + "l34f-adjudicated-reference", + l34f_root_provider, + _L34F_RESULT_ID, + "manifest.json", + "missioncore.l34f-adjudicated-reference/v1", + ), ) ) + items = result["items"] + if not isinstance(items, list): + raise RuntimeError("advanced LAB index items are invalid") l3_identity = latest_l3_visual_identity(l3_visual_root_provider) if l3_identity is not None: - result["items"].append( + items.append( { "work_id": "l3-pointpillars-visual-audit", **l3_identity, "access": "read-only", } ) - l31_identity = latest_l31_identity(l31_ravnoves_root_provider) - if l31_identity is not None: - result["items"].append( + l32_identity = latest_l32_identity(l32_camera_review_root_provider) + if l32_identity is not None: + items.append( { - "work_id": "l31-pointpillars-ravnoves", - **l31_identity, + "work_id": "l32-pointpillars-camera-review", + **l32_identity, + "access": "read-only", + } + ) + l33_identity = latest_l33_identity(l33_camera_first_review_root_provider) + if l33_identity is not None: + items.append( + { + "work_id": "l33-camera-first-detector-review", + **l33_identity, "access": "read-only", } ) @@ -972,10 +1098,7 @@ def build_advanced_laboratory_router( str(candidate.resolve()), _result_signature(candidate), ) - if ( - result.report.get("status") - != "accepted-diagnostic-track-geometry-replay" - ): + if result.report.get("status") != "accepted-diagnostic-track-geometry-replay": raise ValueError("E32 result is not accepted") if len(items) < limit: items.append(_project_e32(result)) diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 9545c48..0178e73 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -41,6 +41,31 @@ from k1link.web.e30_engineering_api import build_e30_engineering_router from k1link.web.e30_human_review_api import build_e30_human_review_router from k1link.web.e30_review_api import build_e30_review_router from k1link.web.e40_case_review_api import build_e40_case_review_router +from k1link.web.e46_blind_review_api import build_e46_blind_review_router +from k1link.web.e46a_ai_engineering_preannotation_api import ( + build_e46a_ai_engineering_preannotation_router, +) +from k1link.web.e46b_temporal_motion_api import build_e46b_temporal_motion_router +from k1link.web.e46c_full_replay_world_tracks_api import ( + build_e46c_full_replay_world_tracks_router, +) +from k1link.web.e46d_temporal_failure_audit_api import ( + build_e46d_temporal_failure_audit_router, +) +from k1link.web.e46e_ready_stack_api import build_e46e_ready_stack_router +from k1link.web.e46f_dashcam_bakeoff_api import build_e46f_dashcam_bakeoff_router +from k1link.web.e46g_rectified_detector_bakeoff_api import ( + build_e46g_rectified_detector_bakeoff_router, +) +from k1link.web.e46h_full_rectified_front_replay_api import ( + build_e46h_full_rectified_front_replay_router, +) +from k1link.web.e46i_grounding_dino_full_replay_api import ( + build_e46i_grounding_dino_full_replay_router, +) +from k1link.web.e46j_raw_fisheye_realtime_api import ( + build_e46j_raw_fisheye_realtime_router, +) from k1link.web.environment_api import build_environment_router from k1link.web.l3_pointpillars_visual_api import ( build_l3_pointpillars_visual_router, @@ -48,6 +73,35 @@ from k1link.web.l3_pointpillars_visual_api import ( from k1link.web.l31_pointpillars_ravnoves_api import ( build_l31_pointpillars_ravnoves_router, ) +from k1link.web.l32_pointpillars_camera_review_api import ( + build_l32_pointpillars_camera_review_router, +) +from k1link.web.l33_camera_first_detector_review_api import ( + build_l33_camera_first_detector_review_router, +) +from k1link.web.l34_annotation_api import ( + build_l34_annotation_router, + build_l34d_blind_annotation_router, +) +from k1link.web.l34_right_yolox_truth_island_api import ( + build_l34_right_yolox_truth_island_freeze_router, +) +from k1link.web.l34a_assisted_yolox_error_audit_api import ( + build_l34a_assisted_yolox_error_audit_router, +) +from k1link.web.l34b_nested_box_consolidation_api import ( + build_l34b_nested_box_consolidation_router, +) +from k1link.web.l34c_tile_seam_stitch_api import ( + build_l34c_tile_seam_stitch_router, +) +from k1link.web.l34d_cumulative_postprocessing_api import ( + build_l34d_cumulative_postprocessing_router, +) +from k1link.web.l34e_self_review_diagnostic_api import ( + build_l34e_self_review_diagnostic_router, +) +from k1link.web.l34f_adjudication_api import build_l34f_adjudication_router from k1link.web.laboratory_api import build_laboratory_router from k1link.web.lidar_api import build_lidar_router from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService @@ -139,18 +193,10 @@ session_integrated_perception_store = ( IntegratedPerceptionOverlayStore( jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs", results_root=( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e10" - / "worker-results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e10" / "worker-results" ), lidar_packs_root=( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e10" - / "lidar-packs" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e10" / "lidar-packs" ), cache_root=session_store.data_dir / "integrated-perception-overlays", ffmpeg_path=_ffmpeg, @@ -304,9 +350,7 @@ async def _recording_preparation_reconciler() -> None: async def app_lifespan(_: FastAPI) -> AsyncIterator[None]: reconciler: asyncio.Task[None] | None = None try: - configure_scanner_diagnostics( - REPOSITORY_ROOT / ".runtime" / "mission-core" / "logs" - ) + configure_scanner_diagnostics(REPOSITORY_ROOT / ".runtime" / "mission-core" / "logs") session_recording_preparation_manager.start() # Recovery is intentionally a one-shot startup phase. The archive # helper owns a cross-process lease, while ordinary catalog requests @@ -441,9 +485,7 @@ app.include_router( ) ) app.include_router( - build_environment_router( - root_provider=lambda: session_store.data_dir / "ui-environment" - ) + build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment") ) app.include_router(build_map_router(map_gateway_proxy)) app.include_router( @@ -453,25 +495,18 @@ app.include_router( ) app.include_router( build_polygon_router( - root_provider=lambda: configured_polygon_runs_root() - or REPOSITORY_ROOT / ".runtime" / "polygon-runs" + root_provider=lambda: ( + configured_polygon_runs_root() or REPOSITORY_ROOT / ".runtime" / "polygon-runs" + ) ) ) app.include_router( build_lidar_router( root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "lidar-replay-v2" - / "packs" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "lidar-replay-v2" / "packs" ), ground_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "lidar-ground-v1" - / "benchmarks" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "lidar-ground-v1" / "benchmarks" ), field_review_root_provider=lambda: ( REPOSITORY_ROOT @@ -481,18 +516,10 @@ app.include_router( / "reviews" ), local_surface_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "k1-local-surface-v1" - / "models" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "k1-local-surface-v1" / "models" ), e10_source_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e10" - / "lidar-packs" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e10" / "lidar-packs" ), dataset_admission_provider=lambda: ( REPOSITORY_ROOT / ".runtime" / "dataset-gateway" / "admission.json" @@ -518,221 +545,574 @@ app.include_router( REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e29" / "results" ), local_surface_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "k1-local-surface-v1" - / "models" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "k1-local-surface-v1" / "models" ), source_pack_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e10" - / "lidar-packs" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e10" / "lidar-packs" ), source_result_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e10" - / "worker-results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e10" / "worker-results" ), ) ) app.include_router( build_advanced_laboratory_router( e31_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e31" - / "source-qualifications" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e31" / "source-qualifications" ), e32_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e32" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e32" / "results" ), e33_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e33" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e33" / "results" ), e34_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e34" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e34" / "results" ), e35_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e35" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e35" / "results" ), e37_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e37" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e37" / "results" ), e38_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e38" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e38" / "results" ), e39_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e39" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e39" / "results" ), e40_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e40" / "results" + ), + e46_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + e46a_root_provider=lambda: ( REPOSITORY_ROOT / ".runtime" / "compute-experiments" - / "e40" - / "results" + / "e46a" + / "ai-engineering-preannotations" + ), + e46b_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46b" / "temporal-motion" + ), + e46c_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46c" + / "full-replay-world-tracks" + ), + e46d_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46d" + / "temporal-failure-audits" + ), + e46e_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46e" + / "ready-stack-results" + ), + e46f_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46f" + / "dashcam-bakeoff-results" + ), + e46g_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46g" / "results" + ), + e46h_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46h" / "results" + ), + e46i_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46i" / "results" + ), + e46j_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46j" / "results" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + l34a_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "assisted-yolox-error-audits" + ), + l34b_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "nested-box-consolidation-shadows" + ), + l34c_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "tile-seam-stitch-shadows" + ), + l34d_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "cumulative-postprocessing-candidates" + ), + l34e_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "self-review-diagnostics" + ), + l34f_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "adjudicated-references" ), l3_visual_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "l3" - / "visual-audits" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "visual-audits" ), l31_ravnoves_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "pointpillars-ravnoves" + ), + l32_camera_review_root_provider=lambda: ( REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" - / "pointpillars-ravnoves" + / "pointpillars-camera-review" + ), + l33_camera_first_review_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "camera-first-detector-review" ), ) ) app.include_router( build_l3_pointpillars_visual_router( root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "l3" - / "visual-audits" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "visual-audits" ) ) ) app.include_router( build_l31_pointpillars_ravnoves_router( + root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "pointpillars-ravnoves" + ) + ) +) +app.include_router( + build_l32_pointpillars_camera_review_router( root_provider=lambda: ( REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" - / "pointpillars-ravnoves" + / "pointpillars-camera-review" ) ) ) +app.include_router( + build_l33_camera_first_detector_review_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "camera-first-detector-review" + ) + ) +) +app.include_router( + build_l34_right_yolox_truth_island_freeze_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + ) +) +app.include_router( + build_e46_blind_review_router( + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + annotation_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "laboratory-annotations" / "e46-blind" + ), + submission_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e48" / "review-submissions" + ), + ) +) +app.include_router( + build_e46a_ai_engineering_preannotation_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46a" + / "ai-engineering-preannotations" + ), + annotation_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "laboratory-annotations" / "e46a-correction" + ), + ) +) +app.include_router( + build_e46b_temporal_motion_router( + root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46b" / "temporal-motion" + ), + ) +) +app.include_router( + build_e46c_full_replay_world_tracks_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46c" + / "full-replay-world-tracks" + ), + e26_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e10" / "worker-results" + ), + ) +) +app.include_router( + build_e46d_temporal_failure_audit_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46d" + / "temporal-failure-audits" + ), + ) +) +app.include_router( + build_e46e_ready_stack_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46e" + / "ready-stack-results" + ), + ) +) +app.include_router( + build_e46f_dashcam_bakeoff_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46f" + / "dashcam-bakeoff-results" + ), + e46e_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e46e" + / "ready-stack-results" + ), + ) +) +app.include_router( + build_e46g_rectified_detector_bakeoff_router( + root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46g" / "results" + ) + ) +) +app.include_router( + build_e46h_full_rectified_front_replay_router( + root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46h" / "results" + ) + ) +) +app.include_router( + build_e46i_grounding_dino_full_replay_router( + root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46i" / "results" + ) + ) +) +app.include_router( + build_e46j_raw_fisheye_realtime_router( + root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46j" / "results" + ) + ) +) +app.include_router( + build_l34_annotation_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + annotation_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "laboratory-annotations" / "l34" + ), + ) +) +app.include_router( + build_l34a_assisted_yolox_error_audit_router( + audit_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "assisted-yolox-error-audits" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + ) +) +app.include_router( + build_l34b_nested_box_consolidation_router( + shadow_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "nested-box-consolidation-shadows" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + ) +) +app.include_router( + build_l34c_tile_seam_stitch_router( + shadow_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "tile-seam-stitch-shadows" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + ) +) +app.include_router( + build_l34d_cumulative_postprocessing_router( + candidate_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "cumulative-postprocessing-candidates" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + ) +) +app.include_router( + build_l34d_blind_annotation_router( + candidate_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "cumulative-postprocessing-candidates" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + annotation_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "laboratory-annotations" / "l34d-blind" + ), + ) +) +app.include_router( + build_l34e_self_review_diagnostic_router( + diagnostic_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "self-review-diagnostics" + ), + candidate_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "cumulative-postprocessing-candidates" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + ) +) +app.include_router( + build_l34f_adjudication_router( + diagnostic_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "self-review-diagnostics" + ), + candidate_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "cumulative-postprocessing-candidates" + ), + l34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "right-yolox-truth-island-freeze" + ), + truth_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e46" / "results" + ), + evaluation_pack_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e2" / "evaluation-packs" + ), + adjudication_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "laboratory-annotations" / "l34f-adjudication" + ), + result_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "l3" / "adjudicated-references" + ), + ) +) app.include_router( build_e30_review_router( materialization_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "materializations" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "materializations" ), review_pack_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "review-packs" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "review-packs" ), ) ) app.include_router( build_e40_case_review_router( e40_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e40" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e40" / "results" ), operator_review_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e40" - / "operator-reviews" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e40" / "operator-reviews" ), ) ) app.include_router( build_e30_engineering_router( generation_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "engineering-generations" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "engineering-generations" ), materialization_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "materializations" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "materializations" ), review_pack_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "review-packs" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "review-packs" ), ) ) app.include_router( build_e30_human_review_router( materialization_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "materializations" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "materializations" ), review_pack_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "review-packs" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "review-packs" ), engineering_generation_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "engineering-generations" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "engineering-generations" ), draft_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e30" - / "human-review-drafts" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e30" / "human-review-drafts" ), generation_root_provider=lambda: ( REPOSITORY_ROOT @@ -752,18 +1132,10 @@ app.include_router( build_artifact_health_router( runtime_root_provider=lambda: REPOSITORY_ROOT / ".runtime", e44_results_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e44" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e44" / "results" ), e50_results_root_provider=lambda: ( - REPOSITORY_ROOT - / ".runtime" - / "compute-experiments" - / "e50" - / "results" + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e50" / "results" ), ) ) diff --git a/src/k1link/web/e46_blind_review_api.py b/src/k1link/web/e46_blind_review_api.py new file mode 100644 index 0000000..b54932d --- /dev/null +++ b/src/k1link/web/e46_blind_review_api.py @@ -0,0 +1,610 @@ +"""Source-only E46 review collection for two independent human slots.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import re +import secrets +from collections.abc import Callable +from pathlib import Path, PurePosixPath +from typing import Annotated, Any, Final, Literal + +from fastapi import APIRouter, Header, HTTPException, Query +from fastapi import Path as ApiPath +from fastapi.responses import FileResponse +from pydantic import BaseModel, ConfigDict, Field + +from k1link.artifacts import write_json_atomic +from k1link.compute.e46_detector_truth_island import ( + E46_REFERENCES_NAME, + E46DetectorTruthIsland, + E46DetectorTruthIslandError, + read_e46_detector_truth_island, +) +from k1link.compute.e46_lab_review_submission import ( + E46LabReviewSubmissionError, + build_e46_lab_review_submission, + read_e46_lab_review_submission, +) +from k1link.web.l34_annotation_api import ( + L34_ANNOTATION_LABEL_CATALOG_SCHEMA, + L34_ANNOTATION_SESSION_CATALOG_SCHEMA, + L34_ANNOTATION_SOURCE_CATALOG_SCHEMA, + L34_ANNOTATION_SOURCE_SCHEMA, + L34AnnotationCreateRequest, + L34AnnotationSaveRequest, + _L34AnnotationSessionStore, + _normalize_object, + _png_dimensions, + _source_projection, +) + +_TRUTH_ID = re.compile(r"^e46-detector-truth-island-[a-f0-9]{64}$") +_SESSION_ID = re.compile(r"^l34-annotation-session-[a-f0-9]{64}$") +_SUBMISSION_ID = re.compile(r"^e46-lab-review-submission-[a-f0-9]{64}$") +_PACK_ID = re.compile(r"^evaluation-pack-[a-f0-9]{64}$") +_REVIEWER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$") +_TARGET_CLASSES: Final = ( + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", +) +_AUTHORITY: Final = { + "ground_truth": False, + "independent_truth": False, + "metric_grade_reference": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, +} + +RootProvider = Callable[[], Path | None] + + +class E46ReviewFreezeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + reviewer_id: str = Field(min_length=2, max_length=64) + independent_attestation: Literal[True] + candidate_identity_not_seen: Literal[True] + model_material_not_seen: Literal[True] + + +def build_e46_blind_review_router( + *, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, + annotation_root_provider: RootProvider = lambda: None, + submission_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46", tags=["laboratory"]) + store = _L34AnnotationSessionStore( + annotation_root_provider, + result_id_pattern=_TRUTH_ID, + lab_label="E46", + initial_assistance_mode="prediction-free-manual", + allowed_assistance_modes=frozenset({"prediction-free-manual"}), + ) + + def require_capability( + result_id: str, + session_id: str, + capability: str | None, + ) -> None: + sidecar = store.session_path(result_id, session_id).with_suffix( + ".capability.json" + ) + try: + document = json.loads(sidecar.read_text(encoding="utf-8")) + except (OSError, ValueError): + raise HTTPException(status_code=403, detail="E46 reviewer slot недоступен") from None + expected = document.get("capability") if isinstance(document, dict) else None + if ( + not isinstance(capability, str) + or not isinstance(expected, str) + or not secrets.compare_digest(capability, expected) + ): + raise HTTPException(status_code=403, detail="E46 reviewer slot недоступен") + + def truth(result_id: str) -> tuple[E46DetectorTruthIsland, tuple[dict[str, Any], ...]]: + if _TRUTH_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46 truth island не найден") + root = _configured_root(truth_root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46 truth island не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46 truth island не найден") + try: + result = read_e46_detector_truth_island(path) + references = tuple(_read_jsonl(result.result_root / E46_REFERENCES_NAME)) + except (E46DetectorTruthIslandError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46 truth island не найден") from None + return result, references + + def source( + result_id: str, + sequence: int, + ) -> tuple[E46DetectorTruthIsland, dict[str, Any], Path]: + result, references = truth(result_id) + reference = next( + (item for item in references if item.get("truth_island_sequence") == sequence), + None, + ) + if reference is None: + raise HTTPException(status_code=404, detail="E46 source frame не найден") + identity = result.manifest.get("identity") + source_identity = identity.get("source") if isinstance(identity, dict) else None + pack_id = ( + source_identity.get("evaluation_pack_id") + if isinstance(source_identity, dict) + else None + ) + pack_root = _configured_root(evaluation_pack_root_provider) + if ( + pack_root is None + or not isinstance(pack_id, str) + or _PACK_ID.fullmatch(pack_id) is None + ): + raise HTTPException(status_code=404, detail="E46 image source не найден") + pack = (pack_root / pack_id).resolve() + relative = reference.get("source_path") + if not isinstance(relative, str): + raise HTTPException(status_code=404, detail="E46 image source не найден") + pure = PurePosixPath(relative) + if ( + pack.parent != pack_root + or pack.is_symlink() + or not pack.is_dir() + or pure.is_absolute() + or ".." in pure.parts + or pure.parts[:2] != ("images", "valid-fov-fill") + or pure.suffix.lower() != ".png" + ): + raise HTTPException(status_code=404, detail="E46 image source не найден") + image = (pack / Path(*pure.parts)).resolve() + if ( + not image.is_relative_to(pack) + or image.is_symlink() + or not image.is_file() + or image.stat().st_size != reference.get("byte_length") + or _sha256(image) != reference.get("sha256") + ): + raise HTTPException(status_code=404, detail="E46 image source изменён") + return result, reference, image + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _truth_candidates(truth_root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + result, references = truth(path.name) + if len(items) < limit: + items.append( + _project_result( + result, + references, + sessions=store.list(result.result_id), + submissions=_submissions_for_truth( + submission_root_provider, + result.result_root, + result.result_id, + ), + ) + ) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46-detector-truth-island-catalog/v1", + "configured": _configured_root(truth_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/results/{result_id}/annotation-source") + def get_source_catalog(result_id: str) -> dict[str, object]: + result, references = truth(result_id) + frames: list[dict[str, object]] = [] + for reference in references: + _, _, image = source(result_id, int(reference["truth_island_sequence"])) + width, height = _png_dimensions(image) + frames.append(_source_projection(reference, width=width, height=height)) + return { + "schema_version": L34_ANNOTATION_SOURCE_CATALOG_SCHEMA, + "result_id": result.result_id, + "truth_island_id": result.result_id, + "contract": _review_contract(), + "frames": frames, + "frame_count": len(frames), + "model_material_included": False, + "candidate_identity_included": False, + "candidate_predictions_included": False, + "prelabels_included": False, + "reviewer_slots": ["A", "B"], + "access": "prediction-free-independent-review-source-read-only", + } + + @router.get("/results/{result_id}/annotation-source/frames/{sequence}") + def get_source_frame(result_id: str, sequence: int) -> dict[str, object]: + _, reference, image = source(result_id, sequence) + width, height = _png_dimensions(image) + return { + "schema_version": L34_ANNOTATION_SOURCE_SCHEMA, + "result_id": result_id, + **_source_projection(reference, width=width, height=height), + "camera_url": ( + f"/api/v1/laboratory/e46/results/{result_id}" + f"/annotation-source/frames/{sequence}/camera" + ), + "model_material_included": False, + "candidate_identity_included": False, + "candidate_predictions_included": False, + "prelabels_included": False, + "access": "prediction-free-independent-review-source-read-only", + } + + @router.get("/results/{result_id}/annotation-source/frames/{sequence}/camera") + def get_source_camera(result_id: str, sequence: int) -> FileResponse: + _, _, image = source(result_id, sequence) + return FileResponse(image, media_type="image/png") + + @router.get("/results/{result_id}/annotation-sessions") + def list_sessions(result_id: str) -> dict[str, object]: + truth(result_id) + items = store.list(result_id) + return { + "schema_version": L34_ANNOTATION_SESSION_CATALOG_SCHEMA, + "result_id": result_id, + "items": items, + "total": len(items), + "maximum_sessions": 2, + "access": "prediction-free-independent-review-summaries", + } + + @router.get("/results/{result_id}/annotation-labels") + def get_labels(result_id: str) -> dict[str, object]: + truth(result_id) + return { + "schema_version": L34_ANNOTATION_LABEL_CATALOG_SCHEMA, + "result_id": result_id, + "items": [], + "total": 0, + "access": "prediction-free-fixed-taxonomy", + } + + @router.post("/results/{result_id}/annotation-sessions") + def create_session( + result_id: str, + request: L34AnnotationCreateRequest, + ) -> dict[str, object]: + result, _ = truth(result_id) + expected = "l34-annotation-session-" + hashlib.sha256( + f"{result_id}\n{request.idempotency_key}".encode() + ).hexdigest() + existing = store.list(result_id) + if len(existing) >= 2 and all(item["session_id"] != expected for item in existing): + raise HTTPException(status_code=409, detail="E46 допускает только Reviewer A и B") + created = store.create( + result_id=result_id, + truth_island_id=result.result_id, + idempotency_key=request.idempotency_key, + ) + capability_path = store.session_path( + result_id, + str(created["session_id"]), + ).with_suffix(".capability.json") + if capability_path.exists(): + try: + capability_document = json.loads( + capability_path.read_text(encoding="utf-8") + ) + capability = capability_document["capability"] + except (KeyError, OSError, TypeError, ValueError): + raise HTTPException( + status_code=409, + detail="E46 reviewer slot не прошёл проверку", + ) from None + else: + capability = secrets.token_urlsafe(32) + write_json_atomic( + capability_path, + { + "schema_version": "missioncore.e46-review-capability/v1", + "result_id": result_id, + "session_id": created["session_id"], + "capability": capability, + }, + ) + capability_path.chmod(0o600) + return {**created, "review_capability": capability} + + @router.get("/results/{result_id}/annotation-sessions/{session_id}") + def get_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + x_e46_review_capability: Annotated[ + str | None, + Header(alias="X-E46-Review-Capability"), + ] = None, + ) -> dict[str, object]: + truth(result_id) + require_capability(result_id, session_id, x_e46_review_capability) + return store.get(result_id, session_id) + + @router.put("/results/{result_id}/annotation-sessions/{session_id}") + def save_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + request: L34AnnotationSaveRequest, + x_e46_review_capability: Annotated[ + str | None, + Header(alias="X-E46-Review-Capability"), + ] = None, + ) -> dict[str, object]: + require_capability(result_id, session_id, x_e46_review_capability) + result, _ = truth(result_id) + if any( + item["annotation_session_id"] == session_id + for item in _submissions_for_truth( + submission_root_provider, + result.result_root, + result.result_id, + ) + ): + raise HTTPException(status_code=409, detail="E46 reviewer slot уже заморожен") + if request.assistance_mode != "prediction-free-manual": + raise HTTPException( + status_code=422, + detail="E46 принимает только prediction-free разметку", + ) + sequences: set[int] = set() + normalized: list[dict[str, object]] = [] + for frame in request.frames: + sequence = frame.truth_island_sequence + if sequence in sequences: + raise HTTPException(status_code=422, detail="Кадр размечен дважды") + sequences.add(sequence) + _, reference, image = source(result_id, sequence) + width, height = _png_dimensions(image) + if any( + item.origin != "manual" or item.category == "unmapped" + for item in frame.objects + ): + raise HTTPException( + status_code=422, + detail="E46 принимает только ручные canonical-классы", + ) + objects = [ + _normalize_object(item, width=width, height=height) + for item in frame.objects + ] + if frame.hard_negative != (len(objects) == 0): + raise HTTPException(status_code=422, detail="Пустой кадр должен быть явно отмечен") + normalized.append( + { + "truth_island_sequence": sequence, + "image_id": int(reference["image_id"]), + "frame_index": int(reference["frame_index"]), + "source_sha256": str(reference["sha256"]), + "reviewed": True, + "hard_negative": frame.hard_negative, + "objects": objects, + } + ) + normalized.sort(key=lambda item: int(item["truth_island_sequence"])) + return store.save( + result_id=result_id, + session_id=session_id, + request=request, + frames=normalized, + ) + + @router.post("/results/{result_id}/annotation-sessions/{session_id}/freeze") + def freeze_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + request: E46ReviewFreezeRequest, + x_e46_review_capability: Annotated[ + str | None, + Header(alias="X-E46-Review-Capability"), + ] = None, + ) -> dict[str, object]: + result, _ = truth(result_id) + require_capability(result_id, session_id, x_e46_review_capability) + session = store.get(result_id, session_id) + if session["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="E46-сессия уже изменена") + if not session["progress"]["complete"]: + raise HTTPException(status_code=422, detail="Для freeze нужны 32/32 кадров") + reviewer_id = request.reviewer_id.strip() + if _REVIEWER_ID.fullmatch(reviewer_id) is None: + raise HTTPException(status_code=422, detail="Некорректный reviewer id") + root = submission_root_provider() + if root is None: + raise HTTPException(status_code=503, detail="Хранилище E46 review не настроено") + existing = _submissions_for_truth( + submission_root_provider, + result.result_root, + result.result_id, + ) + for item in existing: + if item["annotation_session_id"] == session_id: + if item["reviewer_id"] == reviewer_id: + return item + raise HTTPException( + status_code=409, + detail="E46 reviewer slot уже заморожен другим reviewer id", + ) + if item["reviewer_id"] == reviewer_id and item["annotation_session_id"] != session_id: + raise HTTPException( + status_code=409, + detail="Reviewer id уже использован другим слотом", + ) + try: + frozen = build_e46_lab_review_submission( + truth_island_root=result.result_root, + annotation_session_path=store.session_path(result_id, session_id), + reviewer_id=reviewer_id, + output_root=root, + ) + except (E46LabReviewSubmissionError, OSError) as exc: + raise HTTPException( + status_code=409, + detail="E46 review freeze не прошёл проверку", + ) from exc + return _project_submission(frozen) + + return router + + +def _project_result( + result: E46DetectorTruthIsland, + references: tuple[dict[str, Any], ...], + *, + sessions: list[dict[str, object]], + submissions: list[dict[str, object]], +) -> dict[str, object]: + selection = result.report["selection"] + source = result.manifest["identity"]["source"] + return { + "result_id": result.result_id, + "created_at_utc": result.manifest["created_at_utc"], + "status": result.report["status"], + "source_session_id": source["session_id"], + "camera_source_id": source["source_id"], + "metrics": { + "frame_count": len(references), + "anchor_count": selection["anchor_count"], + "temporal_frame_count": selection["temporal_frame_count"], + "temporal_group_count": selection["temporal_group_count"], + "review_session_count": len(sessions), + "completed_submission_count": len(submissions), + "required_submission_count": 2, + }, + "review_sessions": copy.deepcopy(sessions), + "submissions": copy.deepcopy(submissions), + "decision": { + "collection_ready": True, + "two_independent_reviews_complete": len(submissions) == 2, + "adjudication_ready": len(submissions) == 2, + "e48_sealed": False, + "l35_open": False, + "next_action": ( + "complete two distinct prediction-free reviewer submissions" + if len(submissions) < 2 + else "adjudicate both frozen reviews before E48 seal" + ), + }, + "blindness": { + "candidate_identity_included": False, + "model_prelabels_included": False, + "model_predictions_included": False, + "model_scores_included": False, + }, + "authority": _AUTHORITY, + "ground_truth": False, + "access": "review-collection-read-only-summary", + } + + +def _project_submission(result: dict[str, Any]) -> dict[str, object]: + identity = result["manifest"]["identity"] + return { + "result_id": result["result_id"], + "created_at_utc": result["manifest"]["created_at_utc"], + "truth_island_id": identity["truth_island_id"], + "annotation_session_id": identity["annotation_session_id"], + "reviewer_id": identity["reviewer_id"], + "state": result["manifest"]["state"], + "frame_count": len(result["review"]["images"]), + "object_count": sum(len(item["objects"]) for item in result["review"]["images"]), + "blindness": copy.deepcopy(identity["blindness"]), + "authority": copy.deepcopy(identity["authority"]), + "ground_truth": False, + "access": "immutable-e48-review-input", + } + + +def _submissions_for_truth( + provider: RootProvider, + truth_root: Path, + truth_id: str, +) -> list[dict[str, object]]: + root = _configured_root(provider) + if root is None: + return [] + items: list[dict[str, object]] = [] + for path in sorted(root.iterdir(), key=lambda item: item.stat().st_mtime_ns): + if not path.is_dir() or path.is_symlink() or _SUBMISSION_ID.fullmatch(path.name) is None: + continue + try: + result = read_e46_lab_review_submission(path, truth_island_root=truth_root) + if result["manifest"]["identity"]["truth_island_id"] == truth_id: + items.append(_project_submission(result)) + except (E46LabReviewSubmissionError, OSError): + continue + return items + + +def _review_contract() -> dict[str, object]: + return { + "contract_id": "e46-detector-blind-review/v1", + "classes": list(_TARGET_CLASSES), + "unmapped_class": None, + "box_format": "xyxy-pixels-800x600", + "all_identifiable_instances_required": True, + "inside_valid_fov_only": True, + "hard_negative_frame_flag_required": True, + "occluded_and_truncated_flags_required": True, + } + + +def _truth_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 _TRUTH_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 + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] + if any(not isinstance(row, dict) for row in rows): + raise ValueError("invalid E46 references") + return rows + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() diff --git a/src/k1link/web/e46a_ai_engineering_preannotation_api.py b/src/k1link/web/e46a_ai_engineering_preannotation_api.py new file mode 100644 index 0000000..8ea3020 --- /dev/null +++ b/src/k1link/web/e46a_ai_engineering_preannotation_api.py @@ -0,0 +1,383 @@ +"""Read-only LAB projection for E46A AI engineering preannotations.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Annotated, Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi import Path as ApiPath + +from k1link.compute.e46a_ai_engineering_preannotation import ( + E46AAiEngineeringPreannotationError, + read_e46a_ai_engineering_preannotation, +) +from k1link.web.l34_annotation_api import ( + L34_ANNOTATION_LABEL_CATALOG_SCHEMA, + L34_ANNOTATION_SEED_SCHEMA, + L34_ANNOTATION_SESSION_CATALOG_SCHEMA, + L34_ANNOTATION_SOURCE_CATALOG_SCHEMA, + L34_ANNOTATION_SOURCE_SCHEMA, + L34AnnotationCreateRequest, + L34AnnotationSaveRequest, + _annotation_contract, + _L34AnnotationSessionStore, + _normalize_object, +) + +_RESULT_ID = re.compile(r"^e46a-ai-engineering-preannotation-[a-f0-9]{64}$") +_SESSION_ID = re.compile(r"^l34-annotation-session-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + + +def build_e46a_ai_engineering_preannotation_router( + *, + root_provider: RootProvider = lambda: None, + annotation_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46a", tags=["laboratory"]) + store = _L34AnnotationSessionStore( + annotation_root_provider, + result_id_pattern=_RESULT_ID, + lab_label="E46A correction", + initial_assistance_mode="frozen-candidate-seeded", + allowed_assistance_modes=frozenset({"frozen-candidate-seeded"}), + contract_id_overrides={ + "frozen-candidate-seeded": "e46a-ai-engineering-correction/v1" + }, + ) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46A result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46A result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46A result не найден") + try: + return read_e46a_ai_engineering_preannotation(path) + except (E46AAiEngineeringPreannotationError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46A result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46a-ai-engineering-preannotation-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-non-truth", + } + + @router.get("/results/{result_id}/cases/{sequence}") + def get_case(result_id: str, sequence: int) -> dict[str, object]: + if sequence < 1 or sequence > 32: + raise HTTPException(status_code=404, detail="E46A frame не найден") + frozen = result(result_id) + case = next( + ( + item + for item in frozen["cases"] + if item.get("truth_island_sequence") == sequence + ), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="E46A frame не найден") + identity = frozen["manifest"].get("identity") + e46_source = identity.get("e46_source") if isinstance(identity, dict) else None + e46_result_id = ( + e46_source.get("result_id") if isinstance(e46_source, dict) else None + ) + if not isinstance(e46_result_id, str): + raise HTTPException(status_code=404, detail="E46A source не найден") + return { + **copy.deepcopy(case), + "result_id": result_id, + "camera_url": ( + f"/api/v1/laboratory/e46/results/{e46_result_id}" + f"/annotation-source/frames/{sequence}/camera" + ), + "candidate_identity_included": True, + "model_predictions_seen_during_source_adjudication": True, + "model_scores_included": False, + "independent_review": False, + "ground_truth": False, + "access": "read-only-ai-engineering-preannotation-not-truth", + } + + def annotation_case(result_id: str, sequence: int) -> tuple[dict[str, Any], dict[str, Any]]: + frozen = result(result_id) + case = next( + ( + item + for item in frozen["cases"] + if item.get("truth_island_sequence") == sequence + ), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="E46A frame не найден") + return frozen, case + + def source_projection(case: dict[str, Any]) -> dict[str, object]: + group_id = str(case["group_id"]) + return { + "truth_island_sequence": int(case["truth_island_sequence"]), + "image_id": int(case["image_id"]), + "frame_index": int(case["frame_index"]), + "group_id": group_id, + "role": "temporal" if group_id.startswith("clip-") else "anchor", + "session_seconds": float(case["session_seconds"]), + "camera": { + "width": 800, + "height": 600, + "sha256": str(case["source_image_sha256"]), + }, + } + + def e46_source_id(frozen: dict[str, Any]) -> str: + identity = frozen["manifest"].get("identity") + source = identity.get("e46_source") if isinstance(identity, dict) else None + source_id = source.get("result_id") if isinstance(source, dict) else None + if not isinstance(source_id, str): + raise HTTPException(status_code=404, detail="E46A source не найден") + return source_id + + @router.get("/results/{result_id}/annotation-source") + def get_annotation_source_catalog(result_id: str) -> dict[str, object]: + frozen = result(result_id) + frames = [source_projection(case) for case in frozen["cases"]] + return { + "schema_version": L34_ANNOTATION_SOURCE_CATALOG_SCHEMA, + "result_id": result_id, + "truth_island_id": e46_source_id(frozen), + "contract": _annotation_contract( + contract_id="e46a-ai-engineering-correction/v1" + ), + "frames": frames, + "frame_count": len(frames), + "model_material_included": False, + "candidate_identity_included": True, + "candidate_predictions_included": False, + "prelabels_included": False, + "access": "ai-engineering-correction-source-read-only", + } + + @router.get("/results/{result_id}/annotation-source/frames/{sequence}") + def get_annotation_source_frame(result_id: str, sequence: int) -> dict[str, object]: + frozen, case = annotation_case(result_id, sequence) + source_id = e46_source_id(frozen) + return { + "schema_version": L34_ANNOTATION_SOURCE_SCHEMA, + "result_id": result_id, + **source_projection(case), + "camera_url": ( + f"/api/v1/laboratory/e46/results/{source_id}" + f"/annotation-source/frames/{sequence}/camera" + ), + "model_material_included": False, + "candidate_identity_included": True, + "candidate_predictions_included": False, + "prelabels_included": False, + "access": "ai-engineering-correction-source-read-only", + } + + @router.get("/results/{result_id}/annotation-seed/frames/{sequence}") + def get_annotation_seed_frame(result_id: str, sequence: int) -> dict[str, object]: + _, case = annotation_case(result_id, sequence) + objects: list[dict[str, object]] = [] + for raw in case["objects"]: + category = str(raw["category"]) + proposed_label: str | None = None + if category == "stroller": + category = "unmapped" + proposed_label = "Коляска" + elif category == "laptop": + category = "unmapped" + proposed_label = "Ноутбук" + objects.append( + { + "object_id": str(raw["object_id"]), + "category": category, + "proposed_label": proposed_label, + "origin": "frozen_candidate_seed", + "box_xyxy": copy.deepcopy(raw["box_xyxy"]), + "occluded": bool(raw["occluded"]), + "truncated": bool(raw["truncated"]), + } + ) + return { + "schema_version": L34_ANNOTATION_SEED_SCHEMA, + "result_id": result_id, + "truth_island_sequence": sequence, + "source_sha256": str(case["source_image_sha256"]), + "objects": objects, + "assistance": { + "mode": "frozen-candidate-seeded", + "independent_truth_eligible": False, + }, + "model_material_included": True, + "access": "ai-engineering-preannotation-seed-read-only", + } + + @router.get("/results/{result_id}/annotation-sessions") + def list_annotation_sessions(result_id: str) -> dict[str, object]: + annotation_case(result_id, 1) + items = store.list(result_id) + return { + "schema_version": L34_ANNOTATION_SESSION_CATALOG_SCHEMA, + "result_id": result_id, + "items": items, + "total": len(items), + "access": "ai-engineering-human-correction", + } + + @router.get("/results/{result_id}/annotation-labels") + def get_annotation_labels(result_id: str) -> dict[str, object]: + annotation_case(result_id, 1) + values = {"коляска": "Коляска", "ноутбук": "Ноутбук"} + for item in store.labels(result_id): + label = str(item["value"]) + values[label.casefold()] = label + items = [ + {"value": value, "normalization_state": "pending-adjudication"} + for value in sorted(values.values(), key=str.casefold) + ] + return { + "schema_version": L34_ANNOTATION_LABEL_CATALOG_SCHEMA, + "result_id": result_id, + "items": items, + "total": len(items), + "access": "ai-engineering-correction-taxonomy", + } + + @router.post("/results/{result_id}/annotation-sessions") + def create_annotation_session( + result_id: str, + request: L34AnnotationCreateRequest, + ) -> dict[str, object]: + frozen, _ = annotation_case(result_id, 1) + return store.create( + result_id=result_id, + truth_island_id=e46_source_id(frozen), + idempotency_key=request.idempotency_key, + ) + + @router.get("/results/{result_id}/annotation-sessions/{session_id}") + def get_annotation_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + ) -> dict[str, object]: + annotation_case(result_id, 1) + return store.get(result_id, session_id) + + @router.put("/results/{result_id}/annotation-sessions/{session_id}") + def save_annotation_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + request: L34AnnotationSaveRequest, + ) -> dict[str, object]: + if request.assistance_mode != "frozen-candidate-seeded": + raise HTTPException(status_code=422, detail="Режим E46A correction запрещён") + sequences: set[int] = set() + frames: list[dict[str, object]] = [] + for frame in request.frames: + sequence = frame.truth_island_sequence + if sequence in sequences: + raise HTTPException(status_code=422, detail="Кадр размечен дважды") + sequences.add(sequence) + _, case = annotation_case(result_id, sequence) + objects = [ + _normalize_object(item, width=800, height=600) for item in frame.objects + ] + if frame.hard_negative != (len(objects) == 0): + raise HTTPException( + status_code=422, + detail="Пустой кадр должен быть явно отмечен", + ) + frames.append( + { + "truth_island_sequence": sequence, + "image_id": int(case["image_id"]), + "frame_index": int(case["frame_index"]), + "source_sha256": str(case["source_image_sha256"]), + "reviewed": True, + "hard_negative": frame.hard_negative, + "objects": objects, + } + ) + frames.sort(key=lambda item: int(item["truth_island_sequence"])) + return store.save( + result_id=result_id, + session_id=session_id, + request=request, + frames=frames, + ) + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + manifest = frozen["manifest"] + report = frozen["report"] + identity = manifest.get("identity") + e46_source = identity.get("e46_source") if isinstance(identity, dict) else None + return { + "schema_version": "missioncore.e46a-ai-engineering-preannotation-view/v1", + "result_id": frozen["result_id"], + "created_at_utc": report.get("created_at_utc"), + "source_session_id": report.get("source_session_id"), + "camera_source_id": report.get("camera_source_id"), + "source_e46_result_id": ( + e46_source.get("result_id") if isinstance(e46_source, dict) else None + ), + "metrics": copy.deepcopy(report.get("metrics")), + "assistance": copy.deepcopy(report.get("assistance")), + "taxonomy": copy.deepcopy(report.get("taxonomy")), + "decision": copy.deepcopy(report.get("decision")), + "limitations": copy.deepcopy(report.get("limitations")), + "authority": copy.deepcopy(report.get("authority")), + "ground_truth": False, + "access": "read-only-ai-engineering-preannotation-not-truth", + } + + +def _configured_root(provider: RootProvider) -> Path | None: + configured = provider() + if configured is None: + return None + return configured.expanduser().absolute() + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None or not root.is_dir() or root.is_symlink(): + return [] + return sorted( + ( + path + for path in root.iterdir() + if path.is_dir() + and not path.is_symlink() + and _RESULT_ID.fullmatch(path.name) is not None + ), + key=lambda path: (path.stat().st_mtime_ns, path.name), + reverse=True, + ) diff --git a/src/k1link/web/e46b_temporal_motion_api.py b/src/k1link/web/e46b_temporal_motion_api.py new file mode 100644 index 0000000..baef5e8 --- /dev/null +++ b/src/k1link/web/e46b_temporal_motion_api.py @@ -0,0 +1,133 @@ +"""Read-only LAB projection for E46B temporal tracks and motion state.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query + +from k1link.compute.e46b_temporal_motion import ( + E46BTemporalMotionError, + read_e46b_temporal_motion, +) + +_RESULT_ID = re.compile(r"^e46b-temporal-motion-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + + +def build_e46b_temporal_motion_router( + *, root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46b", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46B result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46B result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46B result не найден") + try: + return read_e46b_temporal_motion(path) + except (E46BTemporalMotionError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46B result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46b-temporal-motion-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-engineering-evidence", + } + + @router.get("/results/{result_id}/cases/{sequence}") + def get_case(result_id: str, sequence: int) -> dict[str, object]: + frozen = result(result_id) + case = next( + (item for item in frozen["cases"] if item.get("truth_island_sequence") == sequence), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="E46B frame не найден") + identity = frozen["manifest"]["identity"] + e46_result_id = identity["e46_source"]["result_id"] + return { + **copy.deepcopy(case), + "result_id": result_id, + "camera_url": ( + f"/api/v1/laboratory/e46/results/{e46_result_id}" + f"/annotation-source/frames/{sequence}/camera" + ), + "ground_truth": False, + "access": "read-only-source-scoped-temporal-motion", + } + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + identity = frozen["manifest"]["identity"] + return { + "schema_version": "missioncore.e46b-temporal-motion-view/v1", + "result_id": frozen["result_id"], + "created_at_utc": report["created_at_utc"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "source_e46a_result_id": identity["e46a_source"]["result_id"], + "source_e26_result_id": identity["e26_motion_source"]["result_id"], + "status": report["status"], + "metrics": copy.deepcopy(report["metrics"]), + "tracks": copy.deepcopy(report["tracks"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "ground_truth": False, + "access": "read-only-engineering-evidence", + } + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46c_full_replay_world_tracks_api.py b/src/k1link/web/e46c_full_replay_world_tracks_api.py new file mode 100644 index 0000000..6a6b633 --- /dev/null +++ b/src/k1link/web/e46c_full_replay_world_tracks_api.py @@ -0,0 +1,396 @@ +"""Read-only LAB projection for E46C full-replay world tracks.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import math +import re +from collections.abc import Callable +from functools import lru_cache +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query + +from k1link.compute.e46c_full_replay_world_tracks import ( + E46CFullReplayWorldTracksError, + read_e46c_full_replay_world_tracks, +) + +_RESULT_ID = re.compile(r"^e46c-full-replay-world-tracks-[a-f0-9]{64}$") +_E26_RESULT_ID = re.compile(r"^e10-integrated-perception-[a-f0-9]{64}$") +_SAFE_SESSION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +RootProvider = Callable[[], Path | None] + + +def build_e46c_full_replay_world_tracks_router( + *, + root_provider: RootProvider = lambda: None, + e26_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46c", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46C result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46C result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46C result не найден") + try: + return read_e46c_full_replay_world_tracks(path) + except (E46CFullReplayWorldTracksError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46C result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46c-full-replay-world-tracks-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-diagnostic", + } + + @router.get("/results/{result_id}/cases/{sequence}") + def get_case(result_id: str, sequence: int) -> dict[str, object]: + frozen = result(result_id) + case = next( + (item for item in frozen["cases"] if item.get("truth_island_sequence") == sequence), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="E46C frame не найден") + e46_result_id = frozen["manifest"]["identity"]["e46_source"]["result_id"] + return { + **copy.deepcopy(case), + "result_id": result_id, + "camera_url": ( + f"/api/v1/laboratory/e46/results/{e46_result_id}" + f"/annotation-source/frames/{sequence}/camera" + ), + "ground_truth": False, + "access": "read-only-full-route-diagnostic-sample", + } + + @router.get("/results/{result_id}/video-overlay") + def get_video_overlay(result_id: str) -> dict[str, object]: + frozen = result(result_id) + e26_root = _configured_root(e26_root_provider) + if e26_root is None: + raise HTTPException(status_code=404, detail="E46C video overlay не найден") + identity = frozen["manifest"]["identity"]["e26_full_replay"] + source_result_id = identity.get("result_id") + expected_result_sha256 = identity.get("result_sha256") + expected_fusion_sha256 = identity.get("fusion_frames_sha256") + if ( + not isinstance(source_result_id, str) + or _E26_RESULT_ID.fullmatch(source_result_id) is None + or not isinstance(expected_result_sha256, str) + or not isinstance(expected_fusion_sha256, str) + ): + raise HTTPException(status_code=404, detail="E46C video overlay не найден") + source_path = (e26_root / source_result_id).resolve() + if source_path.parent != e26_root or source_path.is_symlink(): + raise HTTPException(status_code=404, detail="E46C video overlay не найден") + try: + return _cached_video_overlay( + result_id, + str(source_path), + expected_result_sha256, + expected_fusion_sha256, + ) + except (OSError, ValueError, json.JSONDecodeError): + raise HTTPException( + status_code=404, + detail="E46C video overlay не найден", + ) from None + + return router + + +@lru_cache(maxsize=4) +def _cached_video_overlay( + e46c_result_id: str, + source_root_value: str, + expected_result_sha256: str, + expected_fusion_sha256: str, +) -> dict[str, object]: + source_root = Path(source_root_value).resolve(strict=True) + if source_root.is_symlink() or not source_root.is_dir(): + raise ValueError("E26 source root is invalid") + result_path = source_root / "result.json" + if _sha256(result_path) != expected_result_sha256: + raise ValueError("E26 result identity changed") + result = _read_json(result_path) + identity = result.get("identity") + artifacts = result.get("artifacts") + if ( + result.get("schema_version") + != "missioncore.e10-integrated-perception-result/v1" + or result.get("acceptance_state") != "accepted" + or not isinstance(identity, dict) + or not isinstance(artifacts, list) + ): + raise ValueError("E26 result contract is invalid") + selection = identity.get("selection") + configuration = identity.get("configuration") + if not isinstance(selection, dict) or not isinstance(configuration, dict): + raise ValueError("E26 timeline contract is invalid") + recorded_session_id = configuration.get("source_session_id") + if ( + not isinstance(recorded_session_id, str) + or _SAFE_SESSION_ID.fullmatch(recorded_session_id) is None + or identity.get("source_id") != "sensor.camera.right" + or selection.get("frame_count") != 4489 + ): + raise ValueError("E26 recorded source binding is invalid") + fusion_artifact = next( + ( + item + for item in artifacts + if isinstance(item, dict) and item.get("path") == "fusion-frames.jsonl" + ), + None, + ) + fusion_path = _validated_source_artifact( + source_root, + fusion_artifact, + expected_fusion_sha256, + ) + + frames: list[dict[str, object]] = [] + previous_seconds = -math.inf + for expected_frame_index, row in enumerate(_read_jsonl(fusion_path)): + frame_index = row.get("source_frame_index") + session_seconds = row.get("session_seconds") + raw_objects = row.get("objects") + if ( + frame_index != expected_frame_index + or not isinstance(session_seconds, int | float) + or isinstance(session_seconds, bool) + or not math.isfinite(float(session_seconds)) + or float(session_seconds) <= previous_seconds + or not isinstance(raw_objects, list) + ): + raise ValueError("E26 video frame sequence is invalid") + previous_seconds = float(session_seconds) + frames.append( + { + "frame_index": frame_index, + "session_seconds": float(session_seconds), + "fusion_state": str(row.get("fusion_state", "unknown")), + "objects": [ + _video_object(item) + for item in raw_objects + if isinstance(item, dict) + ], + } + ) + timeline_start = float(selection.get("timeline_start_seconds")) + timeline_end = float(selection.get("timeline_end_seconds")) + if ( + len(frames) != 4489 + or frames[0]["session_seconds"] != timeline_start + or frames[-1]["session_seconds"] != timeline_end + ): + raise ValueError("E26 video timeline changed") + + benchmark = configuration.get("benchmark") + benchmark_events = benchmark.get("events") if isinstance(benchmark, dict) else None + review_windows = [] + if isinstance(benchmark_events, list): + for event in benchmark_events: + if not isinstance(event, dict): + continue + window = event.get("window_seconds") + if ( + not isinstance(window, list) + or len(window) != 2 + or not all(isinstance(item, int | float) for item in window) + ): + continue + review_windows.append( + { + "id": str(event.get("id", "review-window")), + "kind": str(event.get("kind", "diagnostic")), + "class_group": str(event.get("class_group", "all")), + "start_seconds": float(window[0]), + "end_seconds": float(window[1]), + "target_source_track_ids": [ + int(value) + for value in event.get("target_source_track_ids", []) + if isinstance(value, int) and not isinstance(value, bool) + ], + } + ) + return { + "schema_version": "missioncore.e46c-recorded-video-overlay/v1", + "result_id": e46c_result_id, + "recorded_source": { + "session_id": recorded_session_id, + "source_id": "sensor.camera.right", + "input_sha256": identity.get("input_sha256"), + "synchronization": "host-arrival-best-effort", + }, + "image_width": 800, + "image_height": 600, + "timeline_start_seconds": timeline_start, + "timeline_end_seconds": timeline_end, + "frame_count": len(frames), + "frames": frames, + "review_windows": review_windows, + "ground_truth": False, + "access": "read-only-full-route-diagnostic-video", + } + + +def _video_object(item: dict[str, Any]) -> dict[str, object]: + box = item.get("bbox_xyxy") + if ( + not isinstance(box, list) + or len(box) != 4 + or not all( + isinstance(value, int | float) + and not isinstance(value, bool) + and math.isfinite(float(value)) + for value in box + ) + ): + raise ValueError("E26 video object box is invalid") + source_track_id = item.get("source_track_id") + world_track_id = item.get("track_id") + if not isinstance(source_track_id, int) or isinstance(source_track_id, bool): + raise ValueError("E26 video object track is invalid") + if not isinstance(world_track_id, int) or isinstance(world_track_id, bool): + world_track_id = source_track_id + return { + "bbox_xyxy": [float(value) for value in box], + "category": str(item.get("label", "unknown")), + "score": float(item.get("score") or 0.0), + "route_track_id": source_track_id, + "world_track_id": ( + world_track_id if world_track_id != source_track_id else None + ), + "motion_state": str(item.get("motion_state", "unknown")), + "motion_confidence": float(item.get("motion_confidence") or 0.0), + "track_hits": int(item.get("track_hits") or 0), + "track_age_seconds": float(item.get("track_age_s") or 0.0), + "camera_evidence_current": item.get("camera_evidence_current") is True, + "world_evidence_current": item.get("occupancy_evidence_current") is True, + } + + +def _validated_source_artifact( + root: Path, + raw: object, + expected_sha256: str, +) -> Path: + if not isinstance(raw, dict) or raw.get("sha256") != expected_sha256: + raise ValueError("E26 fusion artifact identity changed") + relative = raw.get("path") + if not isinstance(relative, str): + raise ValueError("E26 fusion artifact path is invalid") + path = (root / relative).resolve(strict=True) + expected_size = raw.get("byte_length", raw.get("size_bytes")) + if ( + path.parent != root + or path.is_symlink() + or not path.is_file() + or path.stat().st_size != expected_size + or _sha256(path) != expected_sha256 + ): + raise ValueError("E26 fusion artifact changed") + return path + + +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 is invalid") + return value + + +def _read_jsonl(path: Path): + with path.open("r", encoding="utf-8") as handle: + for line in handle: + if line.strip(): + value = json.loads(line) + if not isinstance(value, dict): + raise ValueError("JSONL row is invalid") + yield value + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + identity = frozen["manifest"]["identity"] + return { + "schema_version": "missioncore.e46c-full-replay-world-tracks-view/v1", + "result_id": frozen["result_id"], + "created_at_utc": report["created_at_utc"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "source_e46a_result_id": identity["e46a_visual_sample"]["result_id"], + "source_e26_result_id": identity["e26_full_replay"]["result_id"], + "status": report["status"], + "metrics": copy.deepcopy(report["metrics"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "ground_truth": False, + "access": "read-only-diagnostic", + } + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() + and not item.is_symlink() + and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46d_temporal_failure_audit_api.py b/src/k1link/web/e46d_temporal_failure_audit_api.py new file mode 100644 index 0000000..3f4d217 --- /dev/null +++ b/src/k1link/web/e46d_temporal_failure_audit_api.py @@ -0,0 +1,114 @@ +"""Read-only LAB projection for the E46D full-route temporal audit.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query + +from k1link.compute.e46d_temporal_failure_audit import ( + E46DTemporalFailureAuditError, + read_e46d_temporal_failure_audit, +) + +_RESULT_ID = re.compile(r"^e46d-temporal-failure-audit-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + + +def build_e46d_temporal_failure_audit_router( + *, root_provider: RootProvider = lambda: None +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46d", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46D result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46D result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46D result не найден") + try: + return read_e46d_temporal_failure_audit(path) + except (E46DTemporalFailureAuditError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46D result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46d-temporal-failure-audit-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-recorded-temporal-diagnostic", + } + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + identity = frozen["manifest"]["identity"] + return { + "schema_version": "missioncore.e46d-temporal-failure-audit-view/v1", + "result_id": frozen["result_id"], + "created_at_utc": report["created_at_utc"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "source_e46c_result_id": identity["e46c_source"]["result_id"], + "source_e26_result_id": identity["e26_source"]["result_id"], + "status": report["status"], + "metrics": copy.deepcopy(report["metrics"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "method": copy.deepcopy(report["method"]), + "limitations": copy.deepcopy(report["limitations"]), + "review_clips": copy.deepcopy(frozen["clips"]), + "ground_truth": False, + "access": "read-only-recorded-temporal-diagnostic", + } + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46e_ready_stack_api.py b/src/k1link/web/e46e_ready_stack_api.py new file mode 100644 index 0000000..ef0c32b --- /dev/null +++ b/src/k1link/web/e46e_ready_stack_api.py @@ -0,0 +1,149 @@ +"""Read-only LAB projection and video delivery for the E46E NVIDIA ready stack.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.e46e_ready_stack import ( + E46EReadyStackError, + read_e46e_ready_stack, +) + +_RESULT_ID = re.compile(r"^e46e-ready-stack-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + + +def build_e46e_ready_stack_router( + *, root_provider: RootProvider = lambda: None +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46e", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46E result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46E result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46E result не найден") + try: + return read_e46e_ready_stack(path) + except (E46EReadyStackError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46E result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46e-ready-stack-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-stock-nvidia-recorded-diagnostic", + } + + @router.get("/results/{result_id}/overlay.mp4") + def get_overlay_video(result_id: str) -> FileResponse: + frozen = result(result_id) + overlay = frozen["overlay_path"] + descriptor = next( + ( + row + for row in frozen["manifest"]["artifacts"] + if row.get("role") == "visual-overlay-video" + ), + None, + ) + if not isinstance(descriptor, dict): + raise HTTPException(status_code=404, detail="E46E video не найден") + return FileResponse( + overlay, + media_type="video/mp4", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + overlay = next( + row + for row in frozen["manifest"]["artifacts"] + if row.get("role") == "visual-overlay-video" + ) + result_id = frozen["result_id"] + return { + "schema_version": "missioncore.e46e-ready-stack-view/v1", + "result_id": result_id, + "created_at_utc": report["created_at_utc"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "status": report["status"], + "metrics": copy.deepcopy(report["metrics"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "method": copy.deepcopy(report["method"]), + "limitations": copy.deepcopy(report["limitations"]), + "video": { + "url": f"/api/v1/laboratory/e46e/results/{result_id}/overlay.mp4", + "media_type": "video/mp4", + "byte_length": overlay["byte_length"], + "sha256": overlay["sha256"], + "width": 800, + "height": 600, + }, + "ground_truth": False, + "access": "read-only-stock-nvidia-recorded-diagnostic", + } + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46f_dashcam_bakeoff_api.py b/src/k1link/web/e46f_dashcam_bakeoff_api.py new file mode 100644 index 0000000..54a4171 --- /dev/null +++ b/src/k1link/web/e46f_dashcam_bakeoff_api.py @@ -0,0 +1,230 @@ +"""Read-only LAB projection for the E46F DashCamNet detector bake-off.""" + +from __future__ import annotations + +import copy +import re +from collections import Counter +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.e46e_ready_stack import ( + E46EReadyStackError, + read_e46e_ready_stack, +) +from k1link.compute.e46f_dashcam_bakeoff import ( + E46FDashCamBakeoffError, + read_e46f_dashcam_bakeoff, +) + +_RESULT_ID = re.compile(r"^e46f-dashcam-bakeoff-[a-f0-9]{64}$") +_BASELINE_ID = re.compile(r"^e46e-ready-stack-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + + +def build_e46f_dashcam_bakeoff_router( + *, + root_provider: RootProvider = lambda: None, + e46e_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46f", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46F result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46F result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46F result не найден") + try: + return read_e46f_dashcam_bakeoff(path) + except (E46FDashCamBakeoffError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46F result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen, e46e_root_provider)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46f-dashcam-bakeoff-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-rejected-detector-bakeoff", + } + + @router.get("/results/{result_id}/overlay.mp4") + def get_overlay_video(result_id: str) -> FileResponse: + frozen = result(result_id) + descriptor = next( + ( + row + for row in frozen["manifest"]["artifacts"] + if row.get("role") == "visual-overlay-video" + ), + None, + ) + if not isinstance(descriptor, dict): + raise HTTPException(status_code=404, detail="E46F video не найден") + return FileResponse( + frozen["overlay_path"], + media_type="video/mp4", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + + return router + + +def _project_result(frozen: dict[str, Any], e46e_root_provider: RootProvider) -> dict[str, object]: + report = frozen["report"] + overlay = next( + row for row in frozen["manifest"]["artifacts"] if row.get("role") == "visual-overlay-video" + ) + baseline_id = report["comparison_contract"]["baseline_result_id"] + baseline = _read_baseline(e46e_root_provider, baseline_id) + baseline_metrics = baseline["report"]["metrics"] + metrics = report["metrics"] + candidate_triage = _large_box_triage(frozen["frames"]) + baseline_triage = _large_box_triage(baseline["frames"]) + result_id = frozen["result_id"] + return { + "schema_version": "missioncore.e46f-dashcam-bakeoff-view/v1", + "result_id": result_id, + "created_at_utc": report["created_at_utc"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "status": report["status"], + "metrics": copy.deepcopy(metrics), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "method": copy.deepcopy(report["method"]), + "limitations": copy.deepcopy(report["limitations"]), + "comparison": { + "controlled_change": "detector-only", + "baseline_result_id": baseline_id, + "baseline_metrics": copy.deepcopy(baseline_metrics), + "delta": { + "zero_track_frame_count": metrics["zero_track_frame_count"] + - baseline_metrics["zero_track_frame_count"], + "full_layer_blackout_event_count": metrics["full_layer_blackout_event_count"] + - baseline_metrics["full_layer_blackout_event_count"], + "unique_track_count": metrics["unique_track_count"] + - baseline_metrics["unique_track_count"], + "short_track_fraction": metrics["short_track_fraction"] + - baseline_metrics["short_track_fraction"], + }, + "large_box_visual_triage": { + "area_ratio_threshold": 0.2, + "candidate": candidate_triage, + "baseline": baseline_triage, + "interpretation": "diagnostic visual triage; not precision/recall", + }, + "visual_review": { + "status": "rejected-semantic-regression", + "sample_video_seconds": [4.2, 9.0, 22.0, 44.0, 264.0, 418.0], + "finding": ( + "DashCamNet repeatedly turns the fisheye black rim and edge geometry " + "into very large person tracks; temporal continuity improves, but the " + "semantic object layer is less trustworthy than E46E" + ), + "next_action": ( + "insert one calibration-derived rectification and valid-FOV projection " + "before any perspective ready detector, then repeat the controlled bake-off" + ), + }, + "verdict": "reject-dashcamnet-on-unrectified-fisheye", + }, + "video": { + "url": f"/api/v1/laboratory/e46f/results/{result_id}/overlay.mp4", + "media_type": "video/mp4", + "byte_length": overlay["byte_length"], + "sha256": overlay["sha256"], + "width": 800, + "height": 600, + }, + "ground_truth": False, + "access": "read-only-rejected-detector-bakeoff", + } + + +def _read_baseline(provider: RootProvider, result_id: str) -> dict[str, Any]: + root = _configured_root(provider) + if root is None or _BASELINE_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46E baseline не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46E baseline не найден") + try: + return read_e46e_ready_stack(path) + except (E46EReadyStackError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46E baseline не найден") from None + + +def _large_box_triage(frames: Iterable[dict[str, Any]]) -> dict[str, object]: + observation_count = 0 + frame_ids: set[int] = set() + track_ids: set[int] = set() + classes: Counter[str] = Counter() + for frame in frames: + for item in frame["objects"]: + _, _, width, height = item["bbox"] + if float(width) * float(height) < 800 * 600 * 0.2: + continue + observation_count += 1 + frame_ids.add(int(frame["frame_index"])) + track_ids.add(int(item["source_track_id"])) + classes.update((str(item["class_name"]),)) + return { + "observation_count": observation_count, + "frame_count": len(frame_ids), + "track_id_count": len(track_ids), + "class_observations": dict(sorted(classes.items())), + } + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46g_rectified_detector_bakeoff_api.py b/src/k1link/web/e46g_rectified_detector_bakeoff_api.py new file mode 100644 index 0000000..9fc74ef --- /dev/null +++ b/src/k1link/web/e46g_rectified_detector_bakeoff_api.py @@ -0,0 +1,185 @@ +"""Read-only LAB projection for the calibrated E46G ready-detector bake-off.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.e46g_rectified_detector_bakeoff import ( + E46GRectifiedDetectorBakeoffError, + read_e46g_rectified_detector_bakeoff, +) + +_RESULT_ID = re.compile(r"^e46g-rectified-detector-bakeoff-[a-f0-9]{64}$") +_CANDIDATES = ("trafficcamnet", "dashcamnet") +RootProvider = Callable[[], Path | None] + + +def build_e46g_rectified_detector_bakeoff_router( + *, root_provider: RootProvider = lambda: None +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46g", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46G result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46G result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46G result не найден") + try: + return read_e46g_rectified_detector_bakeoff(path) + except (E46GRectifiedDetectorBakeoffError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46G result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46g-rectified-detector-bakeoff-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-diagnostic-selection", + } + + @router.get("/results/{result_id}/{candidate}.mp4") + def get_comparison_video(result_id: str, candidate: str) -> FileResponse: + if candidate not in _CANDIDATES: + raise HTTPException(status_code=404, detail="E46G video не найден") + frozen = result(result_id) + descriptor = _video_descriptor(frozen, candidate) + return FileResponse( + frozen["comparison_paths"][candidate], + media_type="video/mp4", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + result_id = frozen["result_id"] + videos = { + candidate: { + "url": f"/api/v1/laboratory/e46g/results/{result_id}/{candidate}.mp4", + "media_type": "video/mp4", + "byte_length": descriptor["byte_length"], + "sha256": descriptor["sha256"], + "width": 2880, + "height": 544, + "duration_seconds": 60.0, + "view_order": ["left", "front", "right"], + } + for candidate in _CANDIDATES + for descriptor in (_video_descriptor(frozen, candidate),) + } + return { + "schema_version": "missioncore.e46g-rectified-detector-bakeoff-view/v1", + "result_id": result_id, + "created_at_utc": report["created_at_utc"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "status": "selected-for-next-diagnostic-full-route", + "selection": copy.deepcopy(report["selection"]), + "rectification": copy.deepcopy(report["rectification"]), + "metrics": copy.deepcopy(report["metrics"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "method": copy.deepcopy(report["method"]), + "limitations": copy.deepcopy(report["limitations"]), + "comparison": { + "controlled_change": "detector-provider-only", + "visual_review": { + "status": "selected-for-next-diagnostic", + "reviewed_video_seconds": [0.0, 10.0, 20.0, 30.0, 40.0, 50.0], + "selected_candidate": "trafficcamnet", + "selected_view": "front", + "excluded_views": ["left", "right"], + "finding": ( + "The calibrated FRONT projection is useful. TrafficCamNet preserves " + "substantially more visible vehicles and people than DashCamNet, while " + "DashCamNet repeatedly leaves visible road objects undetected." + ), + "risk": ( + "TrafficCamNet still produces duplicate or stale boxes around some " + "vehicles. LEFT and RIGHT projections are dominated by the camera mount " + "and trigger large semantic false tracks, so they are excluded." + ), + "next_action": ( + "run TrafficCamNet with stock NvDCF over the complete retained FRONT " + "projection and review the full temporal overlay before motion-state work" + ), + }, + "verdict": "select-trafficcamnet-front-only-for-e46h", + }, + "videos": videos, + "ground_truth": False, + "authority": copy.deepcopy(report["authority"]), + "access": "read-only-diagnostic-selection", + } + + +def _video_descriptor(frozen: dict[str, Any], candidate: str) -> dict[str, Any]: + descriptor = next( + ( + row + for row in frozen["manifest"]["artifacts"] + if row.get("role") == f"comparison-video-{candidate}" + ), + None, + ) + if not isinstance(descriptor, dict): + raise HTTPException(status_code=404, detail="E46G video не найден") + return descriptor + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46h_full_rectified_front_replay_api.py b/src/k1link/web/e46h_full_rectified_front_replay_api.py new file mode 100644 index 0000000..8f26616 --- /dev/null +++ b/src/k1link/web/e46h_full_rectified_front_replay_api.py @@ -0,0 +1,224 @@ +"""Read-only LAB projection and video delivery for E46H.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.e46h_full_rectified_front_replay import ( + E46HFullRectifiedFrontReplayError, + read_e46h_full_rectified_front_replay, +) + +_RESULT_ID = re.compile(r"^e46h-full-rectified-front-replay-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + +_REVIEW_WINDOWS = [ + { + "id": "large-false-car-wall", + "label": "6.0–10.9 с · false car на стене", + "start_seconds": 6.0, + "end_seconds": 10.9, + "source_track_id": 6, + "verdict": "semantic-false-positive", + }, + { + "id": "large-false-car-shrub", + "label": "178.6–180.3 с · false car на кусте", + "start_seconds": 178.6, + "end_seconds": 180.3, + "source_track_id": 371, + "verdict": "semantic-false-positive", + }, + { + "id": "large-false-car-ground", + "label": "250.7–265.6 с · false car на полотне", + "start_seconds": 250.7, + "end_seconds": 265.6, + "source_track_id": 481, + "verdict": "semantic-false-positive", + }, + { + "id": "large-false-car-road", + "label": "392.2–400.6 с · false car на дороге", + "start_seconds": 392.2, + "end_seconds": 400.6, + "source_track_id": 822, + "verdict": "semantic-false-positive", + }, + { + "id": "empty-scene-not-blackout", + "label": "419.4–426.9 с · пустая сцена, не сбой", + "start_seconds": 419.4, + "end_seconds": 426.9, + "source_track_id": None, + "verdict": "empty-scene-expected", + }, + { + "id": "large-false-car-terrace", + "label": "440.8–448.4 с · false car на террасе", + "start_seconds": 440.8, + "end_seconds": 448.4, + "source_track_id": 927, + "verdict": "semantic-false-positive", + }, +] + + +def build_e46h_full_rectified_front_replay_router( + *, root_provider: RootProvider = lambda: None +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46h", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46H result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46H result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46H result не найден") + try: + return read_e46h_full_rectified_front_replay(path) + except (E46HFullRectifiedFrontReplayError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46H result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46h-full-rectified-front-replay-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-full-route-diagnostic", + } + + @router.get("/results/{result_id}/overlay.mp4") + def get_overlay_video(result_id: str) -> FileResponse: + frozen = result(result_id) + descriptor = _video_descriptor(frozen) + return FileResponse( + frozen["overlay_path"], + media_type="video/mp4", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + descriptor = _video_descriptor(frozen) + result_id = frozen["result_id"] + return { + "schema_version": "missioncore.e46h-full-rectified-front-replay-view/v1", + "result_id": result_id, + "created_at_utc": report["created_at_utc"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "status": "diagnostic-regression-large-semantic-false-tracks", + "baseline_result_id": report["baseline_result_id"], + "selection": copy.deepcopy(report["selection"]), + "rectification": copy.deepcopy(report["rectification"]), + "metrics": copy.deepcopy(report["metrics"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "method": copy.deepcopy(report["method"]), + "limitations": copy.deepcopy(report["limitations"]), + "visual_review": { + "status": "full-continuous-and-targeted-review-completed", + "complete_video_reviewed": True, + "reviewed_video_range_seconds": [0.0, 448.8], + "verdict": "useful-front-continuity-but-semantic-regression-blocks-promotion", + "review_windows": copy.deepcopy(_REVIEW_WINDOWS), + "finding": ( + "FRONT is readable and most route objects are tracked, but five large false " + "car tracks attach to a wall, shrub, road surface and terrace." + ), + "blackout_interpretation": ( + "The two zero-track intervals at 419.4..426.9 video seconds show an actually " + "empty scene and are not object-layer failures." + ), + "next_action": ( + "do not tune the detector to this route; isolate a provider-level semantic " + "suppression or stronger ready model on the same immutable FRONT adapter" + ), + }, + "video": { + "url": f"/api/v1/laboratory/e46h/results/{result_id}/overlay.mp4", + "media_type": "video/mp4", + "byte_length": descriptor["byte_length"], + "sha256": descriptor["sha256"], + "width": 960, + "height": 544, + "duration_seconds": 448.8, + }, + "ground_truth": False, + "authority": copy.deepcopy(report["authority"]), + "access": "read-only-full-route-diagnostic", + } + + +def _video_descriptor(frozen: dict[str, Any]) -> dict[str, Any]: + descriptor = next( + ( + row + for row in frozen["manifest"]["artifacts"] + if row.get("role") == "visual-overlay-video" + ), + None, + ) + if not isinstance(descriptor, dict): + raise HTTPException(status_code=404, detail="E46H video не найден") + return descriptor + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46i_grounding_dino_full_replay_api.py b/src/k1link/web/e46i_grounding_dino_full_replay_api.py new file mode 100644 index 0000000..a388915 --- /dev/null +++ b/src/k1link/web/e46i_grounding_dino_full_replay_api.py @@ -0,0 +1,194 @@ +"""Read-only LAB projection and visual evidence delivery for E46I.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.e46i_grounding_dino_full_replay import ( + E46IGroundingDinoFullReplayError, + read_e46i_grounding_dino_full_replay, +) + +_RESULT_ID = re.compile(r"^e46i-grounding-dino-full-replay-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + + +def build_e46i_grounding_dino_full_replay_router( + *, root_provider: RootProvider = lambda: None +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46i", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46I result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46I result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46I result не найден") + try: + return read_e46i_grounding_dino_full_replay(path) + except (E46IGroundingDinoFullReplayError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46I result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46i-grounding-dino-full-replay-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-full-route-diagnostic", + } + + @router.get("/results/{result_id}/overlay.mp4") + def get_overlay_video(result_id: str) -> FileResponse: + frozen = result(result_id) + descriptor = _artifact_descriptor(frozen, "visual-overlay-video") + return _file_response(frozen["overlay_path"], descriptor, "video/mp4") + + @router.get("/results/{result_id}/visual/{role}.png") + def get_visual(result_id: str, role: str) -> FileResponse: + roles = { + "shadow-gate": ("shadow-gate-contact-sheet", "shadow_sheet_path"), + "full-route": ("full-route-contact-sheet", "route_sheet_path"), + "targeted-windows": ( + "targeted-windows-contact-sheet", + "targeted_sheet_path", + ), + } + selected = roles.get(role) + if selected is None: + raise HTTPException(status_code=404, detail="E46I visual не найден") + frozen = result(result_id) + descriptor = _artifact_descriptor(frozen, selected[0]) + return _file_response(frozen[selected[1]], descriptor, "image/png") + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + result_id = frozen["result_id"] + video = _artifact_descriptor(frozen, "visual-overlay-video") + visual_roles = { + "shadow_gate": "shadow-gate-contact-sheet", + "full_route": "full-route-contact-sheet", + "targeted_windows": "targeted-windows-contact-sheet", + } + visuals = {} + for key, role in visual_roles.items(): + descriptor = _artifact_descriptor(frozen, role) + slug = role.removesuffix("-contact-sheet") + visuals[key] = { + "url": f"/api/v1/laboratory/e46i/results/{result_id}/visual/{slug}.png", + "media_type": "image/png", + "byte_length": descriptor["byte_length"], + "sha256": descriptor["sha256"], + } + return { + "schema_version": "missioncore.e46i-grounding-dino-full-replay-view/v1", + "result_id": result_id, + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "baseline_result_id": report["baseline_result_id"], + "source": copy.deepcopy(report["source"]), + "provider": copy.deepcopy(report["provider"]), + "inference": copy.deepcopy(report["inference"]), + "metrics": copy.deepcopy(report["metrics"]), + "shadow_gate": copy.deepcopy(report["shadow_gate"]), + "visual_review": copy.deepcopy(report["visual_review"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "method": copy.deepcopy(report["method"]), + "limitations": copy.deepcopy(report["limitations"]), + "video": { + "url": f"/api/v1/laboratory/e46i/results/{result_id}/overlay.mp4", + "media_type": "video/mp4", + "byte_length": video["byte_length"], + "sha256": video["sha256"], + "width": 960, + "height": 544, + "frame_rate": 10.0, + "frame_count": 4488, + "duration_seconds": 448.8, + }, + "visuals": visuals, + "ground_truth": False, + "authority": copy.deepcopy(report["authority"]), + "access": "read-only-full-route-diagnostic", + } + + +def _artifact_descriptor(frozen: dict[str, Any], role: str) -> dict[str, Any]: + descriptor = next( + ( + row + for row in frozen["manifest"]["artifacts"] + if isinstance(row, dict) and row.get("role") == role + ), + None, + ) + if not isinstance(descriptor, dict): + raise HTTPException(status_code=404, detail="E46I artifact не найден") + return descriptor + + +def _file_response(path: Path, descriptor: dict[str, Any], media_type: str) -> FileResponse: + return FileResponse( + path, + media_type=media_type, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/e46j_raw_fisheye_realtime_api.py b/src/k1link/web/e46j_raw_fisheye_realtime_api.py new file mode 100644 index 0000000..84356aa --- /dev/null +++ b/src/k1link/web/e46j_raw_fisheye_realtime_api.py @@ -0,0 +1,196 @@ +"""Read-only LAB projection and visual evidence delivery for E46J.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.e46j_raw_fisheye_realtime import ( + E46JRawFisheyeRealtimeError, + read_e46j_raw_fisheye_realtime, +) + +_RESULT_ID = re.compile(r"^e46j-raw-fisheye-realtime-[a-f0-9]{64}$") +RootProvider = Callable[[], Path | None] + + +def build_e46j_raw_fisheye_realtime_router( + *, root_provider: RootProvider = lambda: None +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/e46j", tags=["laboratory"]) + + def result(result_id: str) -> dict[str, Any]: + if _RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="E46J result не найден") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="E46J result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="E46J result не найден") + try: + return read_e46j_raw_fisheye_realtime(path) + except (E46JRawFisheyeRealtimeError, OSError, ValueError): + raise HTTPException(status_code=404, detail="E46J result не найден") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _candidates(root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + frozen = result(path.name) + if len(items) < limit: + items.append(_project_result(frozen)) + except HTTPException: + invalid_total += 1 + return { + "schema_version": "missioncore.e46j-raw-fisheye-realtime-catalog/v1", + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only-full-route-diagnostic", + } + + @router.get("/results/{result_id}/overlay.mp4") + def get_overlay_video(result_id: str) -> FileResponse: + frozen = result(result_id) + descriptor = _artifact_descriptor(frozen, "visual-overlay-video") + return _file_response(frozen["overlay_path"], descriptor, "video/mp4") + + @router.get("/results/{result_id}/visual/{role}.png") + def get_visual(result_id: str, role: str) -> FileResponse: + roles = { + "full-route": ("full-route-contact-sheet", "route_sheet_path"), + "targeted-windows": ( + "targeted-windows-contact-sheet", + "targeted_sheet_path", + ), + "operator-shadow": ( + "operator-shadow-contact-sheet", + "shadow_sheet_path", + ), + } + selected = roles.get(role) + if selected is None: + raise HTTPException(status_code=404, detail="E46J visual не найден") + frozen = result(result_id) + descriptor = _artifact_descriptor(frozen, selected[0]) + return _file_response(frozen[selected[1]], descriptor, "image/png") + + return router + + +def _project_result(frozen: dict[str, Any]) -> dict[str, object]: + report = frozen["report"] + result_id = frozen["result_id"] + video = _artifact_descriptor(frozen, "visual-overlay-video") + visual_roles = { + "full_route": "full-route-contact-sheet", + "targeted_windows": "targeted-windows-contact-sheet", + "operator_shadow": "operator-shadow-contact-sheet", + } + visuals = {} + for key, role in visual_roles.items(): + descriptor = _artifact_descriptor(frozen, role) + slug = role.removesuffix("-contact-sheet") + visuals[key] = { + "url": f"/api/v1/laboratory/e46j/results/{result_id}/visual/{slug}.png", + "media_type": "image/png", + "byte_length": descriptor["byte_length"], + "sha256": descriptor["sha256"], + } + return { + "schema_version": "missioncore.e46j-raw-fisheye-realtime-view/v1", + "result_id": result_id, + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "source": copy.deepcopy(report["source"]), + "detector": copy.deepcopy(report["detector"]), + "preprocessing": copy.deepcopy(report["preprocessing"]), + "detection": copy.deepcopy(report["detection"]), + "metrics": copy.deepcopy(report["metrics"]), + "visual_review": copy.deepcopy(report["visual_review"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "method": copy.deepcopy(report["method"]), + "limitations": copy.deepcopy(report["limitations"]), + "video": { + "url": f"/api/v1/laboratory/e46j/results/{result_id}/overlay.mp4", + "media_type": "video/mp4", + "byte_length": video["byte_length"], + "sha256": video["sha256"], + "width": 800, + "height": 600, + "frame_rate": 10.003944527024467, + "frame_count": 4489, + "duration_seconds": 448.723, + }, + "visuals": visuals, + "ground_truth": False, + "authority": copy.deepcopy(report["authority"]), + "access": "read-only-full-route-diagnostic", + } + + +def _artifact_descriptor(frozen: dict[str, Any], role: str) -> dict[str, Any]: + descriptor = next( + ( + row + for row in frozen["manifest"]["artifacts"] + if isinstance(row, dict) and row.get("role") == role + ), + None, + ) + if not isinstance(descriptor, dict): + raise HTTPException(status_code=404, detail="E46J artifact не найден") + return descriptor + + +def _file_response(path: Path, descriptor: dict[str, Any], media_type: str) -> FileResponse: + return FileResponse( + path, + media_type=media_type, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + item + for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name) + ), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/l32_pointpillars_camera_review_api.py b/src/k1link/web/l32_pointpillars_camera_review_api.py new file mode 100644 index 0000000..aec8925 --- /dev/null +++ b/src/k1link/web/l32_pointpillars_camera_review_api.py @@ -0,0 +1,323 @@ +"""Read-only API for sealed L3.2 camera-bound PointPillars evidence.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any, Final + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +RootProvider = Callable[[], Path | None] + +RESULT_SCHEMA: Final = "missioncore.l32-pointpillars-camera-review/v1" +CATALOG_SCHEMA: Final = "missioncore.l32-pointpillars-camera-review-catalog/v1" +FRAME_SCHEMA: Final = "missioncore.l32-pointpillars-camera-review-frame/v1" +RESULT_PROJECTION_SCHEMA: Final = ( + "missioncore.l32-pointpillars-camera-review-result/v1" +) +RESULT_CATALOG_SCHEMA: Final = ( + "missioncore.l32-pointpillars-camera-review-catalog-results/v1" +) +RESULT_ID: Final = re.compile( + r"^l32-pointpillars-camera-review-[a-f0-9]{64}$" +) +FRAME_ID: Final = re.compile(r"^[0-9]{6}$") +SHA256: Final = re.compile(r"^[a-f0-9]{64}$") +MAX_JSON_BYTES: Final = 16 * 1024 * 1024 +MAX_FRAMES: Final = 18 + + +def build_l32_pointpillars_camera_review_router( + *, + root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter( + prefix="/api/v1/laboratory/l32/pointpillars-camera-review", + tags=["laboratory"], + ) + + @router.get("/results") + def list_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + root = _configured_root(root_provider) + if root is None: + return _empty_catalog(False) + candidates = _candidates(root) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in candidates: + try: + items.append(_project_result(candidate)) + except RuntimeError: + invalid_total += 1 + items.sort( + key=lambda item: (str(item["created_at_utc"]), str(item["result_id"])), + reverse=True, + ) + return { + "schema_version": RESULT_CATALOG_SCHEMA, + "configured": True, + "items": items[:limit], + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/{result_id}/frames/{frame_id}") + def get_frame(result_id: str, frame_id: str) -> dict[str, object]: + candidate, descriptor = _resolve_frame( + root_provider, result_id=result_id, frame_id=frame_id + ) + path = candidate / str(descriptor["detail_path"]) + payload = _read_json(path) + if ( + payload.get("schema_version") != FRAME_SCHEMA + or payload.get("frame_id") != frame_id + or descriptor.get("detail_sha256") != _sha256(path) + or descriptor.get("detail_byte_length") != path.stat().st_size + or not _valid_frame_payload(payload) + ): + raise HTTPException(status_code=404, detail="L3.2 frame not found") + return {**copy.deepcopy(payload), "access": "read-only"} + + @router.get("/{result_id}/frames/{frame_id}/camera") + def get_camera(result_id: str, frame_id: str) -> FileResponse: + candidate, descriptor = _resolve_frame( + root_provider, result_id=result_id, frame_id=frame_id + ) + path = (candidate / str(descriptor["camera_path"])).resolve() + if ( + not path.is_relative_to(candidate) + or path.is_symlink() + or not path.is_file() + or descriptor.get("camera_sha256") != _sha256(path) + ): + raise HTTPException(status_code=404, detail="L3.2 camera not found") + return FileResponse(path, media_type="image/jpeg") + + return router + + +def latest_l32_identity(root_provider: RootProvider) -> dict[str, str] | None: + root = _configured_root(root_provider) + if root is None: + return None + valid: list[dict[str, object]] = [] + for candidate in _candidates(root): + try: + valid.append(_project_result(candidate)) + except RuntimeError: + continue + if not valid: + return None + latest = max( + valid, + key=lambda item: (str(item["created_at_utc"]), str(item["result_id"])), + ) + return { + "result_id": str(latest["result_id"]), + "created_at_utc": str(latest["created_at_utc"]), + } + + +def _resolve_frame( + root_provider: RootProvider, + *, + result_id: str, + frame_id: str, +) -> tuple[Path, dict[str, Any]]: + if not RESULT_ID.fullmatch(result_id) or not FRAME_ID.fullmatch(frame_id): + raise HTTPException(status_code=404, detail="L3.2 frame not found") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.2 result not found") + candidate = root / result_id + try: + result = _load_result(candidate) + descriptor = next( + item for item in result["catalog"]["frames"] + if item["frame_id"] == frame_id + ) + except (RuntimeError, StopIteration): + raise HTTPException(status_code=404, detail="L3.2 frame not found") from None + return candidate, descriptor + + +def _project_result(candidate: Path) -> dict[str, object]: + result = _load_result(candidate) + manifest = result["manifest"] + identity = manifest["identity"] + return { + "schema_version": RESULT_PROJECTION_SCHEMA, + "result_id": manifest["result_id"], + "created_at_utc": manifest["created_at_utc"], + "status": "camera-bound-review-rejects-current-candidate", + "source_session_id": identity["source_session_id"], + "source_l31_result_id": identity["source_l31_result_id"], + "source_e10_pack_id": identity["source_e10_pack_id"], + "projection": copy.deepcopy(identity["projection"]), + "metrics": copy.deepcopy(manifest["metrics"]), + "frames": copy.deepcopy(result["catalog"]["frames"]), + "limitations": copy.deepcopy(manifest["limitations"]), + "authority": copy.deepcopy(manifest["authority"]), + "access": "read-only", + } + + +def _load_result(candidate: Path) -> dict[str, Any]: + if ( + not candidate.is_dir() + or candidate.is_symlink() + or not RESULT_ID.fullmatch(candidate.name) + ): + raise RuntimeError("L3.2 candidate is invalid") + manifest = _read_json(candidate / "manifest.json") + identity = manifest.get("identity") + authority = manifest.get("authority") + catalog_descriptor = manifest.get("catalog") + if ( + manifest.get("schema_version") != RESULT_SCHEMA + or manifest.get("result_id") != candidate.name + or manifest.get("status") != "camera-bound-review-rejects-current-candidate" + or not isinstance(manifest.get("created_at_utc"), str) + or not isinstance(identity, dict) + or identity.get("schema_version") != RESULT_SCHEMA + or identity.get("source_session_id") != "20260720T065719Z_viewer_live" + or manifest.get("identity_sha256") + != hashlib.sha256(_canonical_json(identity)).hexdigest() + or candidate.name + != f"l32-pointpillars-camera-review-{manifest.get('identity_sha256')}" + or not isinstance(authority, dict) + or authority.get("shadow_only") is not True + or authority.get("accuracy_accepted") is not False + or authority.get("commands_enabled") is not False + or not isinstance(manifest.get("metrics"), dict) + or not isinstance(manifest.get("limitations"), list) + or not isinstance(catalog_descriptor, dict) + or catalog_descriptor.get("path") != "catalog.json" + ): + raise RuntimeError("L3.2 manifest is invalid") + catalog_path = candidate / "catalog.json" + if ( + catalog_descriptor.get("sha256") != _sha256(catalog_path) + or catalog_descriptor.get("byte_length") != catalog_path.stat().st_size + ): + raise RuntimeError("L3.2 catalog changed") + catalog = _read_json(catalog_path) + frames = catalog.get("frames") + if ( + catalog.get("schema_version") != CATALOG_SCHEMA + or catalog.get("result_id") != candidate.name + or not isinstance(frames, list) + or not 1 <= len(frames) <= MAX_FRAMES + or catalog.get("frame_count") != len(frames) + or len({item.get("frame_id") for item in frames if isinstance(item, dict)}) + != len(frames) + or any(not _valid_descriptor(item) for item in frames) + ): + raise RuntimeError("L3.2 catalog is invalid") + return {"manifest": manifest, "catalog": catalog} + + +def _valid_descriptor(value: object) -> bool: + if not isinstance(value, dict): + return False + frame_id = value.get("frame_id") + return ( + isinstance(frame_id, str) + and FRAME_ID.fullmatch(frame_id) is not None + and value.get("detail_path") == f"frame-{frame_id}.json" + and isinstance(value.get("detail_sha256"), str) + and SHA256.fullmatch(value["detail_sha256"]) is not None + and isinstance(value.get("detail_byte_length"), int) + and 0 < value["detail_byte_length"] <= MAX_JSON_BYTES + and isinstance(value.get("camera_path"), str) + and value["camera_path"].startswith("frames/frame-") + and isinstance(value.get("camera_sha256"), str) + and SHA256.fullmatch(value["camera_sha256"]) is not None + and isinstance(value.get("camera_delta_ms"), (int, float)) + and abs(float(value["camera_delta_ms"])) <= 100.0 + ) + + +def _valid_frame_payload(value: dict[str, Any]) -> bool: + projection = value.get("camera_projection") + camera = value.get("camera") + interpretation = value.get("interpretation") + return ( + isinstance(value.get("summary"), dict) + and isinstance(value.get("points"), dict) + and isinstance(value.get("prediction_boxes"), list) + and isinstance(projection, dict) + and projection.get("point_layout") == "flat-xy-depth-m" + and isinstance(projection.get("point_count"), int) + and isinstance(projection.get("point_values"), list) + and len(projection["point_values"]) == projection["point_count"] * 3 + and isinstance(projection.get("boxes"), list) + and isinstance(camera, dict) + and camera.get("media_type") == "image/jpeg" + and camera.get("exact_source_frame") is True + and isinstance(interpretation, dict) + and interpretation.get("camera_is_semantic_reference") is True + and interpretation.get("boxes_are_model_hypotheses") is True + and interpretation.get("accuracy_claim_allowed") is False + ) + + +def _configured_root(provider: RootProvider) -> Path | None: + configured = provider() + if configured is None: + return None + root = configured.expanduser().resolve() + return root if root.is_dir() and not root.is_symlink() else None + + +def _candidates(root: Path) -> list[Path]: + return [ + item for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and RESULT_ID.fullmatch(item.name) + ] + + +def _empty_catalog(configured: bool) -> dict[str, object]: + return { + "schema_version": RESULT_CATALOG_SCHEMA, + "configured": configured, + "items": [], + "candidate_total": 0, + "invalid_total": 0, + "access": "read-only", + } + + +def _read_json(path: Path) -> dict[str, Any]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_JSON_BYTES: + raise RuntimeError("L3.2 JSON artifact is invalid") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError("L3.2 JSON artifact is invalid") from exc + if not isinstance(value, dict): + raise RuntimeError("L3.2 JSON artifact is invalid") + return value + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + +def _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() diff --git a/src/k1link/web/l33_camera_first_detector_review_api.py b/src/k1link/web/l33_camera_first_detector_review_api.py new file mode 100644 index 0000000..250df2b --- /dev/null +++ b/src/k1link/web/l33_camera_first_detector_review_api.py @@ -0,0 +1,394 @@ +"""Read-only API for sealed L3.3 camera-first detector evidence.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any, Final + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.l33_camera_first_admission import is_admitted_l33_identity + +RootProvider = Callable[[], Path | None] + +RESULT_SCHEMA: Final = "missioncore.l33-camera-first-detector-review/v1" +CATALOG_SCHEMA: Final = "missioncore.l33-camera-first-detector-review-catalog/v1" +FRAME_SCHEMA: Final = "missioncore.l33-camera-first-detector-review-frame/v1" +RESULT_PROJECTION_SCHEMA: Final = "missioncore.l33-camera-first-detector-review-result/v1" +RESULT_CATALOG_SCHEMA: Final = "missioncore.l33-camera-first-detector-review-catalog-results/v1" +RESULT_ID: Final = re.compile(r"^l33-camera-first-detector-review-[a-f0-9]{64}$") +FRAME_ID: Final = re.compile(r"^[0-9]{6}$") +SHA256: Final = re.compile(r"^[a-f0-9]{64}$") +MAX_JSON_BYTES: Final = 16 * 1024 * 1024 +MAX_FRAMES: Final = 18 + + +def build_l33_camera_first_detector_review_router( + *, + root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter( + prefix="/api/v1/laboratory/l33/camera-first-detector-review", + tags=["laboratory"], + ) + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + root = _configured_root(root_provider) + if root is None: + return _empty_catalog(False) + candidates = _candidates(root) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in candidates: + try: + items.append(_project_result(candidate)) + except RuntimeError: + invalid_total += 1 + items.sort( + key=lambda item: (str(item["created_at_utc"]), str(item["result_id"])), + reverse=True, + ) + return { + "schema_version": RESULT_CATALOG_SCHEMA, + "configured": True, + "items": items[:limit], + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/{result_id}/frames/{frame_id}") + def get_frame(result_id: str, frame_id: str) -> dict[str, object]: + candidate, descriptor = _resolve_frame( + root_provider, result_id=result_id, frame_id=frame_id + ) + path = candidate / str(descriptor["detail_path"]) + payload = _read_json(path) + if ( + payload.get("schema_version") != FRAME_SCHEMA + or payload.get("frame_id") != frame_id + or descriptor.get("detail_sha256") != _sha256(path) + or descriptor.get("detail_byte_length") != path.stat().st_size + or not _valid_frame_payload(payload) + ): + raise HTTPException(status_code=404, detail="L3.3 frame not found") + return {**copy.deepcopy(payload), "access": "read-only"} + + @router.get("/{result_id}/frames/{frame_id}/camera") + def get_camera(result_id: str, frame_id: str) -> FileResponse: + candidate, descriptor = _resolve_frame( + root_provider, result_id=result_id, frame_id=frame_id + ) + path = (candidate / str(descriptor["camera_path"])).resolve() + if ( + not path.is_relative_to(candidate) + or path.is_symlink() + or not path.is_file() + or descriptor.get("camera_sha256") != _sha256(path) + ): + raise HTTPException(status_code=404, detail="L3.3 camera not found") + return FileResponse(path, media_type="image/jpeg") + + return router + + +def latest_l33_identity(root_provider: RootProvider) -> dict[str, str] | None: + root = _configured_root(root_provider) + if root is None: + return None + valid: list[dict[str, object]] = [] + for candidate in _candidates(root): + try: + valid.append(_project_result(candidate)) + except RuntimeError: + continue + if not valid: + return None + latest = max( + valid, + key=lambda item: (str(item["created_at_utc"]), str(item["result_id"])), + ) + return { + "result_id": str(latest["result_id"]), + "created_at_utc": str(latest["created_at_utc"]), + } + + +def _resolve_frame( + root_provider: RootProvider, + *, + result_id: str, + frame_id: str, +) -> tuple[Path, dict[str, Any]]: + if not RESULT_ID.fullmatch(result_id) or not FRAME_ID.fullmatch(frame_id): + raise HTTPException(status_code=404, detail="L3.3 frame not found") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.3 result not found") + candidate = root / result_id + try: + result = _load_result(candidate) + descriptor = next( + item for item in result["catalog"]["frames"] if item["frame_id"] == frame_id + ) + except (RuntimeError, StopIteration): + raise HTTPException(status_code=404, detail="L3.3 frame not found") from None + return candidate, descriptor + + +def _project_result(candidate: Path) -> dict[str, object]: + result = _load_result(candidate) + manifest = result["manifest"] + identity = manifest["identity"] + return { + "schema_version": RESULT_PROJECTION_SCHEMA, + "result_id": manifest["result_id"], + "created_at_utc": manifest["created_at_utc"], + "status": manifest["status"], + "source_session_id": identity["source_session_id"], + "source_l32_result_id": identity["source_l32_result_id"], + "source_e26_result_id": identity["source_e26_result_id"], + "source_e29_result_id": identity["source_e29_result_id"], + "source_e10_pack_id": identity["source_e10_pack_id"], + "source_world_state_result_id": identity.get("source_world_state_result_id"), + "detector": copy.deepcopy(identity["detector"]), + "semantic_contract": copy.deepcopy(identity["semantic_contract"]), + "geometry_contract": copy.deepcopy(identity["geometry_contract"]), + "metrics": copy.deepcopy(manifest["metrics"]), + "frames": copy.deepcopy(result["catalog"]["frames"]), + "limitations": copy.deepcopy(manifest["limitations"]), + "authority": copy.deepcopy(manifest["authority"]), + "access": "read-only", + } + + +def _load_result(candidate: Path) -> dict[str, Any]: + if ( + not candidate.is_dir() + or candidate.is_symlink() + or not RESULT_ID.fullmatch(candidate.name) + ): + raise RuntimeError("L3.3 candidate is invalid") + manifest = _read_json(candidate / "manifest.json") + identity = manifest.get("identity") + authority = manifest.get("authority") + catalog_descriptor = manifest.get("catalog") + if ( + manifest.get("schema_version") != RESULT_SCHEMA + or manifest.get("result_id") != candidate.name + or manifest.get("status") != "camera-first-boundary-restored-shadow-only" + or not isinstance(manifest.get("created_at_utc"), str) + or manifest.get("ground_truth") is not False + or not isinstance(identity, dict) + or identity.get("schema_version") != RESULT_SCHEMA + or not is_admitted_l33_identity(identity) + or manifest.get("identity_sha256") + != hashlib.sha256(_canonical_json(identity)).hexdigest() + or candidate.name + != f"l33-camera-first-detector-review-{manifest.get('identity_sha256')}" + or not isinstance(authority, dict) + or authority.get("shadow_only") is not True + or authority.get("accuracy_accepted") is not False + or authority.get("commands_enabled") is not False + or authority.get("navigation_or_safety_accepted") is not False + or not isinstance(manifest.get("metrics"), dict) + or not isinstance(manifest.get("limitations"), list) + or not isinstance(catalog_descriptor, dict) + or catalog_descriptor.get("path") != "catalog.json" + ): + raise RuntimeError("L3.3 manifest is invalid") + catalog_path = candidate / "catalog.json" + if ( + catalog_descriptor.get("sha256") != _sha256(catalog_path) + or catalog_descriptor.get("byte_length") != catalog_path.stat().st_size + ): + raise RuntimeError("L3.3 catalog changed") + catalog = _read_json(catalog_path) + frames = catalog.get("frames") + if ( + catalog.get("schema_version") != CATALOG_SCHEMA + or catalog.get("result_id") != candidate.name + or not isinstance(frames, list) + or not 1 <= len(frames) <= MAX_FRAMES + or catalog.get("frame_count") != len(frames) + or len({item.get("frame_id") for item in frames if isinstance(item, dict)}) + != len(frames) + or any(not _valid_descriptor(item) for item in frames) + ): + raise RuntimeError("L3.3 catalog is invalid") + return {"manifest": manifest, "catalog": catalog} + + +def _valid_descriptor(value: object) -> bool: + if not isinstance(value, dict): + return False + frame_id = value.get("frame_id") + return ( + isinstance(frame_id, str) + and FRAME_ID.fullmatch(frame_id) is not None + and value.get("detail_path") == f"frame-{frame_id}.json" + and isinstance(value.get("detail_sha256"), str) + and SHA256.fullmatch(value["detail_sha256"]) is not None + and isinstance(value.get("detail_byte_length"), int) + and 0 < value["detail_byte_length"] <= MAX_JSON_BYTES + and isinstance(value.get("camera_path"), str) + and value["camera_path"].startswith("frames/frame-") + and isinstance(value.get("camera_sha256"), str) + and SHA256.fullmatch(value["camera_sha256"]) is not None + and value.get("semantic_provenance") + in ( + "recorded-current", + "bounded-track-interpolation", + "rectified-detector-current", + "e23-temporal-hold", + ) + ) + + +def _valid_frame_payload(value: dict[str, Any]) -> bool: + projection = value.get("camera_projection") + camera = value.get("camera") + detections = value.get("detections") + prediction_boxes = value.get("prediction_boxes", []) + interpretation = value.get("interpretation") + return ( + isinstance(value.get("summary"), dict) + and isinstance(value.get("points"), dict) + and isinstance(projection, dict) + and projection.get("point_layout") == "flat-xy-depth-m" + and isinstance(projection.get("point_count"), int) + and isinstance(projection.get("point_values"), list) + and len(projection["point_values"]) == projection["point_count"] * 3 + and isinstance(camera, dict) + and camera.get("media_type") == "image/jpeg" + and camera.get("exact_source_frame") is True + and isinstance(detections, list) + and all(_valid_detection(item) for item in detections) + and isinstance(prediction_boxes, list) + and all(_valid_prediction_box(item) for item in prediction_boxes) + and isinstance(interpretation, dict) + and interpretation.get("camera_owns_semantic_identity") is True + and interpretation.get("lidar_owns_metric_range") is True + and interpretation.get("missing_lidar_erases_camera_detection") is False + and interpretation.get("ground_truth_available") is False + and interpretation.get("accuracy_claim_allowed") is False + ) + + +def _valid_detection(value: object) -> bool: + if not isinstance(value, dict): + return False + bbox = value.get("bbox_xyxy") + camera_ray = value.get("camera_ray_lidar") + geometry_anchor = value.get("geometry_anchor_xyz_m") + return ( + isinstance(value.get("source_track_id"), int) + and isinstance(value.get("label"), str) + and isinstance(value.get("score"), (int, float)) + and 0.25 <= float(value["score"]) <= 1.0 + and isinstance(bbox, list) + and len(bbox) == 4 + and all(isinstance(item, (int, float)) for item in bbox) + and value.get("semantic_provenance") + in ( + "recorded-current", + "bounded-track-interpolation", + "rectified-detector-current", + "e23-temporal-hold", + ) + and isinstance(value.get("geometry_status"), str) + and (value.get("range_m") is None or isinstance(value.get("range_m"), (int, float))) + and ( + value.get("source_e29_optical_depth_m") is None + or isinstance(value.get("source_e29_optical_depth_m"), (int, float)) + ) + and isinstance(camera_ray, dict) + and _valid_vector(camera_ray.get("origin_xyz_m"), 3) + and _valid_vector(camera_ray.get("direction_xyz"), 3) + and (geometry_anchor is None or _valid_vector(geometry_anchor, 3)) + and ((value.get("range_m") is None) == (geometry_anchor is None)) + ) + + +def _valid_prediction_box(value: object) -> bool: + if not isinstance(value, dict): + return False + return ( + isinstance(value.get("benchmark_class"), str) + and _valid_vector(value.get("center_xyz_m"), 3) + and _valid_vector(value.get("size_lwh_m"), 3) + and all(float(item) > 0 for item in value["size_lwh_m"]) + and isinstance(value.get("yaw_rad"), (int, float)) + and value.get("status") == "model-prediction" + and isinstance(value.get("score"), (int, float)) + and 0 <= float(value["score"]) <= 1 + and isinstance(value.get("track_id"), int) + and isinstance(value.get("temporal_status"), str) + ) + + +def _valid_vector(value: object, length: int) -> bool: + return ( + isinstance(value, list) + and len(value) == length + and all(isinstance(item, (int, float)) for item in value) + ) + + +def _configured_root(provider: RootProvider) -> Path | None: + configured = provider() + if configured is None: + return None + root = configured.expanduser().resolve() + return root if root.is_dir() and not root.is_symlink() else None + + +def _candidates(root: Path) -> list[Path]: + return [ + item for item in root.iterdir() + if item.is_dir() and not item.is_symlink() and RESULT_ID.fullmatch(item.name) + ] + + +def _empty_catalog(configured: bool) -> dict[str, object]: + return { + "schema_version": RESULT_CATALOG_SCHEMA, + "configured": configured, + "items": [], + "candidate_total": 0, + "invalid_total": 0, + "access": "read-only", + } + + +def _read_json(path: Path) -> dict[str, Any]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_JSON_BYTES: + raise RuntimeError("L3.3 JSON artifact is invalid") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError("L3.3 JSON artifact is invalid") from exc + if not isinstance(value, dict): + raise RuntimeError("L3.3 JSON artifact is invalid") + return value + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + +def _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() diff --git a/src/k1link/web/l34_annotation_api.py b/src/k1link/web/l34_annotation_api.py new file mode 100644 index 0000000..339aaf5 --- /dev/null +++ b/src/k1link/web/l34_annotation_api.py @@ -0,0 +1,1031 @@ +"""Immutable source and assisted human-review API for L3.4 annotations.""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +import threading +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Annotated, Any, Final, Literal + +from fastapi import APIRouter, HTTPException +from fastapi import Path as ApiPath +from pydantic import BaseModel, ConfigDict, Field + +from k1link.artifacts import write_json_atomic +from k1link.web.l34_right_yolox_truth_island_api import ( + RootProvider, + resolve_l34_frame_source, +) +from k1link.web.l34d_cumulative_postprocessing_api import ( + CandidateRootProvider, + resolve_l34d_case_source, +) + +L34_ANNOTATION_SOURCE_SCHEMA: Final = "missioncore.l34-annotation-source/v1" +L34_ANNOTATION_SOURCE_CATALOG_SCHEMA: Final = "missioncore.l34-annotation-source-catalog/v2" +L34_ANNOTATION_SESSION_SCHEMA: Final = "missioncore.l34-annotation-session/v3" +_LEGACY_L34_ANNOTATION_SESSION_SCHEMA_V2: Final = "missioncore.l34-annotation-session/v2" +_LEGACY_L34_ANNOTATION_SESSION_SCHEMA_V1: Final = "missioncore.l34-annotation-session/v1" +L34_ANNOTATION_SESSION_CATALOG_SCHEMA: Final = "missioncore.l34-annotation-session-catalog/v1" +L34_ANNOTATION_SEED_SCHEMA: Final = "missioncore.l34-annotation-seed/v1" +L34_ANNOTATION_LABEL_CATALOG_SCHEMA: Final = "missioncore.l34-annotation-label-catalog/v1" + +_RESULT_ID = re.compile(r"^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$") +_L34D_RESULT_ID = re.compile(r"^l34d-cumulative-postprocessing-candidate-[a-f0-9]{64}$") +_SESSION_ID = re.compile(r"^l34-annotation-session-[a-f0-9]{64}$") +_OBJECT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_TARGET_CLASSES: Final = ( + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", +) +_PREDICTION_FREE_BLINDNESS: Final = { + "candidate_identity_seen": False, + "model_prelabels_seen": False, + "model_predictions_seen": False, + "model_scores_seen": False, +} +_ASSISTED_BLINDNESS: Final = { + "candidate_identity_seen": True, + "model_prelabels_seen": True, + "model_predictions_seen": True, + "model_scores_seen": False, +} +_PREDICTION_FREE_ASSISTANCE: Final = { + "mode": "prediction-free-manual", + "independent_truth_eligible": False, +} +_FROZEN_CANDIDATE_ASSISTANCE: Final = { + "mode": "frozen-candidate-seeded", + "independent_truth_eligible": False, +} +_PREDICTION_HIDDEN_BLINDNESS: Final = { + "candidate_identity_seen": True, + "model_prelabels_seen": False, + "model_predictions_seen": False, + "model_scores_seen": False, +} +_PREDICTION_HIDDEN_ASSISTANCE: Final = { + "mode": "prediction-hidden-manual", + "independent_truth_eligible": False, +} +_AUTHORITY: Final = { + "ground_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, +} + +AnnotationRootProvider = Callable[[], Path | None] + + +def _contract_for_mode(mode: str) -> str: + if mode == "frozen-candidate-seeded": + return "l34-assisted-candidate-review/v1" + if mode == "prediction-hidden-manual": + return "l34d-prediction-hidden-review/v1" + if mode == "prediction-free-manual": + return "e46-detector-blind-review/v1" + raise ValueError("unknown annotation assistance mode") + + +def _blindness_for_mode(mode: str) -> dict[str, bool]: + if mode == "frozen-candidate-seeded": + return dict(_ASSISTED_BLINDNESS) + if mode == "prediction-hidden-manual": + return dict(_PREDICTION_HIDDEN_BLINDNESS) + if mode == "prediction-free-manual": + return dict(_PREDICTION_FREE_BLINDNESS) + raise ValueError("unknown annotation assistance mode") + + +def _assistance_for_mode(mode: str) -> dict[str, object]: + if mode == "frozen-candidate-seeded": + return dict(_FROZEN_CANDIDATE_ASSISTANCE) + if mode == "prediction-hidden-manual": + return dict(_PREDICTION_HIDDEN_ASSISTANCE) + if mode == "prediction-free-manual": + return dict(_PREDICTION_FREE_ASSISTANCE) + raise ValueError("unknown annotation assistance mode") + + +class L34AnnotationCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + idempotency_key: str = Field(min_length=1, max_length=128) + + +class L34AnnotationObjectRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + object_id: str = Field(min_length=1, max_length=64) + category: Literal[ + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", + "unmapped", + ] + proposed_label: str | None = Field(default=None, max_length=80) + origin: Literal["manual", "frozen_candidate_seed"] = "manual" + box_xyxy: list[float] = Field(min_length=4, max_length=4) + occluded: bool + truncated: bool + + +class L34AnnotationFrameRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + truth_island_sequence: int = Field(ge=1, le=32) + reviewed: Literal[True] + hard_negative: bool + objects: list[L34AnnotationObjectRequest] = Field(max_length=256) + + +class L34AnnotationSaveRequest(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) + assistance_mode: Literal[ + "prediction-free-manual", + "frozen-candidate-seeded", + "prediction-hidden-manual", + ] = "prediction-free-manual" + frames: list[L34AnnotationFrameRequest] = Field(max_length=32) + + +class _L34AnnotationSessionStore: + def __init__( + self, + provider: AnnotationRootProvider, + *, + result_id_pattern: re.Pattern[str] = _RESULT_ID, + lab_label: str = "L3.4", + initial_assistance_mode: str = "frozen-candidate-seeded", + allowed_assistance_modes: frozenset[str] = frozenset( + { + "prediction-free-manual", + "frozen-candidate-seeded", + } + ), + contract_id_overrides: dict[str, str] | None = None, + ) -> None: + self._provider = provider + self._result_id_pattern = result_id_pattern + self._lab_label = lab_label + self._initial_assistance_mode = initial_assistance_mode + self._allowed_assistance_modes = allowed_assistance_modes + self._contract_id_overrides = dict(contract_id_overrides or {}) + self._lock = threading.Lock() + + def _contract_id(self, mode: str) -> str: + overridden = self._contract_id_overrides.get(mode) + return overridden if overridden is not None else _contract_for_mode(mode) + + def list(self, result_id: str) -> list[dict[str, object]]: + root = self._result_root(result_id, create=False) + if root is None: + return [] + sessions: list[dict[str, object]] = [] + for path in sorted(root.glob("l34-annotation-session-*.json")): + if path.is_symlink() or _SESSION_ID.fullmatch(path.stem) is None: + continue + try: + document = self._read(path, result_id=result_id) + except (OSError, ValueError): + continue + sessions.append(_project_session(document, include_frames=False)) + sessions.sort( + key=lambda item: str(item["created_at_utc"]), + ) + return sessions + + def get(self, result_id: str, session_id: str) -> dict[str, object]: + path = self._session_path(result_id, session_id, create_root=False) + if path is None or not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="Сессия разметки не найдена") + try: + return _project_session( + self._read(path, result_id=result_id), + include_frames=True, + ) + except (OSError, ValueError) as exc: + raise HTTPException( + status_code=409, + detail="Сессия разметки не прошла проверку целостности", + ) from exc + + def session_path(self, result_id: str, session_id: str) -> Path: + """Resolve one existing immutable-source annotation session file.""" + + path = self._session_path(result_id, session_id, create_root=False) + if path is None or not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="Сессия разметки не найдена") + return path + + def labels(self, result_id: str) -> list[dict[str, str]]: + root = self._result_root(result_id, create=False) + if root is None: + return [] + labels: dict[str, str] = {} + for path in sorted(root.glob("l34-annotation-session-*.json")): + if path.is_symlink() or _SESSION_ID.fullmatch(path.stem) is None: + continue + try: + document = self._read(path, result_id=result_id) + except (OSError, ValueError): + continue + for frame in document["frames"]: + for item in frame["objects"]: + proposed = item.get("proposed_label") + if item.get("category") == "unmapped" and isinstance(proposed, str): + normalized = proposed.strip() + labels.setdefault(normalized.casefold(), normalized) + return [ + { + "value": label, + "normalization_state": "pending-adjudication", + } + for label in sorted(labels.values(), key=str.casefold) + ] + + def create( + self, + *, + result_id: str, + truth_island_id: str, + idempotency_key: str, + ) -> dict[str, object]: + if _IDEMPOTENCY_KEY.fullmatch(idempotency_key) is None: + raise HTTPException(status_code=422, detail="Некорректный ключ операции") + digest = hashlib.sha256(f"{result_id}\n{idempotency_key}".encode()).hexdigest() + session_id = f"l34-annotation-session-{digest}" + with self._lock: + path = self._session_path(result_id, session_id, create_root=True) + assert path is not None + if path.exists(): + try: + return _project_session( + self._read(path, result_id=result_id), + include_frames=True, + ) + except (OSError, ValueError) as exc: + raise HTTPException( + status_code=409, + detail="Сессия разметки не прошла проверку целостности", + ) from exc + reviewer_slot = len(self.list(result_id)) + 1 + now = _utc_now() + assistance = _assistance_for_mode(self._initial_assistance_mode) + document: dict[str, object] = { + "schema_version": L34_ANNOTATION_SESSION_SCHEMA, + "session_id": session_id, + "result_id": result_id, + "truth_island_id": truth_island_id, + "contract_id": self._contract_id(self._initial_assistance_mode), + "title": (f"Разметка · LAB {self._lab_label} · Рецензент {reviewer_slot}"), + "reviewer_slot": reviewer_slot, + "revision": 0, + "state": "draft", + "created_at_utc": now, + "updated_at_utc": now, + "frames": [], + "blindness": dict(_blindness_for_mode(self._initial_assistance_mode)), + "assistance": dict(assistance), + "authority": dict(_AUTHORITY), + "last_save_idempotency_key": None, + } + write_json_atomic(path, document) + return _project_session(document, include_frames=True) + + def save( + self, + *, + result_id: str, + session_id: str, + request: L34AnnotationSaveRequest, + frames: list[dict[str, object]], + ) -> dict[str, object]: + if _IDEMPOTENCY_KEY.fullmatch(request.idempotency_key) is None: + raise HTTPException(status_code=422, detail="Некорректный ключ операции") + if request.assistance_mode not in self._allowed_assistance_modes: + raise HTTPException(status_code=422, detail="Режим разметки запрещён") + path = self._session_path(result_id, session_id, create_root=False) + if path is None or not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="Сессия разметки не найдена") + with self._lock: + try: + current = self._read(path, result_id=result_id) + except (OSError, ValueError) as exc: + raise HTTPException( + status_code=409, + detail="Сессия разметки не прошла проверку целостности", + ) from exc + if current.get("last_save_idempotency_key") == request.idempotency_key: + return _project_session(current, include_frames=True) + if current.get("revision") != request.expected_revision: + raise HTTPException( + status_code=409, + detail="Сессия уже изменена в другом окне", + ) + current.update( + { + "schema_version": L34_ANNOTATION_SESSION_SCHEMA, + "title": request.title.strip(), + "revision": request.expected_revision + 1, + "state": "saved", + "updated_at_utc": _utc_now(), + "frames": frames, + "contract_id": self._contract_id(request.assistance_mode), + "blindness": dict(_blindness_for_mode(request.assistance_mode)), + "assistance": dict(_assistance_for_mode(request.assistance_mode)), + "last_save_idempotency_key": request.idempotency_key, + } + ) + write_json_atomic(path, current) + return _project_session(current, include_frames=True) + + def _result_root(self, result_id: str, *, create: bool) -> Path | None: + if self._result_id_pattern.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="L3.4 result не найден") + value = self._provider() + if value is None: + raise HTTPException(status_code=503, detail="Хранилище разметки не настроено") + root = value.expanduser().absolute() + if root.is_symlink(): + raise HTTPException(status_code=503, detail="Хранилище разметки недоступно") + if create: + root.mkdir(parents=True, exist_ok=True) + if not root.is_dir(): + return None + resolved = root.resolve(strict=True) + result_root = resolved / result_id + if create: + result_root.mkdir(exist_ok=True) + if not result_root.is_dir() or result_root.is_symlink(): + return None + candidate = result_root.resolve(strict=True) + if candidate.parent != resolved: + raise HTTPException(status_code=503, detail="Хранилище разметки недоступно") + return candidate + + def _session_path( + self, + result_id: str, + session_id: str, + *, + create_root: bool, + ) -> Path | None: + if _SESSION_ID.fullmatch(session_id) is None: + raise HTTPException(status_code=404, detail="Сессия разметки не найдена") + root = self._result_root(result_id, create=create_root) + return None if root is None else root / f"{session_id}.json" + + def _read(self, path: Path, *, result_id: str) -> dict[str, Any]: + document = json.loads(path.read_text(encoding="utf-8")) + schema = document.get("schema_version") if isinstance(document, dict) else None + if ( + not isinstance(document, dict) + or schema + not in { + L34_ANNOTATION_SESSION_SCHEMA, + _LEGACY_L34_ANNOTATION_SESSION_SCHEMA_V2, + _LEGACY_L34_ANNOTATION_SESSION_SCHEMA_V1, + } + or document.get("result_id") != result_id + or document.get("session_id") != path.stem + or _SESSION_ID.fullmatch(path.stem) is None + or not isinstance(document.get("revision"), int) + or isinstance(document.get("revision"), bool) + or int(document["revision"]) < 0 + or not isinstance(document.get("frames"), list) + or document.get("authority") != _AUTHORITY + ): + raise ValueError("invalid annotation session") + if schema == L34_ANNOTATION_SESSION_SCHEMA: + assistance = document.get("assistance") + if assistance not in ( + _FROZEN_CANDIDATE_ASSISTANCE, + _PREDICTION_FREE_ASSISTANCE, + _PREDICTION_HIDDEN_ASSISTANCE, + ): + raise ValueError("invalid annotation assistance") + assert isinstance(assistance, dict) + mode = str(assistance["mode"]) + if ( + mode not in self._allowed_assistance_modes + or document.get("blindness") != _blindness_for_mode(mode) + or document.get("contract_id") != self._contract_id(mode) + ): + raise ValueError("invalid annotation assistance") + elif document.get("blindness") != _PREDICTION_FREE_BLINDNESS: + raise ValueError("invalid legacy annotation blindness") + frames = document["frames"] + assert isinstance(frames, list) + for frame in frames: + if not isinstance(frame, dict) or not isinstance(frame.get("objects"), list): + raise ValueError("invalid annotation frame") + for item in frame["objects"]: + if not isinstance(item, dict): + raise ValueError("invalid annotation object") + if schema == _LEGACY_L34_ANNOTATION_SESSION_SCHEMA_V1: + item["proposed_label"] = None + if schema in { + _LEGACY_L34_ANNOTATION_SESSION_SCHEMA_V1, + _LEGACY_L34_ANNOTATION_SESSION_SCHEMA_V2, + }: + item["origin"] = "manual" + _validate_stored_object(item) + if schema != L34_ANNOTATION_SESSION_SCHEMA: + document["assistance"] = dict(_PREDICTION_FREE_ASSISTANCE) + document["schema_version"] = L34_ANNOTATION_SESSION_SCHEMA + return document + + +def build_l34_annotation_router( + *, + root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, + annotation_root_provider: AnnotationRootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/l34", tags=["laboratory"]) + store = _L34AnnotationSessionStore(annotation_root_provider) + + def source(result_id: str, sequence: int) -> tuple[Any, dict[str, Any], Path]: + result, _, reference, image_path = resolve_l34_frame_source( + root_provider=root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return result, reference, image_path + + @router.get("/results/{result_id}/annotation-source") + def get_annotation_source_catalog(result_id: str) -> dict[str, object]: + first, _, _ = source(result_id, 1) + sequences = [int(row["truth_island_sequence"]) for row in first.predictions] + frames: list[dict[str, object]] = [] + truth_island_id = str(first.manifest["identity"]["truth_island"]["result_id"]) + for sequence in sequences: + _, reference, image_path = source(result_id, sequence) + width, height = _png_dimensions(image_path) + frames.append(_source_projection(reference, width=width, height=height)) + return { + "schema_version": L34_ANNOTATION_SOURCE_CATALOG_SCHEMA, + "result_id": result_id, + "truth_island_id": truth_island_id, + "contract": _annotation_contract(), + "frames": frames, + "frame_count": len(frames), + "model_material_included": False, + "access": "annotation-source-read-only", + } + + @router.get("/results/{result_id}/annotation-source/frames/{sequence}") + def get_annotation_source_frame( + result_id: str, + sequence: int, + ) -> dict[str, object]: + _, reference, image_path = source(result_id, sequence) + width, height = _png_dimensions(image_path) + return { + "schema_version": L34_ANNOTATION_SOURCE_SCHEMA, + "result_id": result_id, + **_source_projection(reference, width=width, height=height), + "camera_url": ( + f"/api/v1/laboratory/l34/results/{result_id}" + f"/annotation-source/frames/{sequence}/camera" + ), + "model_material_included": False, + "access": "annotation-source-read-only", + } + + @router.get("/results/{result_id}/annotation-seed/frames/{sequence}") + def get_annotation_seed_frame( + result_id: str, + sequence: int, + ) -> dict[str, object]: + _, prediction, reference, image_path = resolve_l34_frame_source( + root_provider=root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + width, height = _png_dimensions(image_path) + return { + "schema_version": L34_ANNOTATION_SEED_SCHEMA, + "result_id": result_id, + "truth_island_sequence": sequence, + "source_sha256": str(reference["sha256"]), + "objects": _annotation_seed_objects( + prediction, + sequence=sequence, + width=width, + height=height, + ), + "assistance": dict(_FROZEN_CANDIDATE_ASSISTANCE), + "model_material_included": True, + "access": "assisted-annotation-seed-read-only", + } + + @router.get("/results/{result_id}/annotation-source/frames/{sequence}/camera") + def get_annotation_source_camera(result_id: str, sequence: int): + from fastapi.responses import FileResponse + + _, _, image_path = source(result_id, sequence) + return FileResponse(image_path, media_type="image/png") + + @router.get("/results/{result_id}/annotation-sessions") + def list_annotation_sessions(result_id: str) -> dict[str, object]: + source(result_id, 1) + items = store.list(result_id) + return { + "schema_version": L34_ANNOTATION_SESSION_CATALOG_SCHEMA, + "result_id": result_id, + "items": items, + "total": len(items), + "access": "human-annotation", + } + + @router.get("/results/{result_id}/annotation-labels") + def get_annotation_labels(result_id: str) -> dict[str, object]: + source(result_id, 1) + items = store.labels(result_id) + return { + "schema_version": L34_ANNOTATION_LABEL_CATALOG_SCHEMA, + "result_id": result_id, + "items": items, + "total": len(items), + "access": "assisted-annotation-taxonomy", + } + + @router.post("/results/{result_id}/annotation-sessions") + def create_annotation_session( + result_id: str, + request: L34AnnotationCreateRequest, + ) -> dict[str, object]: + result, _, _ = source(result_id, 1) + truth_island_id = str(result.manifest["identity"]["truth_island"]["result_id"]) + return store.create( + result_id=result_id, + truth_island_id=truth_island_id, + idempotency_key=request.idempotency_key, + ) + + @router.get("/results/{result_id}/annotation-sessions/{session_id}") + def get_annotation_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + ) -> dict[str, object]: + source(result_id, 1) + return store.get(result_id, session_id) + + @router.put("/results/{result_id}/annotation-sessions/{session_id}") + def save_annotation_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + request: L34AnnotationSaveRequest, + ) -> dict[str, object]: + sequences: set[int] = set() + normalized: list[dict[str, object]] = [] + for frame in request.frames: + sequence = frame.truth_island_sequence + if sequence in sequences: + raise HTTPException(status_code=422, detail="Кадр размечен дважды") + sequences.add(sequence) + _, reference, image_path = source(result_id, sequence) + width, height = _png_dimensions(image_path) + objects = [ + _normalize_object(item, width=width, height=height) for item in frame.objects + ] + if frame.hard_negative != (len(objects) == 0): + raise HTTPException( + status_code=422, + detail="Пустой кадр должен быть явно отмечен", + ) + normalized.append( + { + "truth_island_sequence": sequence, + "image_id": int(reference["image_id"]), + "frame_index": int(reference["frame_index"]), + "source_sha256": str(reference["sha256"]), + "reviewed": True, + "hard_negative": frame.hard_negative, + "objects": objects, + } + ) + normalized.sort(key=lambda item: int(item["truth_island_sequence"])) + return store.save( + result_id=result_id, + session_id=session_id, + request=request, + frames=normalized, + ) + + return router + + +def build_l34d_blind_annotation_router( + *, + candidate_root_provider: CandidateRootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, + annotation_root_provider: AnnotationRootProvider = lambda: None, +) -> APIRouter: + """Prediction-hidden manual labels bound to one immutable L3.4D candidate.""" + + router = APIRouter(prefix="/api/v1/laboratory/l34d", tags=["laboratory"]) + store = _L34AnnotationSessionStore( + annotation_root_provider, + result_id_pattern=_L34D_RESULT_ID, + lab_label="L3.4D", + initial_assistance_mode="prediction-hidden-manual", + allowed_assistance_modes=frozenset({"prediction-hidden-manual"}), + ) + + def source( + result_id: str, + sequence: int, + ) -> tuple[dict[str, Any], dict[str, Any], Any, dict[str, Any], Path]: + return resolve_l34d_case_source( + candidate_root_provider=candidate_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + + @router.get("/results/{result_id}/annotation-source") + def get_annotation_source_catalog(result_id: str) -> dict[str, object]: + result, _, l34_result, _, _ = source(result_id, 1) + frames: list[dict[str, object]] = [] + sequences = sorted(int(item["truth_island_sequence"]) for item in result["cases"]) + for sequence in sequences: + _, _, _, reference, image_path = source(result_id, sequence) + width, height = _png_dimensions(image_path) + frames.append(_source_projection(reference, width=width, height=height)) + truth_island_id = str(l34_result.manifest["identity"]["truth_island"]["result_id"]) + source_l34_result_id = str(result["manifest"]["identity"]["l34_freeze"]["result_id"]) + return { + "schema_version": L34_ANNOTATION_SOURCE_CATALOG_SCHEMA, + "result_id": result_id, + "truth_island_id": truth_island_id, + "contract": _annotation_contract(contract_id="l34d-prediction-hidden-review/v1"), + "candidate_binding": { + "result_id": result_id, + "source_l34_result_id": source_l34_result_id, + }, + "frames": frames, + "frame_count": len(frames), + "model_material_included": False, + "candidate_predictions_included": False, + "prelabels_included": False, + "access": "prediction-hidden-annotation-source-read-only", + } + + @router.get("/results/{result_id}/annotation-source/frames/{sequence}") + def get_annotation_source_frame( + result_id: str, + sequence: int, + ) -> dict[str, object]: + _, _, _, reference, image_path = source(result_id, sequence) + width, height = _png_dimensions(image_path) + return { + "schema_version": L34_ANNOTATION_SOURCE_SCHEMA, + "result_id": result_id, + **_source_projection(reference, width=width, height=height), + "camera_url": ( + f"/api/v1/laboratory/l34d/results/{result_id}" + f"/annotation-source/frames/{sequence}/camera" + ), + "model_material_included": False, + "candidate_predictions_included": False, + "prelabels_included": False, + "access": "prediction-hidden-annotation-source-read-only", + } + + @router.get("/results/{result_id}/annotation-source/frames/{sequence}/camera") + def get_annotation_source_camera(result_id: str, sequence: int): + from fastapi.responses import FileResponse + + _, _, _, _, image_path = source(result_id, sequence) + return FileResponse(image_path, media_type="image/png") + + @router.get("/results/{result_id}/annotation-sessions") + def list_annotation_sessions(result_id: str) -> dict[str, object]: + source(result_id, 1) + items = store.list(result_id) + return { + "schema_version": L34_ANNOTATION_SESSION_CATALOG_SCHEMA, + "result_id": result_id, + "items": items, + "total": len(items), + "access": "prediction-hidden-human-annotation", + } + + @router.get("/results/{result_id}/annotation-labels") + def get_annotation_labels(result_id: str) -> dict[str, object]: + source(result_id, 1) + items = store.labels(result_id) + return { + "schema_version": L34_ANNOTATION_LABEL_CATALOG_SCHEMA, + "result_id": result_id, + "items": items, + "total": len(items), + "access": "prediction-hidden-annotation-taxonomy", + } + + @router.post("/results/{result_id}/annotation-sessions") + def create_annotation_session( + result_id: str, + request: L34AnnotationCreateRequest, + ) -> dict[str, object]: + _, _, l34_result, _, _ = source(result_id, 1) + truth_island_id = str(l34_result.manifest["identity"]["truth_island"]["result_id"]) + return store.create( + result_id=result_id, + truth_island_id=truth_island_id, + idempotency_key=request.idempotency_key, + ) + + @router.get("/results/{result_id}/annotation-sessions/{session_id}") + def get_annotation_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + ) -> dict[str, object]: + source(result_id, 1) + return store.get(result_id, session_id) + + @router.put("/results/{result_id}/annotation-sessions/{session_id}") + def save_annotation_session( + result_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + request: L34AnnotationSaveRequest, + ) -> dict[str, object]: + if request.assistance_mode != "prediction-hidden-manual": + raise HTTPException(status_code=422, detail="Режим разметки запрещён") + sequences: set[int] = set() + normalized: list[dict[str, object]] = [] + for frame in request.frames: + sequence = frame.truth_island_sequence + if sequence in sequences: + raise HTTPException(status_code=422, detail="Кадр размечен дважды") + sequences.add(sequence) + _, _, _, reference, image_path = source(result_id, sequence) + width, height = _png_dimensions(image_path) + if any(item.origin != "manual" for item in frame.objects): + raise HTTPException( + status_code=422, + detail="Blind-сессия принимает только ручные рамки", + ) + objects = [ + _normalize_object(item, width=width, height=height) for item in frame.objects + ] + if frame.hard_negative != (len(objects) == 0): + raise HTTPException( + status_code=422, + detail="Пустой кадр должен быть явно отмечен", + ) + normalized.append( + { + "truth_island_sequence": sequence, + "image_id": int(reference["image_id"]), + "frame_index": int(reference["frame_index"]), + "source_sha256": str(reference["sha256"]), + "reviewed": True, + "hard_negative": frame.hard_negative, + "objects": objects, + } + ) + normalized.sort(key=lambda item: int(item["truth_island_sequence"])) + return store.save( + result_id=result_id, + session_id=session_id, + request=request, + frames=normalized, + ) + + return router + + +def _annotation_contract( + *, + contract_id: str = "e46-detector-blind-review/v1", +) -> dict[str, object]: + return { + "contract_id": contract_id, + "classes": list(_TARGET_CLASSES), + "unmapped_class": { + "value": "unmapped", + "proposed_label_required": True, + "normalization_state": "pending-adjudication", + }, + "box_format": "xyxy-pixels-800x600", + "all_identifiable_instances_required": True, + "hard_negative_frame_flag_required": True, + "occluded_and_truncated_flags_required": True, + } + + +def _source_projection( + reference: dict[str, Any], + *, + width: int, + height: int, +) -> dict[str, object]: + return { + "truth_island_sequence": int(reference["truth_island_sequence"]), + "image_id": int(reference["image_id"]), + "frame_index": int(reference["frame_index"]), + "group_id": str(reference["group_id"]), + "role": str(reference["role"]), + "session_seconds": float(reference["session_seconds"]), + "camera": { + "width": width, + "height": height, + "sha256": str(reference["sha256"]), + }, + } + + +def _normalize_object( + item: L34AnnotationObjectRequest, + *, + width: int, + height: int, +) -> dict[str, object]: + if _OBJECT_ID.fullmatch(item.object_id) is None: + raise HTTPException(status_code=422, detail="Некорректный id рамки") + if any(not math.isfinite(float(value)) for value in item.box_xyxy): + raise HTTPException(status_code=422, detail="Некорректная геометрия рамки") + left, top, right, bottom = (float(value) for value in item.box_xyxy) + if not (0 <= left < right <= width and 0 <= top < bottom <= height): + raise HTTPException(status_code=422, detail="Рамка выходит за границы кадра") + proposed_label = item.proposed_label.strip() if isinstance(item.proposed_label, str) else None + if item.category == "unmapped": + if not proposed_label: + raise HTTPException( + status_code=422, + detail="Для другого объекта требуется предложенное название", + ) + elif proposed_label is not None: + raise HTTPException( + status_code=422, + detail="Предложенное название допустимо только для другого объекта", + ) + return { + "object_id": item.object_id, + "category": item.category, + "proposed_label": proposed_label, + "origin": item.origin, + "box_xyxy": [left, top, right, bottom], + "occluded": item.occluded, + "truncated": item.truncated, + "notes": None, + } + + +def _validate_stored_object(item: dict[str, Any]) -> None: + category = item.get("category") + proposed_label = item.get("proposed_label") + if category == "unmapped": + if ( + not isinstance(proposed_label, str) + or not proposed_label.strip() + or len(proposed_label) > 80 + ): + raise ValueError("invalid unmapped annotation object") + elif category in _TARGET_CLASSES: + if proposed_label is not None: + raise ValueError("invalid canonical annotation object") + else: + raise ValueError("invalid annotation category") + if item.get("origin") not in {"manual", "frozen_candidate_seed"}: + raise ValueError("invalid annotation object origin") + + +def _annotation_seed_objects( + prediction: dict[str, Any], + *, + sequence: int, + width: int, + height: int, +) -> list[dict[str, object]]: + values = prediction.get("predictions") + if not isinstance(values, list): + raise HTTPException(status_code=404, detail="Предразметка кадра недоступна") + objects: list[dict[str, object]] = [] + for index, item in enumerate(values, start=1): + if not isinstance(item, dict): + raise HTTPException(status_code=404, detail="Предразметка кадра повреждена") + category = item.get("label") + score = item.get("score") + box = item.get("bbox_xyxy") + if ( + category not in _TARGET_CLASSES + or not isinstance(score, (int, float)) + or isinstance(score, bool) + or not math.isfinite(float(score)) + or not 0.25 <= float(score) <= 1.0 + or not isinstance(box, list) + or len(box) != 4 + or any( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + for value in box + ) + ): + raise HTTPException(status_code=404, detail="Предразметка кадра повреждена") + left, top, right, bottom = (float(value) for value in box) + if not (0 <= left < right <= width and 0 <= top < bottom <= height): + raise HTTPException(status_code=404, detail="Предразметка вышла за кадр") + objects.append( + { + "object_id": f"seed-{sequence}-{index}", + "category": category, + "proposed_label": None, + "origin": "frozen_candidate_seed", + "box_xyxy": [left, top, right, bottom], + "occluded": False, + "truncated": False, + } + ) + return objects + + +def _project_session( + document: dict[str, Any], + *, + include_frames: bool, +) -> dict[str, object]: + frames = document.get("frames") + if not isinstance(frames, list): + raise ValueError("invalid annotation frames") + object_count = sum( + len(frame.get("objects", [])) + for frame in frames + if isinstance(frame, dict) and isinstance(frame.get("objects"), list) + ) + projected: dict[str, object] = { + key: document[key] + for key in ( + "schema_version", + "session_id", + "result_id", + "truth_island_id", + "contract_id", + "title", + "reviewer_slot", + "revision", + "state", + "created_at_utc", + "updated_at_utc", + "blindness", + "assistance", + "authority", + ) + } + projected["progress"] = { + "reviewed_frame_count": len(frames), + "frame_count": 32, + "object_count": object_count, + "complete": len(frames) == 32, + } + if include_frames: + projected["frames"] = frames + return projected + + +def _png_dimensions(path: Path) -> tuple[int, int]: + try: + header = path.read_bytes()[:24] + except OSError as exc: + raise HTTPException(status_code=404, detail="Кадр разметки не найден") from exc + if len(header) != 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise HTTPException(status_code=404, detail="Кадр разметки повреждён") + return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big") + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/src/k1link/web/l34_right_yolox_truth_island_api.py b/src/k1link/web/l34_right_yolox_truth_island_api.py new file mode 100644 index 0000000..9c3886a --- /dev/null +++ b/src/k1link/web/l34_right_yolox_truth_island_api.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import math +import re +import struct +from collections.abc import Callable +from pathlib import Path, PurePosixPath +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.e46_detector_truth_island import ( + E46_MANIFEST_NAME, + E46_REFERENCES_NAME, + E46DetectorTruthIslandError, + read_e46_detector_truth_island, +) +from k1link.compute.l34_right_yolox_truth_island_freeze import ( + L34_MANIFEST_NAME, + L34_PREDICTION_SCHEMA, + L34RightYoloxTruthIsland, + L34RightYoloxTruthIslandError, + read_l34_right_yolox_truth_island_freeze, +) + +RootProvider = Callable[[], Path | None] +_RESULT_ID = re.compile(r"^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$") +_TRUTH_ID = re.compile(r"^e46-detector-truth-island-[a-f0-9]{64}$") +_PACK_ID = re.compile(r"^evaluation-pack-[a-f0-9]{64}$") +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_MAX_VISUAL_FRAMES = 32 + + +def latest_l34_identity(provider: RootProvider) -> dict[str, object] | None: + for candidate in _candidates(provider): + try: + result = read_l34_right_yolox_truth_island_freeze(candidate) + except L34RightYoloxTruthIslandError: + continue + created_at_utc = result.manifest.get("created_at_utc") + if not isinstance(created_at_utc, str) or not created_at_utc.strip(): + continue + return { + "result_id": result.result_id, + "created_at_utc": created_at_utc, + "access": "read-only", + } + return None + + +def build_l34_right_yolox_truth_island_freeze_router( + *, + root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/l34/results") + def list_l34_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in _candidates(root_provider): + try: + result = read_l34_right_yolox_truth_island_freeze(candidate) + items.append(_project(result)) + except L34RightYoloxTruthIslandError: + invalid_total += 1 + continue + if len(items) >= limit: + break + return { + "schema_version": ( + "missioncore.l34-right-yolox-truth-island-catalog/v1" + ), + "configured": _configured_root(root_provider) is not None, + "items": items, + "candidate_total": len(items), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/l34/results/{result_id}/frames/{sequence}") + def get_l34_frame(result_id: str, sequence: int) -> dict[str, object]: + result, prediction, reference, image_path = _resolve_visual_frame( + root_provider=root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + width, height = _png_dimensions(image_path) + predictions = _validated_predictions(prediction, width=width, height=height) + identity = result.manifest["identity"] + candidate = identity["candidate"] + return { + "schema_version": "missioncore.l34-right-yolox-truth-island-frame/v1", + "result_id": result.result_id, + "truth_island_sequence": sequence, + "image_id": prediction["image_id"], + "frame_index": prediction["frame_index"], + "group_id": prediction["group_id"], + "role": reference["role"], + "session_seconds": reference["session_seconds"], + "camera": { + "width": width, + "height": height, + "byte_length": image_path.stat().st_size, + "sha256": reference["sha256"], + }, + "prediction_rows_sha256": candidate["prediction_rows_sha256"], + "predictions": predictions, + "truth_labels_read": False, + "access": "read-only", + } + + @router.get("/l34/results/{result_id}/frames/{sequence}/camera") + def get_l34_camera(result_id: str, sequence: int) -> FileResponse: + _, _, _, image_path = _resolve_visual_frame( + root_provider=root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return FileResponse(image_path, media_type="image/png") + + return router + + +def _project(result: L34RightYoloxTruthIsland) -> dict[str, object]: + identity = result.manifest["identity"] + report = result.report + return { + "result_id": result.result_id, + "created_at_utc": result.manifest["created_at_utc"], + "status": report["status"], + "profile_id": report["profile_id"], + "pipeline_id": report["pipeline_id"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "candidate": { + **copy.deepcopy(identity["profile"]["candidate"]), + "l33_result_id": identity["candidate"]["l33_result_id"], + }, + "truth_island": { + "result_id": identity["truth_island"]["result_id"], + "truth_state": identity["truth_island"]["truth_state"], + }, + "metrics": copy.deepcopy(report["metrics"]), + "frames": [_frame_summary(row) for row in result.predictions], + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "access": "read-only", + } + + +def _frame_summary(row: dict[str, Any]) -> dict[str, object]: + sequence = _nonnegative_integer(row.get("truth_island_sequence"), "sequence") + image_id = _nonnegative_integer(row.get("image_id"), "image id") + frame_index = _nonnegative_integer(row.get("frame_index"), "frame index") + group_id = row.get("group_id") + predictions = row.get("predictions") + if ( + row.get("schema_version") != L34_PREDICTION_SCHEMA + or not 1 <= sequence <= _MAX_VISUAL_FRAMES + or not isinstance(group_id, str) + or not group_id.strip() + or not isinstance(predictions, list) + ): + raise L34RightYoloxTruthIslandError("L34 visual frame is invalid") + scores = [ + float(prediction["score"]) + for prediction in predictions + if isinstance(prediction, dict) + and isinstance(prediction.get("score"), (int, float)) + and not isinstance(prediction.get("score"), bool) + ] + if len(scores) != len(predictions) or any(not math.isfinite(score) for score in scores): + raise L34RightYoloxTruthIslandError("L34 visual scores are invalid") + return { + "truth_island_sequence": sequence, + "image_id": image_id, + "frame_index": frame_index, + "group_id": group_id, + "prediction_count": len(predictions), + "maximum_score": max(scores, default=0.0), + } + + +def _resolve_visual_frame( + *, + root_provider: RootProvider, + truth_root_provider: RootProvider, + evaluation_pack_root_provider: RootProvider, + result_id: str, + sequence: int, +) -> tuple[L34RightYoloxTruthIsland, dict[str, Any], dict[str, Any], Path]: + if _RESULT_ID.fullmatch(result_id) is None or not 1 <= sequence <= _MAX_VISUAL_FRAMES: + raise HTTPException(status_code=404, detail="L34 frame not found") + result_root = _configured_root(root_provider) + if result_root is None: + raise HTTPException(status_code=404, detail="L34 result not found") + result_path = (result_root / result_id).resolve() + if result_path.parent != result_root or result_path.is_symlink(): + raise HTTPException(status_code=404, detail="L34 result not found") + try: + result = read_l34_right_yolox_truth_island_freeze(result_path) + except (L34RightYoloxTruthIslandError, OSError): + raise HTTPException(status_code=404, detail="L34 result not found") from None + prediction = next( + ( + row + for row in result.predictions + if row.get("truth_island_sequence") == sequence + ), + None, + ) + if prediction is None: + raise HTTPException(status_code=404, detail="L34 frame not found") + + identity = result.manifest["identity"] + truth_identity = identity["truth_island"] + truth_id = truth_identity.get("result_id") + truth_root = _configured_root(truth_root_provider) + if ( + truth_root is None + or not isinstance(truth_id, str) + or _TRUTH_ID.fullmatch(truth_id) is None + ): + raise HTTPException(status_code=404, detail="L34 truth island not found") + truth_path = (truth_root / truth_id).resolve() + if truth_path.parent != truth_root or truth_path.is_symlink(): + raise HTTPException(status_code=404, detail="L34 truth island not found") + try: + truth = read_e46_detector_truth_island(truth_path) + except (E46DetectorTruthIslandError, OSError): + raise HTTPException(status_code=404, detail="L34 truth island not found") from None + if _sha256(truth.result_root / E46_MANIFEST_NAME) != truth_identity.get( + "manifest_sha256" + ): + raise HTTPException(status_code=404, detail="L34 truth island changed") + references = _read_jsonl(truth.result_root / E46_REFERENCES_NAME) + reference = next( + (row for row in references if row.get("truth_island_sequence") == sequence), + None, + ) + if ( + reference is None + or prediction.get("candidate_id") != "yolox-s-kb4-core3" + or prediction.get("truth_joined") is not False + or reference.get("frame_index") != prediction.get("frame_index") + or reference.get("image_id") != prediction.get("image_id") + or reference.get("group_id") != prediction.get("group_id") + or reference.get("session_seconds") + != prediction.get("session_seconds") + or reference.get("sha256") + != prediction.get("source_image_sha256") + or reference.get("role") not in {"anchor", "temporal"} + or not isinstance(reference.get("session_seconds"), (int, float)) + ): + raise HTTPException(status_code=404, detail="L34 frame binding is invalid") + + truth_source = truth.manifest["identity"]["source"] + pack_id = truth_source.get("evaluation_pack_id") + pack_root = _configured_root(evaluation_pack_root_provider) + if ( + pack_root is None + or not isinstance(pack_id, str) + or _PACK_ID.fullmatch(pack_id) is None + ): + raise HTTPException(status_code=404, detail="L34 image source not found") + pack_path = (pack_root / pack_id).resolve() + if pack_path.parent != pack_root or pack_path.is_symlink() or not pack_path.is_dir(): + raise HTTPException(status_code=404, detail="L34 image source not found") + relative = reference.get("source_path") + if not isinstance(relative, str): + raise HTTPException(status_code=404, detail="L34 image source not found") + pure = PurePosixPath(relative) + if ( + pure.is_absolute() + or ".." in pure.parts + or pure.parts[:2] != ("images", "valid-fov-fill") + or pure.suffix.lower() != ".png" + ): + raise HTTPException(status_code=404, detail="L34 image source not found") + image_path = (pack_path / Path(*pure.parts)).resolve() + if ( + not image_path.is_relative_to(pack_path) + or image_path.is_symlink() + or not image_path.is_file() + or image_path.stat().st_size != reference.get("byte_length") + or _sha256(image_path) != reference.get("sha256") + ): + raise HTTPException(status_code=404, detail="L34 image source changed") + return result, prediction, reference, image_path + + +def resolve_l34_frame_source( + *, + root_provider: RootProvider, + truth_root_provider: RootProvider, + evaluation_pack_root_provider: RootProvider, + result_id: str, + sequence: int, +) -> tuple[L34RightYoloxTruthIsland, dict[str, Any], dict[str, Any], Path]: + """Resolve one immutable L3.4 source frame for bounded sibling APIs. + + The caller receives the server-side binding. Prediction-free source routes + project only the source reference; explicitly assisted routes may project a + bounded, provenance-marked subset of ``prediction``. + """ + + return _resolve_visual_frame( + root_provider=root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + + +def _validated_predictions( + row: dict[str, Any], + *, + width: int, + height: int, +) -> list[dict[str, object]]: + predictions = row.get("predictions") + if not isinstance(predictions, list): + raise HTTPException(status_code=404, detail="L34 predictions not found") + projected: list[dict[str, object]] = [] + for prediction in predictions: + if not isinstance(prediction, dict): + raise HTTPException(status_code=404, detail="L34 prediction is invalid") + label = prediction.get("label") + score = prediction.get("score") + bbox = prediction.get("bbox_xyxy") + if ( + label not in { + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", + } + or not isinstance(score, (int, float)) + or isinstance(score, bool) + or not math.isfinite(float(score)) + or not 0.25 <= float(score) <= 1.0 + or not isinstance(bbox, list) + or len(bbox) != 4 + or any( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + for value in bbox + ) + ): + raise HTTPException(status_code=404, detail="L34 prediction is invalid") + left, top, right, bottom = (float(value) for value in bbox) + if not (0 <= left < right <= width and 0 <= top < bottom <= height): + raise HTTPException(status_code=404, detail="L34 prediction box is invalid") + projected.append( + { + "label": label, + "score": float(score), + "bbox_xyxy": [left, top, right, bottom], + } + ) + return projected + + +def _png_dimensions(path: Path) -> tuple[int, int]: + try: + with path.open("rb") as stream: + header = stream.read(24) + except OSError: + raise HTTPException(status_code=404, detail="L34 camera not found") from None + if len(header) != 24 or header[:8] != _PNG_SIGNATURE or header[12:16] != b"IHDR": + raise HTTPException(status_code=404, detail="L34 camera is invalid") + width, height = struct.unpack(">II", header[16:24]) + if not 1 <= width <= 8192 or not 1 <= height <= 8192: + raise HTTPException(status_code=404, detail="L34 camera is invalid") + return width, height + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + try: + rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] + except (json.JSONDecodeError, OSError): + raise HTTPException(status_code=404, detail="L34 frame index not found") from None + if not rows or len(rows) > _MAX_VISUAL_FRAMES or any(not isinstance(row, dict) for row in rows): + raise HTTPException(status_code=404, detail="L34 frame index is invalid") + return rows + + +def _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 _nonnegative_integer(value: object, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise L34RightYoloxTruthIslandError(f"L34 {label} is invalid") + return value + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + resolved = candidate.resolve(strict=True) + except OSError: + return None + return resolved if resolved.is_dir() else None + + +def _candidates(provider: RootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() + and not candidate.is_symlink() + and _RESULT_ID.fullmatch(candidate.name) is not None + and (candidate / L34_MANIFEST_NAME).is_file() + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/l34a_assisted_yolox_error_audit_api.py b/src/k1link/web/l34a_assisted_yolox_error_audit_api.py new file mode 100644 index 0000000..f913c4f --- /dev/null +++ b/src/k1link/web/l34a_assisted_yolox_error_audit_api.py @@ -0,0 +1,207 @@ +"""Read-only API for the L3.4A assisted YOLOX error audit.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.l34a_assisted_yolox_error_audit import ( + L34A_MANIFEST_NAME, + L34AAssistedYoloxErrorAuditError, + read_l34a_assisted_yolox_error_audit, +) +from k1link.web.l34_right_yolox_truth_island_api import ( + RootProvider, + resolve_l34_frame_source, +) + +_RESULT_ID = re.compile(r"^l34a-assisted-yolox-error-audit-[a-f0-9]{64}$") +AuditRootProvider = Callable[[], Path | None] + + +def build_l34a_assisted_yolox_error_audit_router( + *, + audit_root_provider: AuditRootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/l34a/results") + def list_l34a_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + items: list[dict[str, object]] = [] + invalid_total = 0 + candidates = _candidates(audit_root_provider) + for candidate in candidates: + try: + result = read_l34a_assisted_yolox_error_audit(candidate) + if len(items) < limit: + items.append(_project(result)) + except (L34AAssistedYoloxErrorAuditError, OSError, ValueError): + invalid_total += 1 + return { + "schema_version": "missioncore.l34a-assisted-yolox-error-catalog/v1", + "configured": _configured_root(audit_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/l34a/results/{result_id}/cases/{sequence}") + def get_l34a_case(result_id: str, sequence: int) -> dict[str, object]: + result, case, _, _ = _resolve_case( + audit_root_provider=audit_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return { + **copy.deepcopy(case), + "result_id": result["result_id"], + "ground_truth": False, + "access": "read-only", + } + + @router.get("/l34a/results/{result_id}/cases/{sequence}/camera") + def get_l34a_case_camera(result_id: str, sequence: int) -> FileResponse: + _, _, _, image_path = _resolve_case( + audit_root_provider=audit_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return FileResponse(image_path, media_type="image/png") + + return router + + +def _project(result: dict[str, Any]) -> dict[str, object]: + report = result["report"] + identity = result["manifest"]["identity"] + annotation = identity["assisted_annotation"] + return { + "result_id": result["result_id"], + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "profile_id": report["profile"]["profile_id"], + "pipeline_id": report["method"]["pipeline_id"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "assisted_annotation": copy.deepcopy(annotation), + "metrics": copy.deepcopy(report["metrics"]), + "cases": [_case_summary(case) for case in result["cases"]], + "case_order": copy.deepcopy(report["case_order"]), + "method": copy.deepcopy(report["method"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "ground_truth": False, + "access": "read-only", + } + + +def _case_summary(case: dict[str, Any]) -> dict[str, object]: + return { + "truth_island_sequence": case["truth_island_sequence"], + "image_id": case["image_id"], + "frame_index": case["frame_index"], + "group_id": case["group_id"], + "session_seconds": case["session_seconds"], + "source_image_sha256": case["source_image_sha256"], + "summary": copy.deepcopy(case["summary"]), + } + + +def _resolve_case( + *, + audit_root_provider: AuditRootProvider, + l34_root_provider: RootProvider, + truth_root_provider: RootProvider, + evaluation_pack_root_provider: RootProvider, + result_id: str, + sequence: int, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], Path]: + if _RESULT_ID.fullmatch(result_id) is None or not 1 <= sequence <= 32: + raise HTTPException(status_code=404, detail="L3.4A case not found") + root = _configured_root(audit_root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.4A result not found") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="L3.4A result not found") + try: + result = read_l34a_assisted_yolox_error_audit(path) + except (L34AAssistedYoloxErrorAuditError, OSError): + raise HTTPException(status_code=404, detail="L3.4A result not found") from None + case = next( + ( + item + for item in result["cases"] + if item.get("truth_island_sequence") == sequence + ), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="L3.4A case not found") + l34_result_id = result["manifest"]["identity"]["l34_freeze"]["result_id"] + _, prediction, reference, image_path = resolve_l34_frame_source( + root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=l34_result_id, + sequence=sequence, + ) + if ( + prediction.get("source_image_sha256") != case.get("source_image_sha256") + or reference.get("sha256") != case.get("source_image_sha256") + or prediction.get("image_id") != case.get("image_id") + or prediction.get("frame_index") != case.get("frame_index") + ): + raise HTTPException(status_code=404, detail="L3.4A case binding changed") + return result, case, prediction, image_path + + +def _configured_root(provider: AuditRootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: AuditRootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() + and not candidate.is_symlink() + and _RESULT_ID.fullmatch(candidate.name) is not None + and (candidate / L34A_MANIFEST_NAME).is_file() + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/l34b_nested_box_consolidation_api.py b/src/k1link/web/l34b_nested_box_consolidation_api.py new file mode 100644 index 0000000..02d5b32 --- /dev/null +++ b/src/k1link/web/l34b_nested_box_consolidation_api.py @@ -0,0 +1,202 @@ +"""Read-only API for the L3.4B nested-box consolidation shadow.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.l34b_nested_box_consolidation_shadow import ( + L34B_MANIFEST_NAME, + L34BNestedBoxConsolidationError, + read_l34b_nested_box_consolidation_shadow, +) +from k1link.web.l34_right_yolox_truth_island_api import ( + RootProvider, + resolve_l34_frame_source, +) + +_RESULT_ID = re.compile(r"^l34b-nested-box-consolidation-shadow-[a-f0-9]{64}$") +ShadowRootProvider = Callable[[], Path | None] + + +def build_l34b_nested_box_consolidation_router( + *, + shadow_root_provider: ShadowRootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/l34b/results") + def list_l34b_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + items: list[dict[str, object]] = [] + invalid_total = 0 + candidates = _candidates(shadow_root_provider) + for candidate in candidates: + try: + result = read_l34b_nested_box_consolidation_shadow(candidate) + if len(items) < limit: + items.append(_project(result)) + except (L34BNestedBoxConsolidationError, OSError, ValueError): + invalid_total += 1 + return { + "schema_version": "missioncore.l34b-nested-box-consolidation-catalog/v1", + "configured": _configured_root(shadow_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/l34b/results/{result_id}/cases/{sequence}") + def get_l34b_case(result_id: str, sequence: int) -> dict[str, object]: + result, case, _, _ = _resolve_case( + shadow_root_provider=shadow_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return { + **copy.deepcopy(case), + "result_id": result["result_id"], + "ground_truth": False, + "access": "read-only", + } + + @router.get("/l34b/results/{result_id}/cases/{sequence}/camera") + def get_l34b_case_camera(result_id: str, sequence: int) -> FileResponse: + _, _, _, image_path = _resolve_case( + shadow_root_provider=shadow_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return FileResponse(image_path, media_type="image/png") + + return router + + +def _project(result: dict[str, Any]) -> dict[str, object]: + report = result["report"] + return { + "result_id": result["result_id"], + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "profile": copy.deepcopy(report["profile"]), + "pipeline_id": report["method"]["pipeline_id"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "metrics": copy.deepcopy(report["metrics"]), + "cases": [_case_summary(case) for case in result["cases"]], + "case_order": copy.deepcopy(report["case_order"]), + "method": copy.deepcopy(report["method"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "ground_truth": False, + "access": "read-only", + } + + +def _case_summary(case: dict[str, Any]) -> dict[str, object]: + return { + "truth_island_sequence": case["truth_island_sequence"], + "image_id": case["image_id"], + "frame_index": case["frame_index"], + "group_id": case["group_id"], + "session_seconds": case["session_seconds"], + "source_image_sha256": case["source_image_sha256"], + "before_summary": copy.deepcopy(case["before_summary"]), + "after_summary": copy.deepcopy(case["after_summary"]), + "consolidation_count": len(case["consolidations"]), + } + + +def _resolve_case( + *, + shadow_root_provider: ShadowRootProvider, + l34_root_provider: RootProvider, + truth_root_provider: RootProvider, + evaluation_pack_root_provider: RootProvider, + result_id: str, + sequence: int, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], Path]: + if _RESULT_ID.fullmatch(result_id) is None or not 1 <= sequence <= 32: + raise HTTPException(status_code=404, detail="L3.4B case not found") + root = _configured_root(shadow_root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.4B result not found") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="L3.4B result not found") + try: + result = read_l34b_nested_box_consolidation_shadow(path) + except (L34BNestedBoxConsolidationError, OSError): + raise HTTPException(status_code=404, detail="L3.4B result not found") from None + case = next( + (item for item in result["cases"] if item.get("truth_island_sequence") == sequence), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="L3.4B case not found") + l34_result_id = result["manifest"]["identity"]["l34_freeze"]["result_id"] + _, prediction, reference, image_path = resolve_l34_frame_source( + root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=l34_result_id, + sequence=sequence, + ) + if ( + prediction.get("source_image_sha256") != case.get("source_image_sha256") + or reference.get("sha256") != case.get("source_image_sha256") + or prediction.get("image_id") != case.get("image_id") + or prediction.get("frame_index") != case.get("frame_index") + ): + raise HTTPException(status_code=404, detail="L3.4B case binding changed") + return result, case, prediction, image_path + + +def _configured_root(provider: ShadowRootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: ShadowRootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() + and not candidate.is_symlink() + and _RESULT_ID.fullmatch(candidate.name) is not None + and (candidate / L34B_MANIFEST_NAME).is_file() + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/l34c_tile_seam_stitch_api.py b/src/k1link/web/l34c_tile_seam_stitch_api.py new file mode 100644 index 0000000..7b642c9 --- /dev/null +++ b/src/k1link/web/l34c_tile_seam_stitch_api.py @@ -0,0 +1,206 @@ +"""Read-only API for the L3.4C temporal tile-seam stitch shadow.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.l34c_tile_seam_stitch_shadow import ( + L34C_MANIFEST_NAME, + L34CTileSeamStitchError, + read_l34c_tile_seam_stitch_shadow, +) +from k1link.web.l34_right_yolox_truth_island_api import ( + RootProvider, + resolve_l34_frame_source, +) + +_RESULT_ID = re.compile(r"^l34c-tile-seam-stitch-shadow-[a-f0-9]{64}$") +ShadowRootProvider = Callable[[], Path | None] + + +def build_l34c_tile_seam_stitch_router( + *, + shadow_root_provider: ShadowRootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/l34c/results") + def list_l34c_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + items: list[dict[str, object]] = [] + invalid_total = 0 + candidates = _candidates(shadow_root_provider) + for candidate in candidates: + try: + result = read_l34c_tile_seam_stitch_shadow(candidate) + if len(items) < limit: + items.append(_project(result)) + except (L34CTileSeamStitchError, OSError, ValueError): + invalid_total += 1 + return { + "schema_version": "missioncore.l34c-tile-seam-stitch-catalog/v1", + "configured": _configured_root(shadow_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/l34c/results/{result_id}/cases/{sequence}") + def get_l34c_case(result_id: str, sequence: int) -> dict[str, object]: + result, case, _, _ = _resolve_case( + shadow_root_provider=shadow_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return { + **copy.deepcopy(case), + "result_id": result["result_id"], + "ground_truth": False, + "access": "read-only", + } + + @router.get("/l34c/results/{result_id}/cases/{sequence}/camera") + def get_l34c_case_camera(result_id: str, sequence: int) -> FileResponse: + _, _, _, image_path = _resolve_case( + shadow_root_provider=shadow_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return FileResponse(image_path, media_type="image/png") + + return router + + +def _project(result: dict[str, Any]) -> dict[str, object]: + report = result["report"] + return { + "result_id": result["result_id"], + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "profile": copy.deepcopy(report["profile"]), + "pipeline_id": report["method"]["pipeline_id"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "metrics": copy.deepcopy(report["metrics"]), + "cases": [_case_summary(case) for case in result["cases"]], + "case_order": copy.deepcopy(report["case_order"]), + "method": copy.deepcopy(report["method"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "ground_truth": False, + "access": "read-only", + } + + +def _case_summary(case: dict[str, Any]) -> dict[str, object]: + return { + "truth_island_sequence": case["truth_island_sequence"], + "image_id": case["image_id"], + "frame_index": case["frame_index"], + "group_id": case["group_id"], + "session_seconds": case["session_seconds"], + "source_image_sha256": case["source_image_sha256"], + "before_summary": copy.deepcopy(case["before_summary"]), + "after_summary": copy.deepcopy(case["after_summary"]), + "stitch_count": len(case["stitches"]), + } + + +def _resolve_case( + *, + shadow_root_provider: ShadowRootProvider, + l34_root_provider: RootProvider, + truth_root_provider: RootProvider, + evaluation_pack_root_provider: RootProvider, + result_id: str, + sequence: int, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], Path]: + if _RESULT_ID.fullmatch(result_id) is None or not 1 <= sequence <= 32: + raise HTTPException(status_code=404, detail="L3.4C case not found") + root = _configured_root(shadow_root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.4C result not found") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="L3.4C result not found") + try: + result = read_l34c_tile_seam_stitch_shadow(path) + except (L34CTileSeamStitchError, OSError): + raise HTTPException(status_code=404, detail="L3.4C result not found") from None + case = next( + ( + item + for item in result["cases"] + if item.get("truth_island_sequence") == sequence + ), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="L3.4C case not found") + l34_result_id = result["manifest"]["identity"]["l34_freeze"]["result_id"] + _, prediction, reference, image_path = resolve_l34_frame_source( + root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=l34_result_id, + sequence=sequence, + ) + if ( + prediction.get("source_image_sha256") != case.get("source_image_sha256") + or reference.get("sha256") != case.get("source_image_sha256") + or prediction.get("image_id") != case.get("image_id") + or prediction.get("frame_index") != case.get("frame_index") + ): + raise HTTPException(status_code=404, detail="L3.4C case binding changed") + return result, case, prediction, image_path + + +def _configured_root(provider: ShadowRootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: ShadowRootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() + and not candidate.is_symlink() + and _RESULT_ID.fullmatch(candidate.name) is not None + and (candidate / L34C_MANIFEST_NAME).is_file() + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/l34d_cumulative_postprocessing_api.py b/src/k1link/web/l34d_cumulative_postprocessing_api.py new file mode 100644 index 0000000..0bcbc78 --- /dev/null +++ b/src/k1link/web/l34d_cumulative_postprocessing_api.py @@ -0,0 +1,207 @@ +"""Read-only API for the L3.4D cumulative post-processing candidate.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.l34d_cumulative_postprocessing_candidate import ( + L34D_MANIFEST_NAME, + L34DCumulativeCandidateError, + read_l34d_cumulative_postprocessing_candidate, +) +from k1link.web.l34_right_yolox_truth_island_api import ( + RootProvider, + resolve_l34_frame_source, +) + +_RESULT_ID = re.compile(r"^l34d-cumulative-postprocessing-candidate-[a-f0-9]{64}$") +CandidateRootProvider = Callable[[], Path | None] + + +def build_l34d_cumulative_postprocessing_router( + *, + candidate_root_provider: CandidateRootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/l34d/results") + def list_l34d_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + items: list[dict[str, object]] = [] + invalid_total = 0 + candidates = _candidates(candidate_root_provider) + for candidate in candidates: + try: + result = read_l34d_cumulative_postprocessing_candidate(candidate) + if len(items) < limit: + items.append(_project(result)) + except (L34DCumulativeCandidateError, OSError, ValueError): + invalid_total += 1 + return { + "schema_version": ("missioncore.l34d-cumulative-postprocessing-catalog/v1"), + "configured": _configured_root(candidate_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/l34d/results/{result_id}/cases/{sequence}") + def get_l34d_case(result_id: str, sequence: int) -> dict[str, object]: + result, case, _, _, _ = resolve_l34d_case_source( + candidate_root_provider=candidate_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return { + **copy.deepcopy(case), + "result_id": result["result_id"], + "ground_truth": False, + "access": "read-only", + } + + @router.get("/l34d/results/{result_id}/cases/{sequence}/camera") + def get_l34d_case_camera(result_id: str, sequence: int) -> FileResponse: + _, _, _, _, image_path = resolve_l34d_case_source( + candidate_root_provider=candidate_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return FileResponse(image_path, media_type="image/png") + + return router + + +def _project(result: dict[str, Any]) -> dict[str, object]: + report = result["report"] + return { + "result_id": result["result_id"], + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "profile": copy.deepcopy(report["profile"]), + "pipeline_id": report["method"]["pipeline_id"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "metrics": copy.deepcopy(report["metrics"]), + "cases": [_case_summary(case) for case in result["cases"]], + "case_order": copy.deepcopy(report["case_order"]), + "method": copy.deepcopy(report["method"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "ground_truth": False, + "access": "read-only", + } + + +def _case_summary(case: dict[str, Any]) -> dict[str, object]: + operations = case["operations"] + return { + "truth_island_sequence": case["truth_island_sequence"], + "image_id": case["image_id"], + "frame_index": case["frame_index"], + "group_id": case["group_id"], + "session_seconds": case["session_seconds"], + "source_image_sha256": case["source_image_sha256"], + "before_summary": copy.deepcopy(case["before_summary"]), + "after_summary": copy.deepcopy(case["after_summary"]), + "operation_count": len(operations), + "operation_types": [item["operation_type"] for item in operations], + } + + +def resolve_l34d_case_source( + *, + candidate_root_provider: CandidateRootProvider, + l34_root_provider: RootProvider, + truth_root_provider: RootProvider, + evaluation_pack_root_provider: RootProvider, + result_id: str, + sequence: int, +) -> tuple[dict[str, Any], dict[str, Any], Any, dict[str, Any], Path]: + if _RESULT_ID.fullmatch(result_id) is None or not 1 <= sequence <= 32: + raise HTTPException(status_code=404, detail="L3.4D case not found") + root = _configured_root(candidate_root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.4D result not found") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="L3.4D result not found") + try: + result = read_l34d_cumulative_postprocessing_candidate(path) + except (L34DCumulativeCandidateError, OSError): + raise HTTPException( + status_code=404, + detail="L3.4D result not found", + ) from None + case = next( + (item for item in result["cases"] if item.get("truth_island_sequence") == sequence), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="L3.4D case not found") + l34_result_id = result["manifest"]["identity"]["l34_freeze"]["result_id"] + l34_result, prediction, reference, image_path = resolve_l34_frame_source( + root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=l34_result_id, + sequence=sequence, + ) + if ( + prediction.get("source_image_sha256") != case.get("source_image_sha256") + or reference.get("sha256") != case.get("source_image_sha256") + or prediction.get("image_id") != case.get("image_id") + or prediction.get("frame_index") != case.get("frame_index") + ): + raise HTTPException(status_code=404, detail="L3.4D case binding changed") + return result, case, l34_result, reference, image_path + + +def _configured_root(provider: CandidateRootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: CandidateRootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() + and not candidate.is_symlink() + and _RESULT_ID.fullmatch(candidate.name) is not None + and (candidate / L34D_MANIFEST_NAME).is_file() + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/l34e_self_review_diagnostic_api.py b/src/k1link/web/l34e_self_review_diagnostic_api.py new file mode 100644 index 0000000..c545d23 --- /dev/null +++ b/src/k1link/web/l34e_self_review_diagnostic_api.py @@ -0,0 +1,219 @@ +"""Read-only API for the L3.4E manual self-review diagnostic.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.compute.l34e_self_review_diagnostic import ( + L34E_MANIFEST_NAME, + L34ESelfReviewDiagnosticError, + read_l34e_self_review_diagnostic, +) +from k1link.web.l34_right_yolox_truth_island_api import RootProvider +from k1link.web.l34d_cumulative_postprocessing_api import ( + CandidateRootProvider, + resolve_l34d_case_source, +) + +_RESULT_ID = re.compile(r"^l34e-self-review-diagnostic-[a-f0-9]{64}$") +DiagnosticRootProvider = Callable[[], Path | None] + + +def build_l34e_self_review_diagnostic_router( + *, + diagnostic_root_provider: DiagnosticRootProvider = lambda: None, + candidate_root_provider: CandidateRootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/l34e/results") + def list_l34e_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + items: list[dict[str, object]] = [] + invalid_total = 0 + candidates = _candidates(diagnostic_root_provider) + for candidate in candidates: + try: + result = read_l34e_self_review_diagnostic(candidate) + if len(items) < limit: + items.append(_project(result)) + except (L34ESelfReviewDiagnosticError, OSError, ValueError): + invalid_total += 1 + return { + "schema_version": "missioncore.l34e-self-review-diagnostic-catalog/v1", + "configured": _configured_root(diagnostic_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/l34e/results/{result_id}/cases/{sequence}") + def get_l34e_case(result_id: str, sequence: int) -> dict[str, object]: + result, case, _ = resolve_l34e_case_source( + diagnostic_root_provider=diagnostic_root_provider, + candidate_root_provider=candidate_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return { + **copy.deepcopy(case), + "result_id": result["result_id"], + "ground_truth": False, + "access": "read-only", + } + + @router.get("/l34e/results/{result_id}/cases/{sequence}/camera") + def get_l34e_case_camera(result_id: str, sequence: int) -> FileResponse: + _, _, image_path = resolve_l34e_case_source( + diagnostic_root_provider=diagnostic_root_provider, + candidate_root_provider=candidate_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + return FileResponse(image_path, media_type="image/png") + + return router + + +def resolve_l34e_case_source( + *, + diagnostic_root_provider: DiagnosticRootProvider, + candidate_root_provider: CandidateRootProvider, + l34_root_provider: RootProvider, + truth_root_provider: RootProvider, + evaluation_pack_root_provider: RootProvider, + result_id: str, + sequence: int, +) -> tuple[dict[str, Any], dict[str, Any], Path]: + if _RESULT_ID.fullmatch(result_id) is None or not 1 <= sequence <= 32: + raise HTTPException(status_code=404, detail="L3.4E case not found") + root = _configured_root(diagnostic_root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.4E result not found") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="L3.4E result not found") + try: + result = read_l34e_self_review_diagnostic(path) + except (L34ESelfReviewDiagnosticError, OSError): + raise HTTPException( + status_code=404, + detail="L3.4E result not found", + ) from None + case = next( + ( + item + for item in result["cases"] + if item.get("truth_island_sequence") == sequence + ), + None, + ) + if case is None: + raise HTTPException(status_code=404, detail="L3.4E case not found") + l34d_result_id = result["manifest"]["identity"]["l34d_candidate"][ + "result_id" + ] + _, l34d_case, _, _, image_path = resolve_l34d_case_source( + candidate_root_provider=candidate_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=l34d_result_id, + sequence=sequence, + ) + if any( + case.get(key) != l34d_case.get(key) + for key in ( + "truth_island_sequence", + "image_id", + "frame_index", + "source_image_sha256", + ) + ): + raise HTTPException(status_code=404, detail="L3.4E case binding changed") + return result, case, image_path + + +def _project(result: dict[str, Any]) -> dict[str, object]: + report = result["report"] + return { + "result_id": result["result_id"], + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "profile": copy.deepcopy(report["profile"]), + "pipeline_id": report["method"]["pipeline_id"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "metrics": copy.deepcopy(report["metrics"]), + "cases": [_case_summary(case) for case in result["cases"]], + "case_order": copy.deepcopy(report["case_order"]), + "method": copy.deepcopy(report["method"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "ground_truth": False, + "access": "read-only", + } + + +def _case_summary(case: dict[str, Any]) -> dict[str, object]: + return { + "truth_island_sequence": case["truth_island_sequence"], + "image_id": case["image_id"], + "frame_index": case["frame_index"], + "group_id": case["group_id"], + "session_seconds": case["session_seconds"], + "source_image_sha256": case["source_image_sha256"], + "strict_summary": copy.deepcopy(case["strict_summary"]), + "diagnostic_summary": copy.deepcopy(case["diagnostic_summary"]), + } + + +def _configured_root(provider: DiagnosticRootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + return root if root.is_dir() else None + + +def _candidates(provider: DiagnosticRootProvider) -> list[Path]: + root = _configured_root(provider) + if root is None: + return [] + return sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() + and not candidate.is_symlink() + and _RESULT_ID.fullmatch(candidate.name) is not None + and (candidate / L34E_MANIFEST_NAME).is_file() + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) diff --git a/src/k1link/web/l34f_adjudication_api.py b/src/k1link/web/l34f_adjudication_api.py new file mode 100644 index 0000000..fe6e1f8 --- /dev/null +++ b/src/k1link/web/l34f_adjudication_api.py @@ -0,0 +1,601 @@ +"""Revisioned L3.4F disagreement adjudication and immutable freeze API.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import math +import re +import threading +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Annotated, Any, Final, Literal + +from fastapi import APIRouter, HTTPException, Query +from fastapi import Path as ApiPath +from fastapi.responses import FileResponse +from pydantic import BaseModel, ConfigDict, Field + +from k1link.artifacts import write_json_atomic +from k1link.compute.l34e_self_review_diagnostic import ( + L34ESelfReviewDiagnosticError, + read_l34e_self_review_diagnostic, +) +from k1link.compute.l34f_adjudicated_reference import ( + L34F_MANIFEST_NAME, + L34F_SESSION_SCHEMA, + L34FAdjudicatedReferenceError, + build_l34f_adjudicated_reference, + read_l34f_adjudicated_reference, +) +from k1link.web.l34_right_yolox_truth_island_api import RootProvider +from k1link.web.l34d_cumulative_postprocessing_api import CandidateRootProvider +from k1link.web.l34e_self_review_diagnostic_api import ( + DiagnosticRootProvider, + resolve_l34e_case_source, +) + +_DIAGNOSTIC_ID = re.compile(r"^l34e-self-review-diagnostic-[a-f0-9]{64}$") +_SESSION_ID = re.compile(r"^l34f-adjudication-session-[a-f0-9]{64}$") +_RESULT_ID = re.compile(r"^l34f-adjudicated-reference-[a-f0-9]{64}$") +_OBJECT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$") +_OPERATION_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_TARGET_CLASSES: Final = ( + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", +) +_AUTHORITY: Final = { + "ground_truth": False, + "independent_truth": False, + "metric_grade_reference": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, +} +_BLINDNESS: Final = { + "candidate_identity_seen": True, + "model_prelabels_seen": False, + "model_predictions_seen": True, + "model_scores_seen": False, + "source_self_review_seen": True, +} +_ASSISTANCE: Final = { + "mode": "candidate-visible-human-adjudication", + "independent_truth_eligible": False, + "metric_grade_reference": False, +} + +AdjudicationRootProvider = Callable[[], Path | None] +ResultRootProvider = Callable[[], Path | None] + + +class L34FCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + idempotency_key: str = Field(min_length=1, max_length=128) + + +class L34FObjectRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + object_id: str = Field(min_length=1, max_length=96) + category: Literal[ + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", + "unmapped", + ] + proposed_label: str | None = Field(default=None, max_length=80) + origin: Literal["self_review_seed", "adjudicated_manual"] + box_xyxy: list[float] = Field(min_length=4, max_length=4) + occluded: bool + truncated: bool + + +class L34FFrameRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + truth_island_sequence: int = Field(ge=1, le=32) + reviewed: bool + objects: list[L34FObjectRequest] = Field(max_length=256) + + +class L34FSaveRequest(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) + frames: list[L34FFrameRequest] = Field(min_length=32, max_length=32) + + +class L34FFreezeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_revision: int = Field(ge=1) + + +class _Store: + def __init__(self, provider: AdjudicationRootProvider) -> None: + self.provider = provider + self.lock = threading.Lock() + + def list(self, diagnostic_id: str) -> list[dict[str, object]]: + root = self._diagnostic_root(diagnostic_id, create=False) + if root is None: + return [] + items: list[dict[str, object]] = [] + for path in sorted(root.glob("l34f-adjudication-session-*.json")): + if path.is_symlink() or _SESSION_ID.fullmatch(path.stem) is None: + continue + try: + items.append(_project_session(self._read(path, diagnostic_id), False)) + except (OSError, ValueError): + continue + items.sort(key=lambda item: str(item["created_at_utc"])) + return items + + def create(self, diagnostic: dict[str, Any], key: str) -> dict[str, object]: + diagnostic_id = diagnostic["result_id"] + _validate_operation_key(key) + digest = hashlib.sha256(f"{diagnostic_id}\n{key}".encode()).hexdigest() + session_id = f"l34f-adjudication-session-{digest}" + with self.lock: + path = self._session_path(diagnostic_id, session_id, create=True) + assert path is not None + if path.exists(): + return _project_session(self._read(path, diagnostic_id), True) + now = _utc_now() + frames = [] + for case in sorted(diagnostic["cases"], key=lambda row: row["truth_island_sequence"]): + frames.append( + { + "truth_island_sequence": int(case["truth_island_sequence"]), + "image_id": int(case["image_id"]), + "frame_index": int(case["frame_index"]), + "source_sha256": str(case["source_image_sha256"]), + "reviewed": False, + "objects": [ + { + "object_id": str(item["object_id"]), + "category": str(item["category"]), + "proposed_label": item.get("proposed_label"), + "origin": "self_review_seed", + "box_xyxy": [float(value) for value in item["box_xyxy"]], + "occluded": bool(item["occluded"]), + "truncated": bool(item["truncated"]), + } + for item in case["references"] + ], + } + ) + reviewer_slot = len(self.list(diagnostic_id)) + 1 + document: dict[str, object] = { + "schema_version": L34F_SESSION_SCHEMA, + "session_id": session_id, + "diagnostic_result_id": diagnostic_id, + "source_session_id": str(diagnostic["report"]["source_session_id"]), + "source_review_session_id": str( + diagnostic["manifest"]["identity"]["self_review"]["session_id"] + ), + "contract_id": "l34e-candidate-visible-adjudication/v1", + "title": f"Разбор конфликтов · LAB L3.4F · Рецензент {reviewer_slot}", + "reviewer_slot": reviewer_slot, + "revision": 0, + "state": "draft", + "created_at_utc": now, + "updated_at_utc": now, + "frames": frames, + "blindness": dict(_BLINDNESS), + "assistance": dict(_ASSISTANCE), + "authority": dict(_AUTHORITY), + "last_save_idempotency_key": None, + } + write_json_atomic(path, document) + return _project_session(document, True) + + def get(self, diagnostic_id: str, session_id: str) -> dict[str, object]: + path = self._session_path(diagnostic_id, session_id, create=False) + if path is None or not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="Сессия adjudication не найдена") + try: + return _project_session(self._read(path, diagnostic_id), True) + except (OSError, ValueError) as exc: + raise HTTPException(status_code=409, detail="Сессия adjudication повреждена") from exc + + def save( + self, + diagnostic: dict[str, Any], + session_id: str, + request: L34FSaveRequest, + ) -> dict[str, object]: + diagnostic_id = diagnostic["result_id"] + _validate_operation_key(request.idempotency_key) + path = self._session_path(diagnostic_id, session_id, create=False) + if path is None or not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="Сессия adjudication не найдена") + source_cases = {int(case["truth_island_sequence"]): case for case in diagnostic["cases"]} + seen: set[int] = set() + frames: list[dict[str, object]] = [] + for frame in request.frames: + if frame.truth_island_sequence in seen: + raise HTTPException(status_code=422, detail="Кадр передан дважды") + seen.add(frame.truth_island_sequence) + source = source_cases[frame.truth_island_sequence] + frames.append( + { + "truth_island_sequence": frame.truth_island_sequence, + "image_id": int(source["image_id"]), + "frame_index": int(source["frame_index"]), + "source_sha256": str(source["source_image_sha256"]), + "reviewed": frame.reviewed, + "objects": [_normalize_object(item) for item in frame.objects], + } + ) + if seen != set(range(1, 33)): + raise HTTPException(status_code=422, detail="Требуются все 32 кадра") + frames.sort(key=lambda item: int(item["truth_island_sequence"])) + with self.lock: + current = self._read(path, diagnostic_id) + if current.get("last_save_idempotency_key") == request.idempotency_key: + return _project_session(current, True) + if current["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="Сессия уже изменена в другом окне") + current.update( + { + "title": request.title.strip(), + "revision": request.expected_revision + 1, + "state": "saved", + "updated_at_utc": _utc_now(), + "frames": frames, + "last_save_idempotency_key": request.idempotency_key, + } + ) + write_json_atomic(path, current) + return _project_session(current, True) + + def session_path(self, diagnostic_id: str, session_id: str) -> Path: + path = self._session_path(diagnostic_id, session_id, create=False) + if path is None or not path.is_file() or path.is_symlink(): + raise HTTPException(status_code=404, detail="Сессия adjudication не найдена") + return path + + def _diagnostic_root(self, diagnostic_id: str, *, create: bool) -> Path | None: + if _DIAGNOSTIC_ID.fullmatch(diagnostic_id) is None: + raise HTTPException(status_code=404, detail="L3.4E result не найден") + value = self.provider() + if value is None: + raise HTTPException(status_code=503, detail="Хранилище adjudication не настроено") + root = value.expanduser().absolute() + if root.is_symlink(): + raise HTTPException(status_code=503, detail="Хранилище adjudication недоступно") + if create: + root.mkdir(mode=0o700, parents=True, exist_ok=True) + if not root.is_dir(): + return None + root = root.resolve(strict=True) + child = root / diagnostic_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="Хранилище adjudication недоступно") + return resolved + + def _session_path(self, diagnostic_id: str, session_id: str, *, create: bool) -> Path | None: + if _SESSION_ID.fullmatch(session_id) is None: + raise HTTPException(status_code=404, detail="Сессия adjudication не найдена") + root = self._diagnostic_root(diagnostic_id, create=create) + return None if root is None else root / f"{session_id}.json" + + def _read(self, path: Path, diagnostic_id: str) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if ( + not isinstance(value, dict) + or value.get("schema_version") != L34F_SESSION_SCHEMA + or value.get("session_id") != path.stem + or value.get("diagnostic_result_id") != diagnostic_id + or value.get("contract_id") != "l34e-candidate-visible-adjudication/v1" + or value.get("blindness") != _BLINDNESS + or value.get("assistance") != _ASSISTANCE + or value.get("authority") != _AUTHORITY + or not isinstance(value.get("revision"), int) + or not isinstance(value.get("frames"), list) + or len(value["frames"]) != 32 + ): + raise ValueError("invalid L3.4F session") + return value + + +def build_l34f_adjudication_router( + *, + diagnostic_root_provider: DiagnosticRootProvider = lambda: None, + candidate_root_provider: CandidateRootProvider = lambda: None, + l34_root_provider: RootProvider = lambda: None, + truth_root_provider: RootProvider = lambda: None, + evaluation_pack_root_provider: RootProvider = lambda: None, + adjudication_root_provider: AdjudicationRootProvider = lambda: None, + result_root_provider: ResultRootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory/l34f", tags=["laboratory"]) + store = _Store(adjudication_root_provider) + + def diagnostic(result_id: str) -> dict[str, Any]: + if _DIAGNOSTIC_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="L3.4E result не найден") + root = _configured_root(diagnostic_root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.4E result не найден") + path = (root / result_id).resolve() + if path.parent != root or path.is_symlink(): + raise HTTPException(status_code=404, detail="L3.4E result не найден") + try: + return read_l34e_self_review_diagnostic(path) + except (L34ESelfReviewDiagnosticError, OSError): + raise HTTPException(status_code=404, detail="L3.4E result не найден") from None + + def case_source(result_id: str, sequence: int) -> tuple[dict[str, Any], dict[str, Any], Path]: + return resolve_l34e_case_source( + diagnostic_root_provider=diagnostic_root_provider, + candidate_root_provider=candidate_root_provider, + l34_root_provider=l34_root_provider, + truth_root_provider=truth_root_provider, + evaluation_pack_root_provider=evaluation_pack_root_provider, + result_id=result_id, + sequence=sequence, + ) + + @router.get("/diagnostics/{diagnostic_id}/cases/{sequence}") + def get_case(diagnostic_id: str, sequence: int) -> dict[str, object]: + result, case, image_path = case_source(diagnostic_id, sequence) + width, height = _png_dimensions(image_path) + return { + "schema_version": "missioncore.l34f-adjudication-case/v1", + "diagnostic_result_id": result["result_id"], + "truth_island_sequence": sequence, + "image_id": case["image_id"], + "frame_index": case["frame_index"], + "group_id": case["group_id"], + "session_seconds": case["session_seconds"], + "source_image_sha256": case["source_image_sha256"], + "camera": {"width": width, "height": height}, + "camera_url": ( + f"/api/v1/laboratory/l34f/diagnostics/{diagnostic_id}/cases/{sequence}/camera" + ), + "candidate_objects": [ + { + "candidate_id": f"candidate-{sequence}-{item['prediction_index']}", + "prediction_index": item["prediction_index"], + "category": item["category"], + "box_xyxy": copy.deepcopy(item["box_xyxy"]), + "diagnostic_verdict": item["diagnostic_verdict"], + "associated_object_id": item["associated_object_id"], + } + for item in case["predictions"] + ], + "diagnostic_summary": copy.deepcopy(case["diagnostic_summary"]), + "model_scores_included": False, + "ground_truth": False, + "access": "candidate-visible-adjudication-read-only-source", + } + + @router.get("/diagnostics/{diagnostic_id}/cases/{sequence}/camera") + def get_camera(diagnostic_id: str, sequence: int) -> FileResponse: + _, _, image_path = case_source(diagnostic_id, sequence) + return FileResponse(image_path, media_type="image/png") + + @router.get("/diagnostics/{diagnostic_id}/sessions") + def list_sessions(diagnostic_id: str) -> dict[str, object]: + diagnostic(diagnostic_id) + items = store.list(diagnostic_id) + return { + "schema_version": "missioncore.l34f-adjudication-session-catalog/v1", + "diagnostic_result_id": diagnostic_id, + "items": items, + "total": len(items), + "access": "human-adjudication", + } + + @router.post("/diagnostics/{diagnostic_id}/sessions") + def create_session(diagnostic_id: str, request: L34FCreateRequest) -> dict[str, object]: + return store.create(diagnostic(diagnostic_id), request.idempotency_key) + + @router.get("/diagnostics/{diagnostic_id}/sessions/{session_id}") + def get_session( + diagnostic_id: str, session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)] + ) -> dict[str, object]: + diagnostic(diagnostic_id) + return store.get(diagnostic_id, session_id) + + @router.put("/diagnostics/{diagnostic_id}/sessions/{session_id}") + def save_session( + diagnostic_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + request: L34FSaveRequest, + ) -> dict[str, object]: + return store.save(diagnostic(diagnostic_id), session_id, request) + + @router.post("/diagnostics/{diagnostic_id}/sessions/{session_id}/freeze") + def freeze_session( + diagnostic_id: str, + session_id: Annotated[str, ApiPath(pattern=_SESSION_ID.pattern)], + request: L34FFreezeRequest, + ) -> dict[str, object]: + source = diagnostic(diagnostic_id) + session = store.get(diagnostic_id, session_id) + if session["revision"] != request.expected_revision: + raise HTTPException(status_code=409, detail="Сессия уже изменена") + if not session["progress"]["complete"]: + raise HTTPException(status_code=422, detail="Для freeze нужны 32/32 проверенных кадров") + output = result_root_provider() + if output is None: + raise HTTPException(status_code=503, detail="Хранилище L3.4F не настроено") + try: + frozen = build_l34f_adjudicated_reference( + diagnostic_root=source["result_root"], + adjudication_session_path=store.session_path(diagnostic_id, session_id), + output_root=output, + ) + except (L34FAdjudicatedReferenceError, OSError) as exc: + raise HTTPException(status_code=409, detail="L3.4F freeze не прошёл проверку") from exc + return _project_result(frozen) + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + candidates = _result_candidates(result_root_provider) + items: list[dict[str, object]] = [] + invalid_total = 0 + for path in candidates: + try: + result = read_l34f_adjudicated_reference(path) + if len(items) < limit: + items.append(_project_result(result)) + except (L34FAdjudicatedReferenceError, OSError): + invalid_total += 1 + return { + "schema_version": "missioncore.l34f-adjudicated-reference-catalog/v1", + "configured": _configured_result_root(result_root_provider) is not None, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + return router + + +def _normalize_object(item: L34FObjectRequest) -> dict[str, object]: + if _OBJECT_ID.fullmatch(item.object_id) is None: + raise HTTPException(status_code=422, detail="Некорректный id рамки") + if any(not math.isfinite(float(value)) for value in item.box_xyxy): + raise HTTPException(status_code=422, detail="Некорректная геометрия рамки") + left, top, right, bottom = (float(value) for value in item.box_xyxy) + if not (0 <= left < right <= 800 and 0 <= top < bottom <= 600): + raise HTTPException(status_code=422, detail="Рамка выходит за границы кадра") + proposed = item.proposed_label.strip() if isinstance(item.proposed_label, str) else None + if item.category == "unmapped" and not proposed: + raise HTTPException(status_code=422, detail="Для другого объекта требуется название") + if item.category != "unmapped" and proposed is not None: + raise HTTPException(status_code=422, detail="Название допустимо только для другого объекта") + return { + "object_id": item.object_id, + "category": item.category, + "proposed_label": proposed, + "origin": item.origin, + "box_xyxy": [left, top, right, bottom], + "occluded": item.occluded, + "truncated": item.truncated, + } + + +def _project_session(document: dict[str, Any], include_frames: bool) -> dict[str, object]: + frames = document["frames"] + reviewed = sum(frame.get("reviewed") is True for frame in frames) + objects = sum(len(frame.get("objects", [])) for frame in frames) + value = { + key: copy.deepcopy(document[key]) + for key in ( + "schema_version", + "session_id", + "diagnostic_result_id", + "contract_id", + "title", + "reviewer_slot", + "revision", + "state", + "created_at_utc", + "updated_at_utc", + "blindness", + "assistance", + "authority", + ) + } + value["progress"] = { + "reviewed_frame_count": reviewed, + "frame_count": 32, + "object_count": objects, + "complete": reviewed == 32, + } + if include_frames: + value["frames"] = copy.deepcopy(frames) + return value + + +def _project_result(result: dict[str, Any]) -> dict[str, object]: + report = result["report"] + identity = result["manifest"]["identity"] + return { + "result_id": result["result_id"], + "created_at_utc": report["created_at_utc"], + "status": report["status"], + "source_session_id": report["source_session_id"], + "camera_source_id": report["camera_source_id"], + "diagnostic_result_id": identity["l34e_diagnostic"]["result_id"], + "adjudication_session_id": identity["adjudication_session"]["session_id"], + "metrics": copy.deepcopy(report["metrics"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "ground_truth": False, + "access": "read-only", + } + + +def _configured_root(provider: DiagnosticRootProvider) -> Path | None: + value = provider() + if value is None: + return None + path = value.expanduser().absolute() + if path.is_symlink(): + return None + try: + path = path.resolve(strict=True) + except OSError: + return None + return path if path.is_dir() else None + + +def _configured_result_root(provider: ResultRootProvider) -> Path | None: + return _configured_root(provider) + + +def _result_candidates(provider: ResultRootProvider) -> list[Path]: + root = _configured_result_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 _RESULT_ID.fullmatch(path.name) is not None + and (path / L34F_MANIFEST_NAME).is_file() + ), + key=lambda path: path.stat().st_mtime_ns, + reverse=True, + ) + + +def _validate_operation_key(value: str) -> None: + if _OPERATION_KEY.fullmatch(value) is None: + raise HTTPException(status_code=422, detail="Некорректный ключ операции") + + +def _png_dimensions(path: Path) -> tuple[int, int]: + header = path.read_bytes()[:24] + if len(header) != 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise HTTPException(status_code=404, detail="Кадр повреждён") + return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big") + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/tests/test_advanced_laboratory_api.py b/tests/test_advanced_laboratory_api.py index d35d846..8932f2b 100644 --- a/tests/test_advanced_laboratory_api.py +++ b/tests/test_advanced_laboratory_api.py @@ -53,11 +53,222 @@ def test_advanced_index_is_empty_when_not_configured() -> None: } -def test_advanced_index_publishes_l31_ravnoves_identity( +def test_advanced_index_publishes_l34b_shadow_identity(tmp_path: Path) -> None: + root = tmp_path / "l34b" + digest = "d" * 64 + result_id = f"l34b-nested-box-consolidation-shadow-{digest}" + candidate = root / result_id + candidate.mkdir(parents=True) + (candidate / "manifest.json").write_text( + json.dumps( + { + "schema_version": ( + "missioncore.l34b-nested-box-consolidation-shadow/v1" + ), + "result_id": result_id, + "identity_sha256": digest, + "identity": { + "authority": { + "ground_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + }, + "created_at_utc": "2026-08-03T10:00:00Z", + "ground_truth": False, + } + ), + encoding="utf-8", + ) + router = build_advanced_laboratory_router( + l34b_root_provider=lambda: root, + ) + route = _endpoint(router, "/api/v1/laboratory/advanced-index") + + assert route()["items"] == [ # type: ignore[index,operator] + { + "work_id": "l34b-nested-box-consolidation-shadow", + "result_id": result_id, + "created_at_utc": "2026-08-03T10:00:00Z", + "access": "read-only", + } + ] + + +def test_advanced_index_publishes_l34c_shadow_identity(tmp_path: Path) -> None: + root = tmp_path / "l34c" + digest = "c" * 64 + result_id = f"l34c-tile-seam-stitch-shadow-{digest}" + candidate = root / result_id + candidate.mkdir(parents=True) + (candidate / "manifest.json").write_text( + json.dumps( + { + "schema_version": "missioncore.l34c-tile-seam-stitch-shadow/v1", + "result_id": result_id, + "identity_sha256": digest, + "identity": { + "authority": { + "ground_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + }, + "created_at_utc": "2026-08-03T11:00:00Z", + "ground_truth": False, + } + ), + encoding="utf-8", + ) + router = build_advanced_laboratory_router( + l34c_root_provider=lambda: root, + ) + route = _endpoint(router, "/api/v1/laboratory/advanced-index") + + assert route()["items"] == [ # type: ignore[index,operator] + { + "work_id": "l34c-tile-seam-stitch-shadow", + "result_id": result_id, + "created_at_utc": "2026-08-03T11:00:00Z", + "access": "read-only", + } + ] + + +def test_advanced_index_publishes_l34d_candidate_identity(tmp_path: Path) -> None: + root = tmp_path / "l34d" + digest = "e" * 64 + result_id = f"l34d-cumulative-postprocessing-candidate-{digest}" + candidate = root / result_id + candidate.mkdir(parents=True) + (candidate / "manifest.json").write_text( + json.dumps( + { + "schema_version": ( + "missioncore.l34d-cumulative-postprocessing-candidate/v1" + ), + "result_id": result_id, + "identity_sha256": digest, + "identity": { + "authority": { + "ground_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + }, + "created_at_utc": "2026-08-03T12:00:00Z", + "ground_truth": False, + } + ), + encoding="utf-8", + ) + router = build_advanced_laboratory_router( + l34d_root_provider=lambda: root, + ) + route = _endpoint(router, "/api/v1/laboratory/advanced-index") + + assert route()["items"] == [ # type: ignore[index,operator] + { + "work_id": "l34d-cumulative-postprocessing-candidate", + "result_id": result_id, + "created_at_utc": "2026-08-03T12:00:00Z", + "access": "read-only", + } + ] + + +def test_advanced_index_publishes_l34e_diagnostic_identity(tmp_path: Path) -> None: + root = tmp_path / "l34e" + digest = "f" * 64 + result_id = f"l34e-self-review-diagnostic-{digest}" + candidate = root / result_id + candidate.mkdir(parents=True) + (candidate / "manifest.json").write_text( + json.dumps( + { + "schema_version": "missioncore.l34e-self-review-diagnostic/v1", + "result_id": result_id, + "identity_sha256": digest, + "identity": { + "authority": { + "ground_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + }, + "created_at_utc": "2026-08-03T13:00:00Z", + "ground_truth": False, + } + ), + encoding="utf-8", + ) + router = build_advanced_laboratory_router( + l34e_root_provider=lambda: root, + ) + route = _endpoint(router, "/api/v1/laboratory/advanced-index") + + assert route()["items"] == [ # type: ignore[index,operator] + { + "work_id": "l34e-self-review-diagnostic", + "result_id": result_id, + "created_at_utc": "2026-08-03T13:00:00Z", + "access": "read-only", + } + ] + + +def test_advanced_index_publishes_l34f_adjudicated_reference_identity( + tmp_path: Path, +) -> None: + root = tmp_path / "l34f" + digest = "a" * 64 + result_id = f"l34f-adjudicated-reference-{digest}" + candidate = root / result_id + candidate.mkdir(parents=True) + (candidate / "manifest.json").write_text( + json.dumps( + { + "schema_version": "missioncore.l34f-adjudicated-reference/v1", + "result_id": result_id, + "identity_sha256": digest, + "identity": { + "authority": { + "ground_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + }, + "created_at_utc": "2026-08-03T14:00:00Z", + "ground_truth": False, + } + ), + encoding="utf-8", + ) + router = build_advanced_laboratory_router( + l34f_root_provider=lambda: root, + ) + route = _endpoint(router, "/api/v1/laboratory/advanced-index") + + assert route()["items"] == [ # type: ignore[index,operator] + { + "work_id": "l34f-adjudicated-reference", + "result_id": result_id, + "created_at_utc": "2026-08-03T14:00:00Z", + "access": "read-only", + } + ] + + +def test_advanced_index_publishes_l32_camera_review_identity( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: - result_id = f"l31-pointpillars-ravnoves-{'b' * 64}" + result_id = f"l32-pointpillars-camera-review-{'b' * 64}" def fake_latest(provider: object) -> dict[str, str]: assert callable(provider) @@ -67,15 +278,15 @@ def test_advanced_index_publishes_l31_ravnoves_identity( "created_at_utc": "2026-07-31T10:56:45.861Z", } - monkeypatch.setattr(advanced_api, "latest_l31_identity", fake_latest) + monkeypatch.setattr(advanced_api, "latest_l32_identity", fake_latest) router = build_advanced_laboratory_router( - l31_ravnoves_root_provider=lambda: tmp_path, + l32_camera_review_root_provider=lambda: tmp_path, ) route = _endpoint(router, "/api/v1/laboratory/advanced-index") assert route()["items"] == [ # type: ignore[index,operator] { - "work_id": "l31-pointpillars-ravnoves", + "work_id": "l32-pointpillars-camera-review", "result_id": result_id, "created_at_utc": "2026-07-31T10:56:45.861Z", "access": "read-only", @@ -83,6 +294,36 @@ def test_advanced_index_publishes_l31_ravnoves_identity( ] +def test_advanced_index_publishes_l33_camera_first_identity( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + result_id = f"l33-camera-first-detector-review-{'c' * 64}" + + def fake_latest(provider: object) -> dict[str, str]: + assert callable(provider) + assert provider() == tmp_path # type: ignore[operator] + return { + "result_id": result_id, + "created_at_utc": "2026-07-31T13:42:00.000Z", + } + + monkeypatch.setattr(advanced_api, "latest_l33_identity", fake_latest) + router = build_advanced_laboratory_router( + l33_camera_first_review_root_provider=lambda: tmp_path, + ) + route = _endpoint(router, "/api/v1/laboratory/advanced-index") + + assert route()["items"] == [ # type: ignore[index,operator] + { + "work_id": "l33-camera-first-detector-review", + "result_id": result_id, + "created_at_utc": "2026-07-31T13:42:00.000Z", + "access": "read-only", + } + ] + + def test_advanced_index_reads_only_bounded_identity_documents( tmp_path: Path, ) -> None: diff --git a/tests/test_e46_blind_review_api.py b/tests/test_e46_blind_review_api.py new file mode 100644 index 0000000..b4302fc --- /dev/null +++ b/tests/test_e46_blind_review_api.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import hashlib +import json +import struct +import zlib +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import APIRouter, HTTPException +from fastapi.routing import APIRoute + +import k1link.compute.e46_lab_review_submission as submission +import k1link.compute.e48_detector_truth_seal as e48 +import k1link.web.e46_blind_review_api as api +from k1link.web.l34_annotation_api import ( + L34AnnotationCreateRequest, + L34AnnotationFrameRequest, + L34AnnotationObjectRequest, + L34AnnotationSaveRequest, +) + + +def _endpoint(router: APIRouter, path: str, method: str = "GET") -> object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and method in route.methods + ): + return route.endpoint + raise AssertionError(f"{method} {path} route is missing") + + +def _png(width: int = 8, height: int = 6) -> bytes: + signature = b"\x89PNG\r\n\x1a\n" + + def chunk(kind: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + pixels = b"".join(b"\x00" + b"\x20\x40\x60" * width for _ in range(height)) + return ( + signature + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(pixels)) + + chunk(b"IEND", b"") + ) + + +def _fixture( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[APIRouter, str]: + pack_id = f"evaluation-pack-{'a' * 64}" + pack_root = tmp_path / "packs" + image = pack_root / pack_id / "images" / "valid-fov-fill" / "frame.png" + image.parent.mkdir(parents=True) + image.write_bytes(_png()) + image_sha = hashlib.sha256(image.read_bytes()).hexdigest() + truth_id = f"e46-detector-truth-island-{'b' * 64}" + truth_root = tmp_path / "truth" / truth_id + truth_root.mkdir(parents=True) + references = [ + { + "truth_island_sequence": sequence, + "image_id": sequence, + "frame_index": sequence * 10, + "session_seconds": float(sequence), + "role": "anchor" if sequence <= 16 else "temporal", + "group_id": ( + f"anchor-{sequence:02d}" + if sequence <= 16 + else f"clip-{((sequence - 17) // 4) + 1}" + ), + "source_path": "images/valid-fov-fill/frame.png", + "byte_length": image.stat().st_size, + "sha256": image_sha, + } + for sequence in range(1, 33) + ] + (truth_root / "image-references.jsonl").write_text( + "".join(json.dumps(row) + "\n" for row in references), + encoding="utf-8", + ) + (truth_root / "blind-contract.json").write_text( + json.dumps( + { + "annotation": { + "classes": [ + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", + ] + } + } + ), + encoding="utf-8", + ) + truth = SimpleNamespace( + result_id=truth_id, + result_root=truth_root, + manifest={ + "created_at_utc": "2026-08-04T08:00:00Z", + "identity": { + "source": { + "evaluation_pack_id": pack_id, + "session_id": "ravnoves00", + "source_id": "sensor.camera.right", + } + }, + }, + report={ + "status": "prepared-awaiting-independent-human-review", + "blindness": {"truth_labels_available": False}, + "selection": { + "anchor_count": 16, + "temporal_frame_count": 16, + "temporal_group_count": 4, + }, + }, + ) + monkeypatch.setattr(api, "read_e46_detector_truth_island", lambda _: truth) + monkeypatch.setattr(submission, "read_e46_detector_truth_island", lambda _: truth) + monkeypatch.setattr(e48, "read_e46_detector_truth_island", lambda _: truth) + router = api.build_e46_blind_review_router( + truth_root_provider=lambda: truth_root.parent, + evaluation_pack_root_provider=lambda: pack_root, + annotation_root_provider=lambda: tmp_path / "annotations", + submission_root_provider=lambda: tmp_path / "submissions", + ) + return router, truth_id + + +def _complete_frames() -> list[L34AnnotationFrameRequest]: + return [ + L34AnnotationFrameRequest( + truth_island_sequence=sequence, + reviewed=True, + hard_negative=True, + objects=[], + ) + for sequence in range(1, 33) + ] + + +def test_e46_source_is_candidate_free_and_limits_reviewer_slots( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router, truth_id = _fixture(tmp_path, monkeypatch) + source = _endpoint( + router, + "/api/v1/laboratory/e46/results/{result_id}/annotation-source", + )(result_id=truth_id) # type: ignore[operator] + + assert source["frame_count"] == 32 + assert source["contract"]["unmapped_class"] is None + assert source["candidate_identity_included"] is False + assert source["candidate_predictions_included"] is False + assert source["prelabels_included"] is False + assert all("candidate_id" not in frame for frame in source["frames"]) + + create = _endpoint( + router, + "/api/v1/laboratory/e46/results/{result_id}/annotation-sessions", + "POST", + ) + first = create( # type: ignore[operator] + result_id=truth_id, + request=L34AnnotationCreateRequest(idempotency_key="reviewer-slot-a"), + ) + second = create( # type: ignore[operator] + result_id=truth_id, + request=L34AnnotationCreateRequest(idempotency_key="reviewer-slot-b"), + ) + assert first["reviewer_slot"] == 1 + assert second["reviewer_slot"] == 2 + get_session = _endpoint( + router, + ( + "/api/v1/laboratory/e46/results/{result_id}" + "/annotation-sessions/{session_id}" + ), + ) + with pytest.raises(HTTPException) as private: + get_session( # type: ignore[operator] + result_id=truth_id, + session_id=first["session_id"], + x_e46_review_capability=second["review_capability"], + ) + assert private.value.status_code == 403 + with pytest.raises(HTTPException) as caught: + create( # type: ignore[operator] + result_id=truth_id, + request=L34AnnotationCreateRequest(idempotency_key="reviewer-slot-c"), + ) + assert caught.value.status_code == 409 + + +def test_e46_complete_session_freezes_as_valid_e48_input( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router, truth_id = _fixture(tmp_path, monkeypatch) + create = _endpoint( + router, + "/api/v1/laboratory/e46/results/{result_id}/annotation-sessions", + "POST", + ) + session = create( # type: ignore[operator] + result_id=truth_id, + request=L34AnnotationCreateRequest(idempotency_key="reviewer-slot-a"), + ) + save = _endpoint( + router, + ( + "/api/v1/laboratory/e46/results/{result_id}" + "/annotation-sessions/{session_id}" + ), + "PUT", + ) + saved = save( # type: ignore[operator] + result_id=truth_id, + session_id=session["session_id"], + x_e46_review_capability=session["review_capability"], + request=L34AnnotationSaveRequest( + expected_revision=0, + idempotency_key="save-complete-review-a", + title="E46 review A", + assistance_mode="prediction-free-manual", + frames=_complete_frames(), + ), + ) + assert saved["progress"]["complete"] is True + + freeze = _endpoint( + router, + ( + "/api/v1/laboratory/e46/results/{result_id}" + "/annotation-sessions/{session_id}/freeze" + ), + "POST", + ) + frozen = freeze( # type: ignore[operator] + result_id=truth_id, + session_id=session["session_id"], + x_e46_review_capability=session["review_capability"], + request=api.E46ReviewFreezeRequest( + expected_revision=1, + reviewer_id="reviewer-a", + independent_attestation=True, + candidate_identity_not_seen=True, + model_material_not_seen=True, + ), + ) + assert frozen["state"] == "completed-e48-review-input-not-truth" + assert frozen["frame_count"] == 32 + assert frozen["blindness"]["candidate_identity_seen"] is False + assert frozen["ground_truth"] is False + + with pytest.raises(HTTPException) as locked: + save( # type: ignore[operator] + result_id=truth_id, + session_id=session["session_id"], + x_e46_review_capability=session["review_capability"], + request=L34AnnotationSaveRequest( + expected_revision=1, + idempotency_key="cannot-edit-frozen-review-a", + title="E46 review A changed", + assistance_mode="prediction-free-manual", + frames=_complete_frames(), + ), + ) + assert locked.value.status_code == 409 + + review_path = ( + tmp_path + / "submissions" + / frozen["result_id"] + / submission.E46_LAB_REVIEW_DOCUMENT + ) + validated = e48.validate_e48_detector_review_submission( + truth_island_root=tmp_path / "truth" / truth_id, + review_path=review_path, + ) + assert validated["reviewer_id"] == "reviewer-a" + assert len(validated["images"]) == 32 + + +def test_e46_rejects_unmapped_object( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router, truth_id = _fixture(tmp_path, monkeypatch) + create = _endpoint( + router, + "/api/v1/laboratory/e46/results/{result_id}/annotation-sessions", + "POST", + ) + session = create( # type: ignore[operator] + result_id=truth_id, + request=L34AnnotationCreateRequest(idempotency_key="reviewer-slot-a"), + ) + save = _endpoint( + router, + ( + "/api/v1/laboratory/e46/results/{result_id}" + "/annotation-sessions/{session_id}" + ), + "PUT", + ) + frame = L34AnnotationFrameRequest( + truth_island_sequence=1, + reviewed=True, + hard_negative=False, + objects=[ + L34AnnotationObjectRequest( + object_id="object-1", + category="unmapped", + proposed_label="stroller", + origin="manual", + box_xyxy=[1.0, 1.0, 5.0, 5.0], + occluded=False, + truncated=False, + ) + ], + ) + with pytest.raises(HTTPException) as caught: + save( # type: ignore[operator] + result_id=truth_id, + session_id=session["session_id"], + x_e46_review_capability=session["review_capability"], + request=L34AnnotationSaveRequest( + expected_revision=0, + idempotency_key="reject-unmapped", + title="E46 review A", + assistance_mode="prediction-free-manual", + frames=[frame], + ), + ) + assert caught.value.status_code == 422 diff --git a/tests/test_e46c_full_replay_video_overlay.py b/tests/test_e46c_full_replay_video_overlay.py new file mode 100644 index 0000000..89a8a41 --- /dev/null +++ b/tests/test_e46c_full_replay_video_overlay.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from k1link.web.e46c_full_replay_world_tracks_api import _cached_video_overlay + + +def _canonical(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _source(tmp_path: Path) -> tuple[Path, str, str]: + root = tmp_path / f"e10-integrated-perception-{'1' * 64}" + root.mkdir() + start, end = 35.421857292, 484.044857292 + step = (end - start) / 4488 + fusion = root / "fusion-frames.jsonl" + with fusion.open("wb") as handle: + for frame_index in range(4489): + objects = [] + if frame_index == 20: + objects.append( + { + "bbox_xyxy": [100.0, 120.0, 240.0, 360.0], + "label": "person", + "score": 0.91, + "source_track_id": 83, + "track_id": 240001, + "motion_state": "dynamic", + "motion_confidence": 0.88, + "track_hits": 12, + "track_age_s": 1.2, + "camera_evidence_current": True, + "occupancy_evidence_current": False, + } + ) + row = { + "source_frame_index": frame_index, + "session_seconds": end if frame_index == 4488 else start + frame_index * step, + "fusion_state": "fused", + "objects": objects, + } + handle.write(_canonical(row) + b"\n") + fusion_sha256 = _sha256(fusion) + result = { + "schema_version": "missioncore.e10-integrated-perception-result/v1", + "acceptance_state": "accepted", + "identity": { + "input_sha256": "4" * 64, + "source_id": "sensor.camera.right", + "selection": { + "frame_count": 4489, + "timeline_start_seconds": start, + "timeline_end_seconds": end, + }, + "configuration": { + "source_session_id": "20260720T065719Z_viewer_live", + "benchmark": { + "events": [ + { + "id": "dynamic-person-window", + "kind": "target-motion", + "class_group": "person", + "window_seconds": [54.0, 62.0], + "target_source_track_ids": [83], + } + ] + }, + }, + }, + "artifacts": [ + { + "path": fusion.name, + "byte_length": fusion.stat().st_size, + "sha256": fusion_sha256, + } + ], + } + result_path = root / "result.json" + result_path.write_bytes(_canonical(result) + b"\n") + return root, _sha256(result_path), fusion_sha256 + + +def test_e46c_video_overlay_projects_all_frames_without_paths(tmp_path: Path) -> None: + root, result_sha256, fusion_sha256 = _source(tmp_path) + result_id = f"e46c-full-replay-world-tracks-{'a' * 64}" + + overlay = _cached_video_overlay( + result_id, + str(root), + result_sha256, + fusion_sha256, + ) + + assert overlay["result_id"] == result_id + assert overlay["frame_count"] == 4489 + frames = overlay["frames"] + assert isinstance(frames, list) + assert frames[20]["objects"][0]["route_track_id"] == 83 + assert overlay["review_windows"][0]["target_source_track_ids"] == [83] + assert "/" not in json.dumps(overlay["recorded_source"]) + + +def test_e46c_video_overlay_rejects_a_replaced_fusion_artifact(tmp_path: Path) -> None: + root, result_sha256, fusion_sha256 = _source(tmp_path) + + with pytest.raises(ValueError, match="identity changed"): + _cached_video_overlay( + f"e46c-full-replay-world-tracks-{'b' * 64}", + str(root), + result_sha256, + "0" * 64 if fusion_sha256 != "0" * 64 else "f" * 64, + ) diff --git a/tests/test_e46e_ready_stack_api.py b/tests/test_e46e_ready_stack_api.py new file mode 100644 index 0000000..55a599d --- /dev/null +++ b/tests/test_e46e_ready_stack_api.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.web import e46e_ready_stack_api + + +def test_e46e_catalog_and_overlay_are_read_only_hash_bound( + tmp_path: Path, + monkeypatch, +) -> None: + result_id = f"e46e-ready-stack-{'a' * 64}" + result_root = tmp_path / result_id + result_root.mkdir() + overlay = result_root / "overlay.mp4" + overlay.write_bytes(b"0123456789abcdef") + frozen = { + "result_id": result_id, + "result_root": result_root, + "overlay_path": overlay, + "manifest": { + "artifacts": [ + { + "role": "visual-overlay-video", + "path": "overlay.mp4", + "byte_length": 16, + "sha256": "b" * 64, + } + ] + }, + "report": { + "created_at_utc": "2026-08-04T09:00:00Z", + "source_session_id": "RAVNOVES00", + "camera_source_id": "sensor.camera.right", + "status": "completed-stock-nvidia-recorded-right-replay", + "metrics": {"frame_count": 4489}, + "acceptance": {"full_route_accounted": True}, + "decision": {"custom_temporal_logic_used": False}, + "method": { + "schema_version": "missioncore.laboratory-method/v1", + "completeness": "complete", + "execution_class": "hybrid", + }, + "limitations": ["not truth"], + }, + } + monkeypatch.setattr(e46e_ready_stack_api, "read_e46e_ready_stack", lambda _: frozen) + app = FastAPI() + app.include_router( + e46e_ready_stack_api.build_e46e_ready_stack_router( + root_provider=lambda: tmp_path + ) + ) + client = TestClient(app) + + catalog = client.get("/api/v1/laboratory/e46e/results?limit=1") + assert catalog.status_code == 200 + item = catalog.json()["items"][0] + assert item["result_id"] == result_id + assert item["video"] == { + "url": f"/api/v1/laboratory/e46e/results/{result_id}/overlay.mp4", + "media_type": "video/mp4", + "byte_length": 16, + "sha256": "b" * 64, + "width": 800, + "height": 600, + } + assert item["ground_truth"] is False + + video = client.get( + f"/api/v1/laboratory/e46e/results/{result_id}/overlay.mp4", + headers={"Range": "bytes=4-9"}, + ) + assert video.status_code == 206 + assert video.content == b"456789" + assert video.headers["content-range"] == "bytes 4-9/16" + assert video.headers["etag"] == f'"{"b" * 64}"' + assert video.headers["cache-control"].endswith("immutable") + + +def test_e46e_catalog_is_empty_when_not_configured() -> None: + app = FastAPI() + app.include_router(e46e_ready_stack_api.build_e46e_ready_stack_router()) + response = TestClient(app).get("/api/v1/laboratory/e46e/results") + assert response.status_code == 200 + assert response.json()["configured"] is False + assert response.json()["items"] == [] diff --git a/tests/test_e46f_dashcam_bakeoff_api.py b/tests/test_e46f_dashcam_bakeoff_api.py new file mode 100644 index 0000000..bb8b4b3 --- /dev/null +++ b/tests/test_e46f_dashcam_bakeoff_api.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.web import e46f_dashcam_bakeoff_api + + +def test_e46f_projects_visual_rejection_against_e46e(tmp_path: Path, monkeypatch) -> None: + result_id = f"e46f-dashcam-bakeoff-{'a' * 64}" + baseline_id = f"e46e-ready-stack-{'b' * 64}" + candidate_root = tmp_path / "candidate" + baseline_root = tmp_path / "baseline" + (candidate_root / result_id).mkdir(parents=True) + (baseline_root / baseline_id).mkdir(parents=True) + overlay = candidate_root / result_id / "overlay.mp4" + overlay.write_bytes(b"0123456789abcdef") + candidate = _frozen( + result_id=result_id, + result_root=candidate_root / result_id, + overlay=overlay, + baseline_id=baseline_id, + metrics=_metrics(zero_tracks=25, blackouts=1, unique_tracks=545, short_fraction=0.06789), + objects=[_large_object("person", 17)], + ) + baseline = { + "report": { + "metrics": _metrics( + zero_tracks=28, + blackouts=2, + unique_tracks=909, + short_fraction=0.093509, + ) + }, + "frames": ({"frame_index": 0, "objects": []},), + } + monkeypatch.setattr( + e46f_dashcam_bakeoff_api, + "read_e46f_dashcam_bakeoff", + lambda _: candidate, + ) + monkeypatch.setattr( + e46f_dashcam_bakeoff_api, + "read_e46e_ready_stack", + lambda _: baseline, + ) + app = FastAPI() + app.include_router( + e46f_dashcam_bakeoff_api.build_e46f_dashcam_bakeoff_router( + root_provider=lambda: candidate_root, + e46e_root_provider=lambda: baseline_root, + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/laboratory/e46f/results?limit=1") + assert response.status_code == 200 + item = response.json()["items"][0] + delta = item["comparison"]["delta"] + assert delta["zero_track_frame_count"] == -3 + assert delta["full_layer_blackout_event_count"] == -1 + assert delta["unique_track_count"] == -364 + assert delta["short_track_fraction"] == pytest.approx(-0.025619) + assert item["comparison"]["large_box_visual_triage"]["candidate"] == { + "observation_count": 1, + "frame_count": 1, + "track_id_count": 1, + "class_observations": {"person": 1}, + } + assert item["comparison"]["verdict"] == ("reject-dashcamnet-on-unrectified-fisheye") + assert item["ground_truth"] is False + + video = client.get( + f"/api/v1/laboratory/e46f/results/{result_id}/overlay.mp4", + headers={"Range": "bytes=4-9"}, + ) + assert video.status_code == 206 + assert video.content == b"456789" + assert video.headers["etag"] == f'"{"c" * 64}"' + assert video.headers["cache-control"].endswith("immutable") + + +def test_e46f_catalog_is_empty_when_not_configured() -> None: + app = FastAPI() + app.include_router(e46f_dashcam_bakeoff_api.build_e46f_dashcam_bakeoff_router()) + response = TestClient(app).get("/api/v1/laboratory/e46f/results") + assert response.status_code == 200 + assert response.json()["configured"] is False + assert response.json()["items"] == [] + + +def _metrics( + *, zero_tracks: int, blackouts: int, unique_tracks: int, short_fraction: float +) -> dict[str, int | float]: + return { + "frame_count": 4489, + "zero_track_frame_count": zero_tracks, + "full_layer_blackout_event_count": blackouts, + "unique_track_count": unique_tracks, + "short_track_fraction": short_fraction, + } + + +def _large_object(label: str, track_id: int) -> dict[str, object]: + return { + "bbox": [0.0, 0.0, 400.0, 300.0], + "source_track_id": track_id, + "class_name": label, + } + + +def _frozen( + *, + result_id: str, + result_root: Path, + overlay: Path, + baseline_id: str, + metrics: dict[str, int | float], + objects: list[dict[str, object]], +) -> dict[str, object]: + return { + "result_id": result_id, + "result_root": result_root, + "overlay_path": overlay, + "manifest": { + "artifacts": [ + { + "role": "visual-overlay-video", + "path": "overlay.mp4", + "byte_length": 16, + "sha256": "c" * 64, + } + ] + }, + "report": { + "created_at_utc": "2026-08-04T09:00:00Z", + "source_session_id": "RAVNOVES00", + "camera_source_id": "sensor.camera.right", + "status": "completed-stock-nvidia-detector-only-bakeoff", + "comparison_contract": {"baseline_result_id": baseline_id}, + "metrics": metrics, + "acceptance": {"full_route_accounted": True}, + "decision": {"custom_temporal_logic_used": False}, + "method": { + "schema_version": "missioncore.laboratory-method/v1", + "completeness": "complete", + "execution_class": "hybrid", + }, + "limitations": ["not truth"], + }, + "frames": ({"frame_index": 0, "objects": objects},), + } diff --git a/tests/test_e46g_rectified_detector_bakeoff_api.py b/tests/test_e46g_rectified_detector_bakeoff_api.py new file mode 100644 index 0000000..da78e19 --- /dev/null +++ b/tests/test_e46g_rectified_detector_bakeoff_api.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.web import e46g_rectified_detector_bakeoff_api + + +def test_e46g_projects_visual_front_only_selection(tmp_path: Path, monkeypatch) -> None: + result_id = f"e46g-rectified-detector-bakeoff-{'a' * 64}" + root = tmp_path / "results" + result_root = root / result_id + result_root.mkdir(parents=True) + traffic = result_root / "trafficcamnet.mp4" + dash = result_root / "dashcamnet.mp4" + traffic.write_bytes(b"traffic-comparison") + dash.write_bytes(b"dash-comparison") + frozen = _frozen(result_id, result_root, traffic, dash) + monkeypatch.setattr( + e46g_rectified_detector_bakeoff_api, + "read_e46g_rectified_detector_bakeoff", + lambda _: frozen, + ) + app = FastAPI() + app.include_router( + e46g_rectified_detector_bakeoff_api.build_e46g_rectified_detector_bakeoff_router( + root_provider=lambda: root + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/laboratory/e46g/results?limit=1") + assert response.status_code == 200 + item = response.json()["items"][0] + review = item["comparison"]["visual_review"] + assert review["selected_candidate"] == "trafficcamnet" + assert review["selected_view"] == "front" + assert review["excluded_views"] == ["left", "right"] + assert item["comparison"]["verdict"] == "select-trafficcamnet-front-only-for-e46h" + assert item["ground_truth"] is False + assert item["authority"]["navigation_or_safety_accepted"] is False + assert item["videos"]["trafficcamnet"]["view_order"] == ["left", "front", "right"] + + video = client.get( + f"/api/v1/laboratory/e46g/results/{result_id}/trafficcamnet.mp4", + headers={"Range": "bytes=2-8"}, + ) + assert video.status_code == 206 + assert video.content == b"affic-c" + assert video.headers["etag"] == f'"{"b" * 64}"' + assert video.headers["cache-control"].endswith("immutable") + + +def test_e46g_catalog_is_empty_when_not_configured() -> None: + app = FastAPI() + app.include_router( + e46g_rectified_detector_bakeoff_api.build_e46g_rectified_detector_bakeoff_router() + ) + response = TestClient(app).get("/api/v1/laboratory/e46g/results") + assert response.status_code == 200 + assert response.json()["configured"] is False + assert response.json()["items"] == [] + + +def _frozen( + result_id: str, + result_root: Path, + traffic: Path, + dash: Path, +) -> dict[str, object]: + return { + "result_id": result_id, + "result_root": result_root, + "comparison_paths": {"trafficcamnet": traffic, "dashcamnet": dash}, + "manifest": { + "artifacts": [ + { + "role": "comparison-video-trafficcamnet", + "path": traffic.name, + "byte_length": traffic.stat().st_size, + "sha256": "b" * 64, + }, + { + "role": "comparison-video-dashcamnet", + "path": dash.name, + "byte_length": dash.stat().st_size, + "sha256": "c" * 64, + }, + ] + }, + "report": { + "created_at_utc": "2026-08-04T13:28:23.707Z", + "source_session_id": "20260720T065719Z_viewer_live", + "camera_source_id": "sensor.camera.right", + "selection": { + "first_source_frame_index": 1000, + "last_source_frame_index": 1599, + "frame_count": 600, + }, + "rectification": { + "provider": "NVIDIA Gst-nvdewarper", + "output_resolution": [960, 544], + "view_order": ["left", "front", "right"], + }, + "metrics": { + "trafficcamnet": {"views": {"front": {"zero_track_frame_count": 0}}}, + "dashcamnet": {"views": {"front": {"zero_track_frame_count": 34}}}, + }, + "acceptance": {"official_nvidia_dewarper_executed": True}, + "method": { + "schema_version": "missioncore.laboratory-method/v1", + "completeness": "complete", + "execution_class": "hybrid", + "pipeline_id": "e46g-test/v1", + "components": [], + }, + "limitations": ["not truth"], + "authority": { + "ground_truth": False, + "independent_truth": False, + "metric_grade_reference": False, + "candidate_accepted": False, + "free_space_authority": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + }, + } diff --git a/tests/test_e46h_full_rectified_front_replay_api.py b/tests/test_e46h_full_rectified_front_replay_api.py new file mode 100644 index 0000000..d97cd3b --- /dev/null +++ b/tests/test_e46h_full_rectified_front_replay_api.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.web import e46h_full_rectified_front_replay_api + + +def test_e46h_projects_full_video_and_visual_exception_windows( + tmp_path: Path, + monkeypatch, +) -> None: + result_id = f"e46h-full-rectified-front-replay-{'a' * 64}" + root = tmp_path / "results" + result_root = root / result_id + result_root.mkdir(parents=True) + overlay = result_root / "overlay.mp4" + overlay.write_bytes(b"0123456789abcdef") + frozen = _frozen(result_id, result_root, overlay) + monkeypatch.setattr( + e46h_full_rectified_front_replay_api, + "read_e46h_full_rectified_front_replay", + lambda _: frozen, + ) + app = FastAPI() + app.include_router( + e46h_full_rectified_front_replay_api.build_e46h_full_rectified_front_replay_router( + root_provider=lambda: root + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/laboratory/e46h/results?limit=1") + assert response.status_code == 200 + item = response.json()["items"][0] + assert item["selection"]["frame_count"] == 4488 + assert item["rectification"]["view"] == "front" + assert ( + item["visual_review"]["status"] + == "full-continuous-and-targeted-review-completed" + ) + assert item["visual_review"]["complete_video_reviewed"] is True + assert item["visual_review"]["reviewed_video_range_seconds"] == [0.0, 448.8] + assert len(item["visual_review"]["review_windows"]) == 6 + assert sum( + row["verdict"] == "semantic-false-positive" + for row in item["visual_review"]["review_windows"] + ) == 5 + assert item["ground_truth"] is False + assert item["authority"]["candidate_accepted"] is False + + video = client.get( + f"/api/v1/laboratory/e46h/results/{result_id}/overlay.mp4", + headers={"Range": "bytes=4-9"}, + ) + assert video.status_code == 206 + assert video.content == b"456789" + assert video.headers["etag"] == f'"{"b" * 64}"' + assert video.headers["cache-control"].endswith("immutable") + + +def test_e46h_catalog_is_empty_when_not_configured() -> None: + app = FastAPI() + app.include_router( + e46h_full_rectified_front_replay_api.build_e46h_full_rectified_front_replay_router() + ) + response = TestClient(app).get("/api/v1/laboratory/e46h/results") + assert response.status_code == 200 + assert response.json()["configured"] is False + assert response.json()["items"] == [] + + +def _frozen(result_id: str, result_root: Path, overlay: Path) -> dict[str, object]: + return { + "result_id": result_id, + "result_root": result_root, + "overlay_path": overlay, + "manifest": { + "artifacts": [ + { + "role": "visual-overlay-video", + "path": overlay.name, + "byte_length": overlay.stat().st_size, + "sha256": "b" * 64, + } + ] + }, + "report": { + "created_at_utc": "2026-08-04T14:17:42.899Z", + "source_session_id": "20260720T065719Z_viewer_live", + "camera_source_id": "sensor.camera.right", + "baseline_result_id": f"e46g-rectified-detector-bakeoff-{'c' * 64}", + "selection": { + "first_source_frame_index": 0, + "last_source_frame_index": 4487, + "frame_count": 4488, + "excluded_source_tail_frame_count": 1, + }, + "rectification": { + "provider": "NVIDIA Gst-nvdewarper", + "provider_version": "DeepStream 9.1", + "projection": "fisheye-to-perspective", + "view": "front", + "output_resolution": [960, 544], + "horizontal_fov_degrees": 100, + }, + "metrics": {"frame_count": 4488}, + "acceptance": {"candidate_accepted": False}, + "decision": {"provider_promoted": False}, + "method": { + "schema_version": "missioncore.laboratory-method/v1", + "completeness": "complete", + "execution_class": "hybrid", + "pipeline_id": "e46h-test/v1", + "components": [], + }, + "limitations": ["not truth"], + "authority": { + "ground_truth": False, + "independent_truth": False, + "metric_grade_reference": False, + "candidate_accepted": False, + "free_space_authority": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + }, + } diff --git a/tests/test_e46i_grounding_dino_full_replay_api.py b/tests/test_e46i_grounding_dino_full_replay_api.py new file mode 100644 index 0000000..1b54e47 --- /dev/null +++ b/tests/test_e46i_grounding_dino_full_replay_api.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.web import e46i_grounding_dino_full_replay_api + + +def test_e46i_projects_full_video_and_visual_comparison( + tmp_path: Path, + monkeypatch, +) -> None: + result_id = f"e46i-grounding-dino-full-replay-{'a' * 64}" + root = tmp_path / "results" + result_root = root / result_id + result_root.mkdir(parents=True) + paths = { + "overlay_path": result_root / "overlay.mp4", + "shadow_sheet_path": result_root / "shadow.png", + "route_sheet_path": result_root / "route.png", + "targeted_sheet_path": result_root / "targeted.png", + } + for index, path in enumerate(paths.values(), 1): + path.write_bytes(bytes(range(index, index + 16))) + frozen = _frozen(result_id, result_root, paths) + monkeypatch.setattr( + e46i_grounding_dino_full_replay_api, + "read_e46i_grounding_dino_full_replay", + lambda _: frozen, + ) + app = FastAPI() + app.include_router( + e46i_grounding_dino_full_replay_api.build_e46i_grounding_dino_full_replay_router( + root_provider=lambda: root + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/laboratory/e46i/results?limit=1") + assert response.status_code == 200 + item = response.json()["items"][0] + assert item["status"] == "semantic-regression-suppressed-awaiting-temporal-layer" + assert item["source"]["video_frame_count"] == 4488 + assert item["shadow_gate"]["legacy_large_false_background_cases_suppressed"] == 5 + assert item["visual_review"]["complete_video_playback_completed"] is True + assert len(item["visual_review"]["review_windows"]) == 6 + assert item["video"]["duration_seconds"] == 448.8 + assert set(item["visuals"]) == {"shadow_gate", "full_route", "targeted_windows"} + assert item["ground_truth"] is False + assert item["authority"]["candidate_accepted"] is False + + video = client.get( + f"/api/v1/laboratory/e46i/results/{result_id}/overlay.mp4", + headers={"Range": "bytes=4-9"}, + ) + assert video.status_code == 206 + assert video.content == bytes(range(5, 11)) + assert video.headers["cache-control"].endswith("immutable") + + visual = client.get( + f"/api/v1/laboratory/e46i/results/{result_id}/visual/targeted-windows.png" + ) + assert visual.status_code == 200 + assert visual.headers["content-type"] == "image/png" + + +def test_e46i_catalog_is_empty_when_not_configured() -> None: + app = FastAPI() + app.include_router( + e46i_grounding_dino_full_replay_api.build_e46i_grounding_dino_full_replay_router() + ) + response = TestClient(app).get("/api/v1/laboratory/e46i/results") + assert response.status_code == 200 + assert response.json()["configured"] is False + assert response.json()["items"] == [] + + +def _frozen( + result_id: str, + result_root: Path, + paths: dict[str, Path], +) -> dict[str, object]: + roles = { + "overlay_path": "visual-overlay-video", + "shadow_sheet_path": "shadow-gate-contact-sheet", + "route_sheet_path": "full-route-contact-sheet", + "targeted_sheet_path": "targeted-windows-contact-sheet", + } + artifacts = [ + { + "role": role, + "path": paths[key].name, + "byte_length": paths[key].stat().st_size, + "sha256": f"{index:x}" * 64, + } + for index, (key, role) in enumerate(roles.items(), 1) + ] + return { + "result_id": result_id, + "result_root": result_root, + **paths, + "manifest": {"artifacts": artifacts}, + "report": { + "created_at_utc": "2026-08-04T16:10:00.000Z", + "status": "semantic-regression-suppressed-awaiting-temporal-layer", + "baseline_result_id": f"e46h-full-rectified-front-replay-{'b' * 64}", + "source": { + "camera_source_id": "sensor.camera.right", + "session_id": "20260720T065719Z_viewer_live", + "view": "front", + "projection_resolution": [960, 544], + "video_frame_count": 4488, + "video_frame_rate": 10.0, + "video_duration_seconds": 448.8, + }, + "provider": {"name": "NVIDIA Grounding DINO"}, + "inference": {"confidence_threshold": 0.5}, + "metrics": {"frame_count": 4488}, + "shadow_gate": { + "legacy_large_false_background_cases_suppressed": 5, + }, + "visual_review": { + "complete_video_playback_completed": True, + "review_windows": [{"id": str(index)} for index in range(6)], + }, + "acceptance": {"candidate_accepted": False}, + "decision": {"provider_promoted": False}, + "method": { + "schema_version": "missioncore.laboratory-method/v1", + "completeness": "complete", + "execution_class": "hybrid", + "pipeline_id": "e46i-test/v1", + "components": [], + }, + "limitations": ["not truth"], + "authority": { + "ground_truth": False, + "independent_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + }, + } diff --git a/tests/test_e46j_raw_fisheye_realtime_api.py b/tests/test_e46j_raw_fisheye_realtime_api.py new file mode 100644 index 0000000..898dcf8 --- /dev/null +++ b/tests/test_e46j_raw_fisheye_realtime_api.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.web import e46j_raw_fisheye_realtime_api + + +def test_e46j_projects_full_raw_video_and_known_shadow_exception( + tmp_path: Path, + monkeypatch, +) -> None: + result_id = f"e46j-raw-fisheye-realtime-{'a' * 64}" + root = tmp_path / "results" + result_root = root / result_id + result_root.mkdir(parents=True) + paths = { + "overlay_path": result_root / "overlay.mp4", + "route_sheet_path": result_root / "route.png", + "targeted_sheet_path": result_root / "targeted.png", + "shadow_sheet_path": result_root / "shadow.png", + } + for index, path in enumerate(paths.values(), 1): + path.write_bytes(bytes(range(index, index + 16))) + frozen = _frozen(result_id, result_root, paths) + monkeypatch.setattr( + e46j_raw_fisheye_realtime_api, + "read_e46j_raw_fisheye_realtime", + lambda _: frozen, + ) + app = FastAPI() + app.include_router( + e46j_raw_fisheye_realtime_api.build_e46j_raw_fisheye_realtime_router( + root_provider=lambda: root + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/laboratory/e46j/results?limit=1") + assert response.status_code == 200 + item = response.json()["items"][0] + assert item["status"] == "realtime-capacity-passed-awaiting-temporal-layer" + assert item["source"]["resolution"] == [800, 600] + assert item["metrics"]["core_capacity_fps"] == 47.84049 + assert item["metrics"]["operator_shadow_person_frame_count"] == 35 + assert item["video"]["frame_count"] == 4489 + assert item["video"]["duration_seconds"] == 448.723 + assert set(item["visuals"]) == { + "full_route", + "targeted_windows", + "operator_shadow", + } + assert item["ground_truth"] is False + assert item["authority"]["provider_promoted"] is False + + video = client.get( + f"/api/v1/laboratory/e46j/results/{result_id}/overlay.mp4", + headers={"Range": "bytes=4-9"}, + ) + assert video.status_code == 206 + assert video.content == bytes(range(5, 11)) + assert video.headers["cache-control"].endswith("immutable") + + visual = client.get( + f"/api/v1/laboratory/e46j/results/{result_id}/visual/operator-shadow.png" + ) + assert visual.status_code == 200 + assert visual.headers["content-type"] == "image/png" + + +def test_e46j_catalog_is_empty_when_not_configured() -> None: + app = FastAPI() + app.include_router( + e46j_raw_fisheye_realtime_api.build_e46j_raw_fisheye_realtime_router() + ) + response = TestClient(app).get("/api/v1/laboratory/e46j/results") + assert response.status_code == 200 + assert response.json()["configured"] is False + assert response.json()["items"] == [] + + +def _frozen( + result_id: str, + result_root: Path, + paths: dict[str, Path], +) -> dict[str, object]: + roles = { + "overlay_path": "visual-overlay-video", + "route_sheet_path": "full-route-contact-sheet", + "targeted_sheet_path": "targeted-windows-contact-sheet", + "shadow_sheet_path": "operator-shadow-contact-sheet", + } + artifacts = [ + { + "role": role, + "path": paths[key].name, + "byte_length": paths[key].stat().st_size, + "sha256": f"{index:x}" * 64, + } + for index, (key, role) in enumerate(roles.items(), 1) + ] + authority = { + "ground_truth": False, + "provider_promoted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + return { + "result_id": result_id, + "result_root": result_root, + **paths, + "manifest": {"artifacts": artifacts}, + "report": { + "created_at_utc": "2026-08-04T18:20:00.000Z", + "status": "realtime-capacity-passed-awaiting-temporal-layer", + "source": { + "camera_source_id": "sensor.camera.right", + "session_id": "20260720T065719Z_viewer_live", + "resolution": [800, 600], + "frame_count": 4489, + "frame_rate": 10.003944527024467, + "calibration_model": "KB4", + }, + "detector": {"architecture": "YOLOX-S"}, + "preprocessing": {"resize": "bilinear-letterbox-top-left"}, + "detection": {"minimum_score": 0.5}, + "metrics": { + "frame_count": 4489, + "core_capacity_fps": 47.84049, + "operator_shadow_person_frame_count": 35, + }, + "visual_review": { + "review_windows": [{"id": str(index)} for index in range(6)], + }, + "acceptance": {"ten_hz_capacity_gate_passed": True}, + "decision": {"provider_promoted": False}, + "method": { + "schema_version": "missioncore.laboratory-method/v1", + "completeness": "complete", + "execution_class": "hybrid", + "pipeline_id": "e46j-test/v1", + "components": [], + }, + "limitations": ["not truth"], + "authority": authority, + }, + } diff --git a/tests/test_l33_camera_first_detector_review_api.py b/tests/test_l33_camera_first_detector_review_api.py new file mode 100644 index 0000000..6ecfdb4 --- /dev/null +++ b/tests/test_l33_camera_first_detector_review_api.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +from fastapi import APIRouter +from fastapi.routing import APIRoute + +from k1link.compute.l33_camera_first_admission import ( + RAVNOVES00_ADMITTED_WORLD_STATE_RESULT_ID, + RAVNOVES00_SESSION_ID, +) +from k1link.compute.l33_camera_first_detector_review import ( + L33CameraFirstDetectorReviewError, + build_l33_camera_first_detector_review, +) +from k1link.web.l33_camera_first_detector_review_api import ( + build_l33_camera_first_detector_review_router, + latest_l33_identity, +) + + +def _canonical_json(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _endpoint(router: APIRouter, path: str) -> object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and "GET" in route.methods + ): + return route.endpoint + raise AssertionError(f"GET {path} route is missing") + + +def _write_candidate( + root: Path, + *, + world_state_result_id: str, + created_at_utc: str, +) -> Path: + identity = { + "schema_version": "missioncore.l33-camera-first-detector-review/v1", + "source_session_id": RAVNOVES00_SESSION_ID, + "source_l32_result_id": f"l32-pointpillars-camera-review-{'1' * 64}", + "source_e26_result_id": f"e10-integrated-perception-{'2' * 64}", + "source_e29_result_id": f"e29-camera-geometry-{'3' * 64}", + "source_e10_pack_id": f"e10-lidar-pack-{'4' * 64}", + "source_world_state_result_id": world_state_result_id, + "detector": {"id": "yolox_s"}, + "semantic_contract": {"owner": "camera"}, + "geometry_contract": {"owner": "lidar"}, + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + result_id = f"l33-camera-first-detector-review-{identity_sha256}" + candidate = root / result_id + candidate.mkdir(parents=True) + catalog = { + "schema_version": "missioncore.l33-camera-first-detector-review-catalog/v1", + "result_id": result_id, + "source_session_id": RAVNOVES00_SESSION_ID, + "frame_count": 1, + "frames": [ + { + "frame_id": "000001", + "detail_path": "frame-000001.json", + "detail_sha256": "5" * 64, + "detail_byte_length": 1, + "camera_path": "frames/frame-000001.jpg", + "camera_sha256": "6" * 64, + "semantic_provenance": "rectified-detector-current", + } + ], + } + catalog_path = candidate / "catalog.json" + catalog_path.write_text(json.dumps(catalog), encoding="utf-8") + manifest = { + "schema_version": "missioncore.l33-camera-first-detector-review/v1", + "result_id": result_id, + "identity": identity, + "identity_sha256": identity_sha256, + "created_at_utc": created_at_utc, + "status": "camera-first-boundary-restored-shadow-only", + "metrics": {}, + "catalog": { + "path": "catalog.json", + "sha256": _sha256(catalog_path), + "byte_length": catalog_path.stat().st_size, + }, + "limitations": [], + "authority": { + "shadow_only": True, + "accuracy_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + "ground_truth": False, + } + (candidate / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + return candidate + + +def test_catalog_and_latest_ignore_newer_unadmitted_parent(tmp_path: Path) -> None: + admitted = _write_candidate( + tmp_path, + world_state_result_id=RAVNOVES00_ADMITTED_WORLD_STATE_RESULT_ID, + created_at_utc="2026-07-31T17:23:36.806+00:00", + ) + _write_candidate( + tmp_path, + world_state_result_id=f"rectified-camera-world-state-{'b' * 64}", + created_at_utc="2026-07-31T18:19:40.451+00:00", + ) + router = build_l33_camera_first_detector_review_router(root_provider=lambda: tmp_path) + catalog = _endpoint(router, "/api/v1/laboratory/l33/camera-first-detector-review/results")( + limit=10 + ) # type: ignore[operator] + + assert catalog["candidate_total"] == 2 + assert catalog["invalid_total"] == 1 + assert [item["result_id"] for item in catalog["items"]] == [admitted.name] + assert latest_l33_identity(lambda: tmp_path) == { + "result_id": admitted.name, + "created_at_utc": "2026-07-31T17:23:36.806+00:00", + } + + +def test_builder_rejects_unadmitted_world_state_before_reading_sources( + tmp_path: Path, +) -> None: + wrong_world_state = tmp_path / f"rectified-camera-world-state-{'b' * 64}" + wrong_world_state.mkdir() + + with pytest.raises( + L33CameraFirstDetectorReviewError, + match="not admitted for RAVNOVES00", + ): + build_l33_camera_first_detector_review( + l32_result_root=tmp_path / "missing-l32", + e26_result_root=tmp_path / "missing-e26", + e29_result_root=tmp_path / "missing-e29", + e10_pack_root=tmp_path / "missing-pack", + output_root=tmp_path / "output", + rectified_world_state_root=wrong_world_state, + ) diff --git a/tests/test_l34_right_yolox_truth_island_api.py b/tests/test_l34_right_yolox_truth_island_api.py new file mode 100644 index 0000000..6f40c47 --- /dev/null +++ b/tests/test_l34_right_yolox_truth_island_api.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +import hashlib +import json +import struct +import zlib +from pathlib import Path + +import pytest +from fastapi import APIRouter, HTTPException +from fastapi.routing import APIRoute + +from k1link.compute.e46_detector_truth_island import ( + E46_CONTRACT_NAME, + E46_MANIFEST_NAME, + E46_REFERENCES_NAME, + E46_REPORT_NAME, + E46_REVIEW_NAME, +) +from k1link.compute.l34_right_yolox_truth_island_freeze import ( + L34_MANIFEST_NAME, + L34_PREDICTIONS_NAME, + L34_REPORT_NAME, +) +from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router +from k1link.web.l34_annotation_api import ( + L34AnnotationCreateRequest, + L34AnnotationFrameRequest, + L34AnnotationObjectRequest, + L34AnnotationSaveRequest, + build_l34_annotation_router, +) +from k1link.web.l34_right_yolox_truth_island_api import ( + build_l34_right_yolox_truth_island_freeze_router, +) + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _endpoint(router: APIRouter, path: str, method: str = "GET") -> object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and method in route.methods + ): + return route.endpoint + raise AssertionError(f"{method} {path} route is missing") + + +def _write_result( + root: Path, + *, + truth_result_id: str | None = None, + truth_manifest_sha256: str | None = None, + source_image_sha256: str = "6" * 64, +) -> Path: + identity = { + "schema_version": "missioncore.l34-right-yolox-truth-island-freeze/v1", + "profile": { + "profile_id": "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1", + "candidate": { + "architecture": "YOLOX-S", + "model_sha256": "1" * 64, + "minimum_score": 0.25, + }, + }, + "source": { + "session_id": "20260720T065719Z_viewer_live", + "source_id": "sensor.camera.right", + "mode": "recorded-replay-only", + }, + "truth_island": { + "result_id": truth_result_id + or f"e46-detector-truth-island-{'2' * 64}", + "manifest_sha256": truth_manifest_sha256 or "4" * 64, + "truth_state": "labels-unavailable", + }, + "candidate": { + "l33_result_id": f"l33-camera-first-detector-review-{'3' * 64}", + "prediction_rows_sha256": "5" * 64, + }, + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + result_id = f"l34-right-yolox-truth-island-freeze-{identity_sha256}" + candidate = root / result_id + candidate.mkdir(parents=True) + report = { + "schema_version": "missioncore.l34-right-yolox-truth-island-report/v1", + "result_id": result_id, + "identity_sha256": identity_sha256, + "status": "predictions-frozen-awaiting-independent-truth", + "profile_id": "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1", + "pipeline_id": "kb4-core3-yolox-eomt-k1-lidar-e23-temporal/v1", + "source_session_id": "20260720T065719Z_viewer_live", + "camera_source_id": "sensor.camera.right", + "metrics": { + "frame_count": 1, + "temporal_group_count": 1, + "prediction_count": 1, + "frames_with_predictions": 1, + "class_counts": {"car": 1}, + "accuracy_metrics_available": False, + }, + "decision": { + "candidate_predictions_frozen": True, + "truth_labels_read": False, + "candidate_accepted": False, + "model_retraining_authorized": False, + "next_gate": "independent truth", + }, + "limitations": ["recorded right camera only"], + "authority": identity["authority"], + "access": "read-only", + } + prediction = { + "schema_version": "missioncore.l34-right-yolox-truth-island-prediction/v1", + "candidate_id": "yolox-s-kb4-core3", + "truth_island_sequence": 1, + "image_id": 1, + "frame_index": 1, + "session_seconds": 1.25, + "source_image_sha256": source_image_sha256, + "group_id": "anchor-test", + "predictions": [ + { + "label": "car", + "score": 0.9, + "bbox_xyxy": [1.0, 1.0, 6.0, 5.0], + } + ], + "truth_joined": False, + } + report_path = candidate / L34_REPORT_NAME + prediction_path = candidate / L34_PREDICTIONS_NAME + report_path.write_text(json.dumps(report), encoding="utf-8") + prediction_path.write_text(json.dumps(prediction) + "\n", encoding="utf-8") + manifest = { + "schema_version": "missioncore.l34-right-yolox-truth-island-freeze/v1", + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": "2026-08-01T00:30:00Z", + "ground_truth": False, + "artifacts": [ + { + "path": L34_REPORT_NAME, + "byte_length": report_path.stat().st_size, + "sha256": _sha256(report_path), + }, + { + "path": L34_PREDICTIONS_NAME, + "byte_length": prediction_path.stat().st_size, + "sha256": _sha256(prediction_path), + }, + ], + "authority": identity["authority"], + } + (candidate / L34_MANIFEST_NAME).write_text( + json.dumps(manifest), + encoding="utf-8", + ) + return candidate + + +def _png(width: int, height: int) -> bytes: + signature = b"\x89PNG\r\n\x1a\n" + + def chunk(kind: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + pixels = b"".join(b"\x00" + b"\x20\x40\x60" * width for _ in range(height)) + return ( + signature + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(pixels)) + + chunk(b"IEND", b"") + ) + + +def _write_visual_sources(root: Path) -> tuple[Path, Path, Path]: + pack_id = f"evaluation-pack-{'a' * 64}" + pack_root = root / "packs" + image_path = pack_root / pack_id / "images" / "valid-fov-fill" / "frame.png" + image_path.parent.mkdir(parents=True) + image_path.write_bytes(_png(8, 6)) + reference = { + "schema_version": "missioncore.e46-truth-island-image-reference/v1", + "truth_island_sequence": 1, + "image_id": 1, + "frame_index": 1, + "group_id": "anchor-test", + "role": "anchor", + "session_seconds": 1.25, + "source_path": "images/valid-fov-fill/frame.png", + "byte_length": image_path.stat().st_size, + "sha256": _sha256(image_path), + } + truth_parent = root / "truth" + truth_parent.mkdir() + identity = { + "source": { + "evaluation_pack_id": pack_id, + "source_id": "sensor.camera.right", + }, + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + truth_id = f"e46-detector-truth-island-{identity_sha256}" + truth_root = truth_parent / truth_id + truth_root.mkdir() + documents = { + E46_REPORT_NAME: { + "schema_version": "missioncore.e46-truth-island-preparation-report/v1", + "result_id": truth_id, + "identity_sha256": identity_sha256, + "blindness": { + "candidate_comparison_authorized": False, + "model_prelabels_included": False, + "predictions_included": False, + "truth_labels_available": False, + }, + "selection": {"frame_count": 1}, + }, + E46_REFERENCES_NAME: None, + E46_CONTRACT_NAME: { + "schema_version": "missioncore.e46-detector-blind-contract/v1", + "truth_state": "labels-unavailable", + "reviewer_package": { + "model_prelabels_included": False, + "model_predictions_included": False, + }, + }, + E46_REVIEW_NAME: { + "schema_version": "missioncore.e46-detector-review-template/v1", + "state": "prepared-unreviewed-no-prelabels", + "images": [], + }, + } + for name, document in documents.items(): + path = truth_root / name + if name == E46_REFERENCES_NAME: + path.write_text(json.dumps(reference) + "\n", encoding="utf-8") + else: + path.write_text(json.dumps(document), encoding="utf-8") + authority = identity["authority"] + manifest = { + "schema_version": "missioncore.e46-detector-truth-island/v1", + "result_id": truth_id, + "identity_sha256": identity_sha256, + "identity": identity, + "acceptance_state": "prepared-not-truth", + "artifacts": [ + { + "path": name, + "byte_length": (truth_root / name).stat().st_size, + "sha256": _sha256(truth_root / name), + } + for name in documents + ], + "authority": authority, + } + manifest_path = truth_root / E46_MANIFEST_NAME + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + result_root = root / "results" + result_root.mkdir() + result = _write_result( + result_root, + truth_result_id=truth_id, + truth_manifest_sha256=_sha256(manifest_path), + source_image_sha256=_sha256(image_path), + ) + return result, truth_parent, pack_root + + +def test_l34_catalog_is_path_free_and_truth_closed(tmp_path: Path) -> None: + result = _write_result(tmp_path) + router = build_l34_right_yolox_truth_island_freeze_router( + root_provider=lambda: tmp_path, + ) + catalog = _endpoint(router, "/api/v1/laboratory/l34/results")( + limit=1 + ) # type: ignore[operator] + + item = catalog["items"][0] + assert item["result_id"] == result.name + assert item["camera_source_id"] == "sensor.camera.right" + assert item["metrics"]["accuracy_metrics_available"] is False + assert item["decision"]["truth_labels_read"] is False + assert item["frames"] == [ + { + "truth_island_sequence": 1, + "image_id": 1, + "frame_index": 1, + "group_id": "anchor-test", + "prediction_count": 1, + "maximum_score": 0.9, + } + ] + assert "result_root" not in json.dumps(item) + + +def test_l34_visual_frame_is_hash_bound_and_path_free(tmp_path: Path) -> None: + result, truth_root, pack_root = _write_visual_sources(tmp_path) + router = build_l34_right_yolox_truth_island_freeze_router( + root_provider=lambda: result.parent, + truth_root_provider=lambda: truth_root, + evaluation_pack_root_provider=lambda: pack_root, + ) + frame = _endpoint( + router, + "/api/v1/laboratory/l34/results/{result_id}/frames/{sequence}", + )(result_id=result.name, sequence=1) # type: ignore[operator] + assert frame["camera"]["width"] == 8 + assert frame["camera"]["height"] == 6 + assert frame["predictions"][0]["bbox_xyxy"] == [1.0, 1.0, 6.0, 5.0] + assert frame["truth_labels_read"] is False + assert "source_path" not in json.dumps(frame) + + image_path = next(pack_root.rglob("frame.png")) + image_path.write_bytes(image_path.read_bytes() + b"changed") + with pytest.raises(HTTPException) as caught: + _endpoint( + router, + "/api/v1/laboratory/l34/results/{result_id}/frames/{sequence}", + )(result_id=result.name, sequence=1) # type: ignore[operator] + assert caught.value.status_code == 404 + + +def test_l34_annotation_source_is_prediction_free(tmp_path: Path) -> None: + result, truth_root, pack_root = _write_visual_sources(tmp_path) + router = build_l34_annotation_router( + root_provider=lambda: result.parent, + truth_root_provider=lambda: truth_root, + evaluation_pack_root_provider=lambda: pack_root, + annotation_root_provider=lambda: tmp_path / "annotations", + ) + + catalog = _endpoint( + router, + "/api/v1/laboratory/l34/results/{result_id}/annotation-source", + )(result_id=result.name) # type: ignore[operator] + frame = _endpoint( + router, + ( + "/api/v1/laboratory/l34/results/{result_id}" + "/annotation-source/frames/{sequence}" + ), + )(result_id=result.name, sequence=1) # type: ignore[operator] + seed = _endpoint( + router, + ( + "/api/v1/laboratory/l34/results/{result_id}" + "/annotation-seed/frames/{sequence}" + ), + )(result_id=result.name, sequence=1) # type: ignore[operator] + + serialized = json.dumps({"catalog": catalog, "frame": frame}) + assert catalog["frame_count"] == 1 + assert catalog["contract"]["classes"] == [ + "person", + "bicycle", + "motorcycle", + "car", + "heavy_vehicle", + "static_obstacle", + "animal", + ] + assert catalog["contract"]["unmapped_class"] == { + "value": "unmapped", + "proposed_label_required": True, + "normalization_state": "pending-adjudication", + } + assert frame["camera"]["width"] == 8 + assert frame["camera_url"].endswith("/annotation-source/frames/1/camera") + assert frame["model_material_included"] is False + for forbidden in ("predictions", "prediction_rows", "candidate", "score"): + assert forbidden not in serialized + assert seed == { + "schema_version": "missioncore.l34-annotation-seed/v1", + "result_id": result.name, + "truth_island_sequence": 1, + "source_sha256": _sha256(next(pack_root.rglob("frame.png"))), + "objects": [ + { + "object_id": "seed-1-1", + "category": "car", + "proposed_label": None, + "origin": "frozen_candidate_seed", + "box_xyxy": [1.0, 1.0, 6.0, 5.0], + "occluded": False, + "truncated": False, + } + ], + "assistance": { + "mode": "frozen-candidate-seeded", + "independent_truth_eligible": False, + }, + "model_material_included": True, + "access": "assisted-annotation-seed-read-only", + } + assert "score" not in json.dumps(seed) + + +def test_l34_annotation_sessions_are_revisioned_and_not_truth( + tmp_path: Path, +) -> None: + result, truth_root, pack_root = _write_visual_sources(tmp_path) + annotation_root = tmp_path / "annotations" + router = build_l34_annotation_router( + root_provider=lambda: result.parent, + truth_root_provider=lambda: truth_root, + evaluation_pack_root_provider=lambda: pack_root, + annotation_root_provider=lambda: annotation_root, + ) + create = _endpoint( + router, + "/api/v1/laboratory/l34/results/{result_id}/annotation-sessions", + "POST", + ) + created = create( # type: ignore[operator] + result_id=result.name, + request=L34AnnotationCreateRequest(idempotency_key="browser-create-1"), + ) + repeated = create( # type: ignore[operator] + result_id=result.name, + request=L34AnnotationCreateRequest(idempotency_key="browser-create-1"), + ) + assert repeated["session_id"] == created["session_id"] + assert created["title"] == "Разметка · LAB L3.4 · Рецензент 1" + assert created["revision"] == 0 + assert created["authority"]["ground_truth"] is False + assert created["assistance"] == { + "mode": "frozen-candidate-seeded", + "independent_truth_eligible": False, + } + + save = _endpoint( + router, + ( + "/api/v1/laboratory/l34/results/{result_id}" + "/annotation-sessions/{session_id}" + ), + "PUT", + ) + request = L34AnnotationSaveRequest( + expected_revision=0, + idempotency_key="browser-save-1", + title="Разметка · LAB L3.4 · Рецензент 1", + assistance_mode="frozen-candidate-seeded", + frames=[ + L34AnnotationFrameRequest( + truth_island_sequence=1, + reviewed=True, + hard_negative=False, + objects=[ + L34AnnotationObjectRequest( + object_id="box-1", + category="car", + origin="frozen_candidate_seed", + box_xyxy=[1.0, 1.0, 6.0, 5.0], + occluded=False, + truncated=True, + ), + L34AnnotationObjectRequest( + object_id="box-stroller", + category="unmapped", + proposed_label="Детская коляска", + box_xyxy=[0.0, 0.0, 2.0, 2.0], + occluded=False, + truncated=False, + ), + ], + ) + ], + ) + saved = save( # type: ignore[operator] + result_id=result.name, + session_id=created["session_id"], + request=request, + ) + replayed = save( # type: ignore[operator] + result_id=result.name, + session_id=created["session_id"], + request=request, + ) + assert saved["revision"] == 1 + assert replayed["revision"] == 1 + assert saved["progress"] == { + "reviewed_frame_count": 1, + "frame_count": 32, + "object_count": 2, + "complete": False, + } + assert saved["frames"][0]["source_sha256"] == _sha256( + next(pack_root.rglob("frame.png")) + ) + assert saved["frames"][0]["objects"][0]["proposed_label"] is None + assert saved["frames"][0]["objects"][0]["origin"] == "frozen_candidate_seed" + assert saved["frames"][0]["objects"][1]["proposed_label"] == "Детская коляска" + assert saved["contract_id"] == "l34-assisted-candidate-review/v1" + assert saved["blindness"]["model_prelabels_seen"] is True + assert "last_save_idempotency_key" not in json.dumps(saved) + + labels = _endpoint( + router, + "/api/v1/laboratory/l34/results/{result_id}/annotation-labels", + )(result_id=result.name) # type: ignore[operator] + assert labels["items"] == [ + { + "value": "Детская коляска", + "normalization_state": "pending-adjudication", + } + ] + + with pytest.raises(HTTPException) as missing_label: + save( # type: ignore[operator] + result_id=result.name, + session_id=created["session_id"], + request=L34AnnotationSaveRequest( + expected_revision=1, + idempotency_key="browser-save-unmapped-without-label", + title=request.title, + frames=[ + L34AnnotationFrameRequest( + truth_island_sequence=1, + reviewed=True, + hard_negative=False, + objects=[ + L34AnnotationObjectRequest( + object_id="box-unmapped", + category="unmapped", + box_xyxy=[0.0, 0.0, 2.0, 2.0], + occluded=False, + truncated=False, + ) + ], + ) + ], + ), + ) + assert missing_label.value.status_code == 422 + + with pytest.raises(HTTPException) as caught: + save( # type: ignore[operator] + result_id=result.name, + session_id=created["session_id"], + request=request.model_copy( + update={"idempotency_key": "browser-save-stale"} + ), + ) + assert caught.value.status_code == 409 + + +def test_advanced_index_includes_l34_by_real_creation_time(tmp_path: Path) -> None: + result = _write_result(tmp_path) + router = build_advanced_laboratory_router( + l34_root_provider=lambda: tmp_path, + ) + index = _endpoint(router, "/api/v1/laboratory/advanced-index")() + + assert index["items"] == [ + { + "work_id": "l34-right-yolox-truth-island-freeze", + "result_id": result.name, + "created_at_utc": "2026-08-01T00:30:00Z", + "access": "read-only", + } + ] diff --git a/tests/test_l34d_blind_annotation_api.py b/tests/test_l34d_blind_annotation_api.py new file mode 100644 index 0000000..b8d3a4f --- /dev/null +++ b/tests/test_l34d_blind_annotation_api.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import json +import struct +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import APIRouter, HTTPException +from fastapi.routing import APIRoute + +from k1link.web import l34_annotation_api +from k1link.web.l34_annotation_api import ( + L34AnnotationCreateRequest, + L34AnnotationFrameRequest, + L34AnnotationObjectRequest, + L34AnnotationSaveRequest, + build_l34d_blind_annotation_router, +) + + +def _endpoint(router: APIRouter, path: str, method: str = "GET") -> object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and method in route.methods + ): + return route.endpoint + raise AssertionError(f"{method} {path} route is missing") + + +def _png_header(width: int, height: int) -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + struct.pack(">I", 13) + b"IHDR" + struct.pack(">II", width, height) + ) + + +def _router(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[APIRouter, str]: + result_id = f"l34d-cumulative-postprocessing-candidate-{'d' * 64}" + image_path = tmp_path / "clean-source.png" + image_path.write_bytes(_png_header(800, 600)) + reference = { + "truth_island_sequence": 1, + "image_id": 15, + "frame_index": 1248, + "group_id": "clip-stroller-person", + "role": "temporal", + "session_seconds": 160.142857292, + "sha256": "a" * 64, + } + case = { + "truth_island_sequence": 1, + "image_id": 15, + "frame_index": 1248, + "group_id": "clip-stroller-person", + "session_seconds": 160.142857292, + "source_image_sha256": "a" * 64, + } + result = { + "result_id": result_id, + "cases": [case], + "manifest": { + "identity": { + "l34_freeze": { + "result_id": f"l34-right-yolox-truth-island-freeze-{'b' * 64}", + } + } + }, + } + l34_result = SimpleNamespace( + manifest={ + "identity": { + "truth_island": { + "result_id": f"e46-detector-truth-island-{'c' * 64}", + } + } + } + ) + + def resolve(**kwargs: object): + assert kwargs["result_id"] == result_id + assert kwargs["sequence"] == 1 + return result, case, l34_result, reference, image_path + + monkeypatch.setattr(l34_annotation_api, "resolve_l34d_case_source", resolve) + router = build_l34d_blind_annotation_router( + annotation_root_provider=lambda: tmp_path / "blind-annotations", + ) + return router, result_id + + +def test_l34d_blind_source_physically_excludes_candidate_material( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router, result_id = _router(tmp_path, monkeypatch) + catalog = _endpoint( + router, + "/api/v1/laboratory/l34d/results/{result_id}/annotation-source", + )(result_id=result_id) # type: ignore[operator] + frame = _endpoint( + router, + ("/api/v1/laboratory/l34d/results/{result_id}/annotation-source/frames/{sequence}"), + )(result_id=result_id, sequence=1) # type: ignore[operator] + + assert catalog["contract"]["contract_id"] == ("l34d-prediction-hidden-review/v1") + assert catalog["candidate_predictions_included"] is False + assert catalog["prelabels_included"] is False + assert frame["candidate_predictions_included"] is False + assert frame["prelabels_included"] is False + serialized = json.dumps({"catalog": catalog, "frame": frame}) + for forbidden in ("bbox_xyxy", "objects", "score", "origin"): + assert forbidden not in serialized + with pytest.raises(AssertionError): + _endpoint( + router, + ("/api/v1/laboratory/l34d/results/{result_id}/annotation-seed/frames/{sequence}"), + ) + + +def test_l34d_blind_session_rejects_seeded_objects( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router, result_id = _router(tmp_path, monkeypatch) + created = _endpoint( + router, + "/api/v1/laboratory/l34d/results/{result_id}/annotation-sessions", + "POST", + )( + result_id=result_id, + request=L34AnnotationCreateRequest(idempotency_key="blind-create-1"), + ) # type: ignore[operator] + + assert created["title"] == "Разметка · LAB L3.4D · Рецензент 1" + assert created["contract_id"] == "l34d-prediction-hidden-review/v1" + assert created["assistance"] == { + "mode": "prediction-hidden-manual", + "independent_truth_eligible": False, + } + assert created["blindness"] == { + "candidate_identity_seen": True, + "model_prelabels_seen": False, + "model_predictions_seen": False, + "model_scores_seen": False, + } + + save = _endpoint( + router, + ("/api/v1/laboratory/l34d/results/{result_id}/annotation-sessions/{session_id}"), + "PUT", + ) + saved = save( # type: ignore[operator] + result_id=result_id, + session_id=created["session_id"], + request=L34AnnotationSaveRequest( + expected_revision=0, + idempotency_key="blind-save-1", + title=created["title"], + assistance_mode="prediction-hidden-manual", + frames=[ + L34AnnotationFrameRequest( + truth_island_sequence=1, + reviewed=True, + hard_negative=False, + objects=[ + L34AnnotationObjectRequest( + object_id="box-stroller", + category="unmapped", + proposed_label="Детская коляска", + origin="manual", + box_xyxy=[40.0, 50.0, 240.0, 320.0], + occluded=False, + truncated=False, + ) + ], + ) + ], + ), + ) + assert saved["revision"] == 1 + assert saved["frames"][0]["objects"][0]["origin"] == "manual" + + with pytest.raises(HTTPException) as caught: + save( # type: ignore[operator] + result_id=result_id, + session_id=created["session_id"], + request=L34AnnotationSaveRequest( + expected_revision=1, + idempotency_key="blind-save-seed", + title=created["title"], + assistance_mode="prediction-hidden-manual", + frames=[ + L34AnnotationFrameRequest( + truth_island_sequence=1, + reviewed=True, + hard_negative=False, + objects=[ + L34AnnotationObjectRequest( + object_id="seed-1-1", + category="car", + origin="frozen_candidate_seed", + box_xyxy=[10.0, 10.0, 100.0, 100.0], + occluded=False, + truncated=False, + ) + ], + ) + ], + ), + ) + assert caught.value.status_code == 422 diff --git a/tests/test_l34e_self_review_diagnostic_api.py b/tests/test_l34e_self_review_diagnostic_api.py new file mode 100644 index 0000000..afc57e8 --- /dev/null +++ b/tests/test_l34e_self_review_diagnostic_api.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter +from fastapi.routing import APIRoute +from pytest import MonkeyPatch + +import k1link.web.l34e_self_review_diagnostic_api as l34e_api +from k1link.web.l34e_self_review_diagnostic_api import ( + build_l34e_self_review_diagnostic_router, +) + + +def _endpoint(router: APIRouter, path: str) -> object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and "GET" in route.methods + ): + return route.endpoint + raise AssertionError(f"GET {path} route is missing") + + +def test_l34e_catalog_projects_read_only_diagnostic( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + digest = "a" * 64 + result_id = f"l34e-self-review-diagnostic-{digest}" + candidate = tmp_path / result_id + candidate.mkdir() + (candidate / "manifest.json").write_text("{}", encoding="utf-8") + case = { + "truth_island_sequence": 1, + "image_id": 2, + "frame_index": 70, + "group_id": "anchor-002", + "session_seconds": 10.0, + "source_image_sha256": "b" * 64, + "strict_summary": {"true_positive": 1}, + "diagnostic_summary": { + "associated_pair_count": 1, + "prediction_only": 0, + "reference_only": 0, + }, + } + result = { + "result_id": result_id, + "report": { + "created_at_utc": "2026-08-03T17:21:06Z", + "status": "completed-self-review-diagnostic-not-truth", + "profile": {"profile_id": "l34e-self-review-diagnostic/v1"}, + "method": { + "pipeline_id": "ravnoves00-right-yolox-self-review-diagnostic/v1" + }, + "source_session_id": "RAVNOVES00", + "camera_source_id": "sensor.camera.right", + "metrics": { + "reference_quality": "not-metric-grade-self-review" + }, + "case_order": list(range(1, 33)), + "decision": { + "candidate_accepted": False, + "detector_retuning_authorized": False, + }, + "limitations": ["not independent truth"], + "authority": { + "ground_truth": False, + "candidate_accepted": False, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + }, + "cases": tuple( + { + **case, + "truth_island_sequence": sequence, + } + for sequence in range(1, 33) + ), + } + + def fake_read(path: Path) -> dict[str, object]: + assert path == candidate + return result + + monkeypatch.setattr(l34e_api, "read_l34e_self_review_diagnostic", fake_read) + router = build_l34e_self_review_diagnostic_router( + diagnostic_root_provider=lambda: tmp_path, + ) + route = _endpoint(router, "/api/v1/laboratory/l34e/results") + + catalog = route(limit=1) # type: ignore[operator] + assert catalog["schema_version"] == ( + "missioncore.l34e-self-review-diagnostic-catalog/v1" + ) + assert catalog["candidate_total"] == 1 + assert catalog["invalid_total"] == 0 + item = catalog["items"][0] + assert item["result_id"] == result_id + assert item["ground_truth"] is False + assert item["access"] == "read-only" + assert item["metrics"]["reference_quality"] == ( + "not-metric-grade-self-review" + ) + assert len(item["cases"]) == 32 + + +def test_l34e_catalog_is_empty_when_not_configured() -> None: + router = build_l34e_self_review_diagnostic_router() + route = _endpoint(router, "/api/v1/laboratory/l34e/results") + + assert route(limit=1) == { # type: ignore[operator] + "schema_version": "missioncore.l34e-self-review-diagnostic-catalog/v1", + "configured": False, + "items": [], + "candidate_total": 0, + "invalid_total": 0, + "access": "read-only", + } diff --git a/tests/test_l34f_adjudication_api.py b/tests/test_l34f_adjudication_api.py new file mode 100644 index 0000000..287c0a7 --- /dev/null +++ b/tests/test_l34f_adjudication_api.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import struct +from pathlib import Path + +import pytest +from fastapi import APIRouter, HTTPException +from fastapi.routing import APIRoute + +from k1link.web import l34f_adjudication_api +from k1link.web.l34f_adjudication_api import ( + L34FCreateRequest, + L34FFrameRequest, + L34FFreezeRequest, + L34FObjectRequest, + L34FSaveRequest, + build_l34f_adjudication_router, +) + + +def _endpoint(router: APIRouter, path: str, method: str = "GET") -> object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and method in route.methods + ): + return route.endpoint + raise AssertionError(f"{method} {path} route is missing") + + +def _png_header(width: int, height: int) -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + struct.pack(">I", 13) + b"IHDR" + struct.pack(">II", width, height) + ) + + +def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + diagnostic_id = f"l34e-self-review-diagnostic-{'e' * 64}" + diagnostic_root = tmp_path / "diagnostics" + result_root = diagnostic_root / diagnostic_id + result_root.mkdir(parents=True) + image = tmp_path / "frame.png" + image.write_bytes(_png_header(800, 600)) + cases = [] + for sequence in range(1, 33): + cases.append( + { + "truth_island_sequence": sequence, + "image_id": sequence, + "frame_index": sequence * 10, + "group_id": f"group-{sequence}", + "session_seconds": float(sequence), + "source_image_sha256": "a" * 64, + "predictions": [ + { + "prediction_index": 1, + "category": "car", + "score": 0.91, + "box_xyxy": [10.0, 20.0, 100.0, 120.0], + "diagnostic_verdict": "localization_disagreement", + "associated_object_id": f"self-{sequence:02d}-01", + } + ], + "references": [ + { + "object_id": f"self-{sequence:02d}-01", + "category": "car", + "proposed_label": None, + "box_xyxy": [12.0, 22.0, 102.0, 122.0], + "occluded": False, + "truncated": False, + } + ], + "diagnostic_summary": {"severity_score": 1}, + } + ) + diagnostic = { + "result_id": diagnostic_id, + "result_root": result_root, + "manifest": { + "identity": {"self_review": {"session_id": f"l34-annotation-session-{'b' * 64}"}} + }, + "report": {"source_session_id": "RAVNOVES00"}, + "cases": tuple(cases), + } + monkeypatch.setattr( + l34f_adjudication_api, + "read_l34e_self_review_diagnostic", + lambda path: diagnostic, + ) + + def resolve(**kwargs: object): + sequence = int(kwargs["sequence"]) + return diagnostic, cases[sequence - 1], image + + monkeypatch.setattr(l34f_adjudication_api, "resolve_l34e_case_source", resolve) + router = build_l34f_adjudication_router( + diagnostic_root_provider=lambda: diagnostic_root, + adjudication_root_provider=lambda: tmp_path / "adjudication", + result_root_provider=lambda: tmp_path / "frozen", + ) + return router, diagnostic_id, diagnostic + + +def test_l34f_case_separates_candidate_and_hides_scores( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router, diagnostic_id, _ = _fixture(tmp_path, monkeypatch) + case = _endpoint( + router, + "/api/v1/laboratory/l34f/diagnostics/{diagnostic_id}/cases/{sequence}", + )(diagnostic_id=diagnostic_id, sequence=1) # type: ignore[operator] + + assert case["model_scores_included"] is False + assert case["ground_truth"] is False + assert case["candidate_objects"][0]["category"] == "car" + assert "score" not in case["candidate_objects"][0] + assert "references" not in case + + +def test_l34f_session_is_revisioned_and_freezes_only_at_32_of_32( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router, diagnostic_id, diagnostic = _fixture(tmp_path, monkeypatch) + created = _endpoint( + router, + "/api/v1/laboratory/l34f/diagnostics/{diagnostic_id}/sessions", + "POST", + )( + diagnostic_id=diagnostic_id, + request=L34FCreateRequest(idempotency_key="create-l34f-1"), + ) # type: ignore[operator] + assert created["revision"] == 0 + assert created["progress"]["reviewed_frame_count"] == 0 + assert len(created["frames"]) == 32 + assert created["frames"][0]["objects"][0]["origin"] == "self_review_seed" + assert created["authority"]["ground_truth"] is False + + frames = [] + for source in diagnostic["cases"]: + reference = source["references"][0] + frames.append( + L34FFrameRequest( + truth_island_sequence=source["truth_island_sequence"], + reviewed=source["truth_island_sequence"] != 32, + objects=[ + L34FObjectRequest( + object_id=reference["object_id"], + category="car", + proposed_label=None, + origin="self_review_seed", + box_xyxy=reference["box_xyxy"], + occluded=False, + truncated=False, + ) + ], + ) + ) + save = _endpoint( + router, + "/api/v1/laboratory/l34f/diagnostics/{diagnostic_id}/sessions/{session_id}", + "PUT", + ) + saved = save( # type: ignore[operator] + diagnostic_id=diagnostic_id, + session_id=created["session_id"], + request=L34FSaveRequest( + expected_revision=0, + idempotency_key="save-l34f-1", + title=created["title"], + frames=frames, + ), + ) + assert saved["revision"] == 1 + assert saved["progress"]["reviewed_frame_count"] == 31 + + freeze = _endpoint( + router, + ("/api/v1/laboratory/l34f/diagnostics/{diagnostic_id}/sessions/{session_id}/freeze"), + "POST", + ) + with pytest.raises(HTTPException) as caught: + freeze( # type: ignore[operator] + diagnostic_id=diagnostic_id, + session_id=created["session_id"], + request=L34FFreezeRequest(expected_revision=1), + ) + assert caught.value.status_code == 422 + + frames[-1].reviewed = True + completed = save( # type: ignore[operator] + diagnostic_id=diagnostic_id, + session_id=created["session_id"], + request=L34FSaveRequest( + expected_revision=1, + idempotency_key="save-l34f-2", + title=created["title"], + frames=frames, + ), + ) + assert completed["progress"]["complete"] is True + + def build(**kwargs: object): + assert Path(kwargs["adjudication_session_path"]).is_file() + return { + "result_id": f"l34f-adjudicated-reference-{'f' * 64}", + "manifest": { + "identity": { + "l34e_diagnostic": {"result_id": diagnostic_id}, + "adjudication_session": {"session_id": created["session_id"]}, + } + }, + "report": { + "created_at_utc": "2026-08-03T00:00:00.000Z", + "status": "completed-candidate-visible-adjudication-not-truth", + "source_session_id": "RAVNOVES00", + "camera_source_id": "sensor.camera.right", + "metrics": {"frame_count": 32}, + "decision": {"candidate_accepted": False}, + "limitations": [], + "authority": completed["authority"], + }, + } + + monkeypatch.setattr( + l34f_adjudication_api, + "build_l34f_adjudicated_reference", + build, + ) + frozen = freeze( # type: ignore[operator] + diagnostic_id=diagnostic_id, + session_id=created["session_id"], + request=L34FFreezeRequest(expected_revision=2), + ) + assert frozen["ground_truth"] is False + assert frozen["decision"]["candidate_accepted"] is False