From 55b7821a5cfc74400f54f1798a38e5c0c6da869e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 31 Jul 2026 14:35:34 +0300 Subject: [PATCH] feat(perception): run PointPillars on RAVNOVES00 --- ...POINTPILLARS_RAVNOVES_REPORT_2026-07-31.md | 123 +++ ...POINTPILLARS_TRANSFER_REPORT_2026-07-31.md | 14 +- .../prepare_l3_pointpillars_worker_package.py | 6 + .../worker/run_l31_pointpillars_ravnoves.py | 777 ++++++++++++++++++ .../compute/l31_pointpillars_ravnoves.py | 135 +++ src/k1link/web/advanced_laboratory_api.py | 14 +- src/k1link/web/app.py | 27 +- .../web/l31_pointpillars_ravnoves_api.py | 392 +++++++++ tests/test_advanced_laboratory_api.py | 30 + tests/test_l31_pointpillars_ravnoves.py | 49 ++ tests/test_l31_pointpillars_ravnoves_api.py | 202 +++++ 11 files changed, 1760 insertions(+), 9 deletions(-) create mode 100644 experiments/perception/L31_POINTPILLARS_RAVNOVES_REPORT_2026-07-31.md create mode 100644 experiments/perception/worker/run_l31_pointpillars_ravnoves.py create mode 100644 src/k1link/compute/l31_pointpillars_ravnoves.py create mode 100644 src/k1link/web/l31_pointpillars_ravnoves_api.py create mode 100644 tests/test_l31_pointpillars_ravnoves.py create mode 100644 tests/test_l31_pointpillars_ravnoves_api.py diff --git a/experiments/perception/L31_POINTPILLARS_RAVNOVES_REPORT_2026-07-31.md b/experiments/perception/L31_POINTPILLARS_RAVNOVES_REPORT_2026-07-31.md new file mode 100644 index 0000000..0c2e1a5 --- /dev/null +++ b/experiments/perception/L31_POINTPILLARS_RAVNOVES_REPORT_2026-07-31.md @@ -0,0 +1,123 @@ +# L3.1 · PointPillars на RAVNOVES00 — 2026-07-31 + +## Решение + +`L3.1` — это полный transfer-прогон текущего PointPillars-кандидата по +реальной записи нашего сканера `RAVNOVES00`. Внешний KITTI в эту лабораторную +работу не входит: прежний `L3` сохранён отдельно как публичный benchmark для +проверки измерителя и идеального box-truth контура. + +Полный runtime-контракт на RAVNOVES00 выполнен, но модель не допускается в +operational detector. У записи нет независимых ориентированных 3D truth-боксов, +поэтому полученные боксы являются только визуально проверяемыми гипотезами. +Кроме того, повтор выбранных кадров совпал только в `50%` случаев. + +Неизменяемый результат: + +`l31-pointpillars-ravnoves-80a9715f64ea397222fbcfd700803f9152009e3751caf87e2e9dc5ec6fc01b72` + +## Источник + +- запись: `RAVNOVES00`; +- канонический session id: `20260720T065719Z_viewer_live`; +- replay pack: + `lidar-replay-pack-8fc0fb418578b8ee2ac88d502d2acbc63ae533437a9da14f1a9f9d8916f613ce`; +- logical content SHA-256: + `8d5642e5f394a48f5e07322e813384c601c96880f1ec349075f064a2d077b4ac`; +- LiDAR-кадров: `4,570`; +- pose-кадров: `4,598`; +- исходных точек: `10,751,258`. + +Каждая порция point cloud была связана с ближайшей pose. Точки, сохранённые в +map frame, детерминированно преобразованы обратно в sensor-frame XYZI, +ожидаемый моделью. Возраст pose binding не превысил `73.797625 ms`. + +## Исполнение + +Прогон выполнен на Worker 006 внутри уже работающего канонического контейнера +`ndc-mission-core-triton`: + +- второй контейнер и второй serving stack не создавались; +- существующий Triton не перезапускался; +- один последовательный worker; +- source pacing отключён; +- Synology не использовался; +- модель ONNX SHA-256: + `2dcabddc3a365e9608a112d7bbbb7db769a6dddeeaa59aa03611a83113326da1`; +- TensorRT engine SHA-256: + `12005d972a4632d56342a5da44442b632c1dcc5144fa3c70b162dec334532481`; +- worker package: + `l3-pointpillars-worker-package-6799b6f4ad776a4a959dc2d6366c6bcdf8ddb922990571c46841acf186e28c9b`. + +## Полный результат + +| Метрика | Значение | +| --- | ---: | +| Принято входных кадров | `4,570 / 4,570` (`100%`) | +| Валидных выходных контрактов | `4,570 / 4,570` (`100%`) | +| Кадров с гипотезами | `4,570` | +| Кадров с Vehicle-гипотезами | `4,559` | +| Всего гипотез | `48,005` | +| Vehicle | `28,097` | +| Pedestrian | `1,095` | +| Cyclist | `18,813` | +| Inference mean | `27.857662 ms` | +| Inference p50 | `26.324656 ms` | +| Inference p95 | `39.919944 ms` | +| Inference max | `143.260391 ms` | +| Повтор выбранных визуальных кадров | `9 / 18` (`50%`) | + +Идентичность полного набора per-frame результатов: + +`9e4bb0fe1fc7a31d77bbc7d4de08e9e42c1456d28728ae25f9cc88598f0ebac0` + +## Визуальное доказательство + +Из полного маршрута детерминированно выбраны `18` кадров: маршрут разделён на +равные временные окна, внутри каждого приоритет имеет число Vehicle-гипотез, +затем общее число гипотез и близость к центру окна. + +Для каждого выбранного кадра опубликованы: + +- ограниченная равномерная выборка реальных XYZI-точек RAVNOVES00; +- ориентированные 3D-боксы PointPillars; +- классы и confidence; +- исходная latency; +- результат повторного запуска того же кадра. + +Mission Core загружает только выбранный кадр и показывает его в общем +лабораторном viewer в режимах `3D` и `BEV`. В интерфейсе боксы явно обозначены +как гипотезы модели, а не ground truth, TP/FP/FN или подтверждённые объекты. + +## Что доказано + +- реальный RAVNOVES00 проходит через текущий PointPillars input/output seam без + пропуска кадров и нарушения схемы; +- существующий Worker 006 и Triton способны последовательно обработать полный + маршрут; +- задержка inference измерена на всём наборе, а не на коротком smoke; +- фактические точки и гипотезы можно проверять визуально в Mission Core. + +## Что не доказано + +- точность, полнота и классовая корректность детектора; +- TP/FP/FN и пригодность боксов для продукта; +- source-paced queue/drop/deadline поведение; +- навигация, команды или safety; +- замена camera-first семантики LiDAR-only моделью. + +Текущий source — vendor map increment без ring, firing time, scan geometry и +IMU. Модель обучена на другом proprietary solid-state LiDAR domain. + +## Следующий gate + +1. Разобрать визуально `18` опубликованных кадров и локализовать характер + доменного расхождения. +2. Выяснить причину `50%` повторяемости боксов при неизменном входе и sealed + engine. +3. Зафиксировать небольшой независимый RAVNOVES box-truth набор до любого + обучения или подбора порога. +4. Только после этого допускать новый train/adaptation candidate на Worker 006. +5. Основную продуктовую архитектуру сохранять как + `camera-first semantics + LiDAR metric geometry`; LiDAR-native detector + остаётся отдельным доказательным кандидатом. diff --git a/experiments/perception/L3_POINTPILLARS_TRANSFER_REPORT_2026-07-31.md b/experiments/perception/L3_POINTPILLARS_TRANSFER_REPORT_2026-07-31.md index a25fb3c..3c0285e 100644 --- a/experiments/perception/L3_POINTPILLARS_TRANSFER_REPORT_2026-07-31.md +++ b/experiments/perception/L3_POINTPILLARS_TRANSFER_REPORT_2026-07-31.md @@ -1,5 +1,11 @@ # L3 PointPillars transfer report — 2026-07-31 +> Контур размещения: это отдельный публичный benchmark на KITTI. Он сохранён +> для проверки измерителя, box-truth и cross-domain ideal/reference path, но не +> является основной лабораторной работой нашего сканера. Реальный прогон +> RAVNOVES00 оформлен отдельно как `L3.1`; см. +> `L31_POINTPILLARS_RAVNOVES_REPORT_2026-07-31.md`. + ## Decision The LiDAR-native detector seam is operational on Worker 006, but the frozen @@ -230,10 +236,10 @@ the sensor-domain mismatch. 4. Export a new ONNX, build its TensorRT engine on Worker 006 and evaluate through the already proven canonical Triton seam. 5. Require a useful public box-truth result before K1 accuracy language. -6. Run `RAVNOVES01` as a K1 representation/stability transfer after the public - candidate is useful. Because that session has no camera and no independent - 3D cuboids, it can prove deterministic replay, output stability, latency, - queue/drop behavior and visual plausibility, but not detector accuracy. +6. Keep the public KITTI benchmark outside the primary scanner profile. + RAVNOVES transfer is now the separate `L3.1` laboratory work; its completed + baseline and limitations are recorded in + `L31_POINTPILLARS_RAVNOVES_REPORT_2026-07-31.md`. The adaptation task is a new admission. This report does not authorize a training container, a second serving stack, K1 quality claims or navigation diff --git a/experiments/perception/prepare_l3_pointpillars_worker_package.py b/experiments/perception/prepare_l3_pointpillars_worker_package.py index 5626965..b0b376b 100644 --- a/experiments/perception/prepare_l3_pointpillars_worker_package.py +++ b/experiments/perception/prepare_l3_pointpillars_worker_package.py @@ -26,6 +26,9 @@ _SOURCE_FILES = { "runtime/k1link/compute/pointpillars_postprocess.py": ( "src/k1link/compute/pointpillars_postprocess.py" ), + "runtime/k1link/compute/l31_pointpillars_ravnoves.py": ( + "src/k1link/compute/l31_pointpillars_ravnoves.py" + ), "runtime/k1link/datasets/kitti_3d_admission.py": ( "src/k1link/datasets/kitti_3d_admission.py" ), @@ -41,6 +44,9 @@ _SOURCE_FILES = { "runtime/run_l3_pointpillars_public_baseline.py": ( "experiments/perception/worker/run_l3_pointpillars_public_baseline.py" ), + "runtime/run_l31_pointpillars_ravnoves.py": ( + "experiments/perception/worker/run_l31_pointpillars_ravnoves.py" + ), "runtime/smoke_l3_pointpillars_live.py": ( "experiments/perception/worker/smoke_l3_pointpillars_live.py" ), diff --git a/experiments/perception/worker/run_l31_pointpillars_ravnoves.py b/experiments/perception/worker/run_l31_pointpillars_ravnoves.py new file mode 100644 index 0000000..0110b67 --- /dev/null +++ b/experiments/perception/worker/run_l31_pointpillars_ravnoves.py @@ -0,0 +1,777 @@ +#!/usr/bin/env python3 +"""Run PointPillars sequentially over the sealed RAVNOVES00 K1 replay.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import tempfile +import time +import urllib.request +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import numpy as np + +from k1link.compute.l31_pointpillars_ravnoves import ( + nearest_pose_indices, + select_visual_frame_indices, + sensor_frame_xyzi, +) +from k1link.compute.pointpillars_postprocess import ( + POINTPILLARS_EMBEDDED_SCORE_THRESHOLD, + POINTPILLARS_MODEL_POINT_CLOUD_RANGE, + PointPillarsBox, + decode_pointpillars_output, +) + +RUN_SCHEMA = "missioncore.l31-pointpillars-ravnoves/v1" +FRAME_SCHEMA = "missioncore.l31-pointpillars-ravnoves-frame/v1" +CATALOG_SCHEMA = "missioncore.l31-pointpillars-ravnoves-catalog/v1" +VISUAL_FRAME_SCHEMA = "missioncore.l31-pointpillars-ravnoves-visual-frame/v1" +WORKER_PACKAGE_SCHEMA = "missioncore.l3-pointpillars-worker-package/v1" +REPLAY_SCHEMA = "missioncore.lidar-replay-pack/v2" +EXPECTED_PACK_ID = ( + "lidar-replay-pack-" + "8fc0fb418578b8ee2ac88d502d2acbc63ae533437a9da14f1a9f9d8916f613ce" +) +EXPECTED_REPLAY_SHA256 = ( + "cbb75341ca0d82aea6e59bec26636d06aa58d9c300e6fb5555033ab35b2bed7e" +) +EXPECTED_SESSION_ID = "20260720T065719Z_viewer_live" +EXPECTED_MODEL_SHA256 = ( + "2dcabddc3a365e9608a112d7bbbb7db769a6dddeeaa59aa03611a83113326da1" +) +EXPECTED_ENGINE_SHA256 = ( + "12005d972a4632d56342a5da44442b632c1dcc5144fa3c70b162dec334532481" +) +MODEL_NAME = "pointpillars" +MAXIMUM_POINTS = 204_800 +MAXIMUM_VISUAL_BOXES = 512 +MAXIMUM_VISUAL_FRAMES = 18 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--replay-pack", type=Path, required=True) + parser.add_argument("--worker-package", type=Path, required=True) + parser.add_argument("--triton-url", default="http://127.0.0.1:8000") + parser.add_argument( + "--output-root", + type=Path, + default=Path( + "/mnt/d/NDC_MISSIONCORE/runtime/experiments/l3/" + "pointpillars-ravnoves" + ), + ) + args = parser.parse_args() + + replay_root, replay_manifest, arrays = _read_replay(args.replay_pack) + package = _read_worker_package(args.worker_package) + _require_triton_ready(args.triton_url) + point_times = arrays["point_received_monotonic_ns"] + pose_indices, pose_age_ms = nearest_pose_indices( + point_times, + arrays["pose_received_monotonic_ns"], + ) + if float(np.max(pose_age_ms)) > 100.0: + raise RuntimeError("RAVNOVES00 contains a pose binding older than 100 ms") + + identity = { + "schema_version": RUN_SCHEMA, + "source_pack_id": replay_manifest["pack_id"], + "source_pack_identity_sha256": replay_manifest["identity_sha256"], + "source_logical_content_sha256": replay_manifest["identity"][ + "logical_content_sha256" + ], + "source_session_id": EXPECTED_SESSION_ID, + "point_frame_count": int(point_times.size), + "model": { + "name": MODEL_NAME, + "source_model_sha256": EXPECTED_MODEL_SHA256, + "engine_sha256": EXPECTED_ENGINE_SHA256, + "embedded_score_threshold": POINTPILLARS_EMBEDDED_SCORE_THRESHOLD, + "point_cloud_range": list(POINTPILLARS_MODEL_POINT_CLOUD_RANGE), + }, + "worker_package_id": package["package_id"], + "worker_package_identity_sha256": package["identity_sha256"], + "producer_sha256": _sha256(Path(__file__)), + "execution": { + "worker_host_id": "worker-006", + "sequential": True, + "parallel_workers": 1, + "source_paced": False, + "existing_triton_only": True, + }, + "authority": { + "shadow_only": True, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + "accuracy_accepted": False, + }, + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + result_id = f"l31-pointpillars-ravnoves-{identity_sha256}" + result_root = args.output_root.expanduser().absolute() / result_id + summaries_root = result_root / "frame-results" + visual_root = result_root / "visual-frames" + summaries_root.mkdir(mode=0o700, parents=True, exist_ok=True) + visual_root.mkdir(mode=0o700, parents=True, exist_ok=True) + _write_once(result_root / "identity.json", identity) + + point_offsets = arrays["point_offsets"] + first_time_ns = int(point_times[0]) + summaries: list[dict[str, Any]] = [] + for completed, frame_index in enumerate(range(point_times.size), start=1): + xyzi = _frame_xyzi(arrays, point_offsets, pose_indices, frame_index) + input_sha256 = hashlib.sha256( + np.ascontiguousarray(xyzi, dtype=np.float32).tobytes() + ).hexdigest() + frame_path = summaries_root / f"{frame_index:06d}.json" + if frame_path.exists(): + summary = _read_frame_summary( + frame_path, + frame_index=frame_index, + input_sha256=input_sha256, + ) + else: + boxes, elapsed_ms = _run_frame(args.triton_url, xyzi) + summary = _frame_summary( + frame_index=frame_index, + session_seconds=(int(point_times[frame_index]) - first_time_ns) + / 1_000_000_000.0, + source_point_count=int(xyzi.shape[0]), + pose_index=int(pose_indices[frame_index]), + pose_binding_age_ms=float(pose_age_ms[frame_index]), + input_sha256=input_sha256, + inference_ms=elapsed_ms, + boxes=boxes, + ) + _atomic_json(frame_path, summary) + summaries.append(summary) + if completed == 1 or completed % 100 == 0: + print( + json.dumps( + { + "result_id": result_id, + "completed": completed, + "total": int(point_times.size), + "frame_index": frame_index, + }, + sort_keys=True, + ), + flush=True, + ) + + selected_indices = select_visual_frame_indices( + [int(row["class_counts"]["Vehicle"]) for row in summaries], + [int(row["prediction_count"]) for row in summaries], + maximum_frames=MAXIMUM_VISUAL_FRAMES, + ) + visual_descriptors: list[dict[str, Any]] = [] + deterministic_matches = 0 + for frame_index in selected_indices: + xyzi = _frame_xyzi(arrays, point_offsets, pose_indices, frame_index) + boxes, elapsed_ms = _run_frame(args.triton_url, xyzi) + observed_sha256 = _boxes_sha256(boxes) + if observed_sha256 == summaries[frame_index]["boxes_sha256"]: + deterministic_matches += 1 + visual_payload = _visual_frame( + summary=summaries[frame_index], + xyzi=xyzi, + boxes=boxes, + replay_inference_ms=elapsed_ms, + deterministic_replay=( + observed_sha256 == summaries[frame_index]["boxes_sha256"] + ), + ) + visual_path = visual_root / f"{frame_index:06d}.json" + _atomic_json(visual_path, visual_payload) + descriptor = _artifact( + visual_path, + "visual-frame", + relative_to=result_root, + ) + visual_descriptors.append( + { + "frame_id": f"{frame_index:06d}", + "frame_index": frame_index, + "session_seconds": summaries[frame_index]["session_seconds"], + "source_point_count": summaries[frame_index]["source_point_count"], + "prediction_count": summaries[frame_index]["prediction_count"], + "class_counts": summaries[frame_index]["class_counts"], + "inference_ms": summaries[frame_index]["inference_ms"], + "detail_path": descriptor["path"], + "detail_sha256": descriptor["sha256"], + "detail_byte_length": descriptor["byte_length"], + } + ) + + metrics = _metrics( + summaries, + deterministic_matches=deterministic_matches, + deterministic_total=len(selected_indices), + ) + catalog = { + "schema_version": CATALOG_SCHEMA, + "result_id": result_id, + "source_session_id": EXPECTED_SESSION_ID, + "selection": { + "strategy": ( + "18 equal route-time bins; prefer Vehicle count, then total " + "prediction count, then bin-centre proximity" + ), + "maximum_frames": MAXIMUM_VISUAL_FRAMES, + "maximum_boxes_per_frame": MAXIMUM_VISUAL_BOXES, + }, + "frame_count": len(visual_descriptors), + "frames": visual_descriptors, + } + _atomic_json(result_root / "catalog.json", catalog) + manifest = { + "schema_version": RUN_SCHEMA, + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": datetime.now(UTC) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + "status": "k1-cross-domain-transfer-measured-visual-review-required", + "metrics": metrics, + "catalog": _artifact( + result_root / "catalog.json", + "visual-frame-catalog", + relative_to=result_root, + ), + "frame_results": { + "count": len(summaries), + "identity_sha256": _directory_identity(summaries_root), + }, + "limitations": [ + "RAVNOVES00 has no independent oriented 3D cuboid ground truth.", + "Predicted boxes are model hypotheses, not TP/FP/FN or accepted objects.", + "The K1 source is a vendor map increment without ring, firing time, " + "scan geometry, or IMU.", + "Execution is offline sequential and does not establish source-paced " + "drop or deadline behaviour.", + "The model was trained on another proprietary solid-state LiDAR domain.", + ], + "authority": identity["authority"], + } + _atomic_json(result_root / "manifest.json", manifest) + print( + json.dumps( + { + "result_id": result_id, + "frame_count": len(summaries), + "frames_with_predictions": metrics["frames_with_predictions"], + "vehicle_predictions": metrics["class_counts"]["Vehicle"], + "inference_p95_ms": metrics["inference_latency_ms"]["p95"], + "deterministic_replay_fraction": metrics[ + "deterministic_replay_fraction" + ], + }, + sort_keys=True, + ), + flush=True, + ) + del replay_root + return 0 + + +def _read_replay( + path: Path, +) -> tuple[Path, dict[str, Any], dict[str, np.ndarray]]: + root = path.expanduser().resolve(strict=True) + manifest = _read_json(root / "manifest.json") + identity = manifest.get("identity") + artifacts = manifest.get("artifacts") + if ( + not root.is_dir() + or root.is_symlink() + or root.name != EXPECTED_PACK_ID + or manifest.get("schema_version") != REPLAY_SCHEMA + or manifest.get("pack_id") != EXPECTED_PACK_ID + or manifest.get("identity_sha256") != EXPECTED_PACK_ID.removeprefix( + "lidar-replay-pack-" + ) + or not isinstance(identity, dict) + or identity.get("session_id") != EXPECTED_SESSION_ID + or identity.get("point_frame_count") != 4570 + or identity.get("pose_frame_count") != 4598 + or not isinstance(artifacts, list) + ): + raise RuntimeError("RAVNOVES00 replay identity is invalid") + descriptor = next( + ( + row + for row in artifacts + if isinstance(row, dict) and row.get("path") == "lidar-replay.npz" + ), + None, + ) + replay_path = root / "lidar-replay.npz" + if ( + not isinstance(descriptor, dict) + or descriptor.get("sha256") != EXPECTED_REPLAY_SHA256 + or descriptor.get("byte_length") != replay_path.stat().st_size + or _sha256(replay_path) != EXPECTED_REPLAY_SHA256 + ): + raise RuntimeError("RAVNOVES00 replay artifact changed") + expected = { + "point_offsets", + "point_xyz_map", + "point_intensity", + "point_received_monotonic_ns", + "pose_positions_map", + "pose_quaternions_map_from_lidar", + "pose_received_monotonic_ns", + } + with np.load(replay_path, allow_pickle=False) as archive: + if not expected.issubset(archive.files): + raise RuntimeError("RAVNOVES00 replay arrays changed") + # Load each required member once. Reopening a compressed NPZ member for + # every frame would repeatedly inflate the complete 10.7M-point array. + arrays = {name: archive[name] for name in expected} + if ( + arrays["point_offsets"].shape != (4571,) + or arrays["point_offsets"].dtype != np.int64 + or arrays["point_xyz_map"].shape != (10_751_258, 3) + or arrays["point_xyz_map"].dtype != np.float64 + or arrays["point_intensity"].shape != (10_751_258,) + or arrays["point_intensity"].dtype != np.uint8 + or arrays["point_received_monotonic_ns"].shape != (4570,) + or arrays["point_received_monotonic_ns"].dtype != np.int64 + or arrays["pose_positions_map"].shape != (4598, 3) + or arrays["pose_positions_map"].dtype != np.float64 + or arrays["pose_quaternions_map_from_lidar"].shape != (4598, 4) + or arrays["pose_quaternions_map_from_lidar"].dtype != np.float64 + or arrays["pose_received_monotonic_ns"].shape != (4598,) + or arrays["pose_received_monotonic_ns"].dtype != np.int64 + or int(arrays["point_offsets"][0]) != 0 + or int(arrays["point_offsets"][-1]) != 10_751_258 + or np.any(np.diff(arrays["point_offsets"]) <= 0) + ): + raise RuntimeError("RAVNOVES00 replay array contract changed") + return root, manifest, arrays + + +def _frame_xyzi( + arrays: dict[str, np.ndarray], + offsets: np.ndarray, + pose_indices: np.ndarray, + frame_index: int, +) -> np.ndarray: + start = int(offsets[frame_index]) + stop = int(offsets[frame_index + 1]) + pose_index = int(pose_indices[frame_index]) + xyzi = sensor_frame_xyzi( + arrays["point_xyz_map"][start:stop], + arrays["point_intensity"][start:stop], + position_map_xyz=arrays["pose_positions_map"][pose_index], + orientation_map_from_lidar_xyzw=arrays[ + "pose_quaternions_map_from_lidar" + ][pose_index], + ) + if not 1 <= xyzi.shape[0] <= MAXIMUM_POINTS: + raise RuntimeError("RAVNOVES00 frame violates PointPillars input bounds") + return xyzi + + +def _run_frame( + triton_url: str, + native: np.ndarray, +) -> tuple[tuple[PointPillarsBox, ...], float]: + points = np.zeros((1, MAXIMUM_POINTS, 4), dtype=np.float32) + points[0, : native.shape[0]] = native + num_points = np.asarray([native.shape[0]], dtype=np.int32) + output_boxes, output_count, elapsed_ms = _infer( + triton_url, + points, + num_points, + ) + return decode_pointpillars_output(output_boxes, output_count), elapsed_ms + + +def _infer( + triton_url: str, + points: np.ndarray, + num_points: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, float]: + points_binary = np.ascontiguousarray(points, dtype=np.float32).tobytes() + count_binary = np.ascontiguousarray(num_points, dtype=np.int32).tobytes() + header = { + "inputs": [ + { + "name": "points", + "shape": [1, MAXIMUM_POINTS, 4], + "datatype": "FP32", + "parameters": {"binary_data_size": len(points_binary)}, + }, + { + "name": "num_points", + "shape": [1], + "datatype": "INT32", + "parameters": {"binary_data_size": len(count_binary)}, + }, + ], + "outputs": [ + {"name": "output_boxes", "parameters": {"binary_data": True}}, + {"name": "num_boxes", "parameters": {"binary_data": True}}, + ], + } + encoded = _canonical_json(header) + request = urllib.request.Request( + f"{triton_url.rstrip('/')}/v2/models/{MODEL_NAME}/infer", + data=encoded + points_binary + count_binary, + headers={ + "Content-Type": "application/octet-stream", + "Inference-Header-Content-Length": str(len(encoded)), + }, + method="POST", + ) + started = time.perf_counter() + with urllib.request.urlopen(request, timeout=120) as response: + payload = response.read() + header_length = int(response.headers["Inference-Header-Content-Length"]) + elapsed_ms = (time.perf_counter() - started) * 1000.0 + response_header = json.loads(payload[:header_length]) + outputs = response_header.get("outputs") + if not isinstance(outputs, list) or len(outputs) != 2: + raise RuntimeError("Triton PointPillars output set changed") + offset = header_length + decoded: dict[str, np.ndarray] = {} + for descriptor in outputs: + if not isinstance(descriptor, dict): + raise RuntimeError("Triton output descriptor is invalid") + name = descriptor.get("name") + parameters = descriptor.get("parameters") + if not isinstance(name, str) or not isinstance(parameters, dict): + raise RuntimeError("Triton output descriptor is invalid") + byte_length = parameters.get("binary_data_size") + if isinstance(byte_length, bool) or not isinstance(byte_length, int): + raise RuntimeError("Triton output byte length is invalid") + binary = payload[offset : offset + byte_length] + offset += byte_length + if name == "output_boxes" and descriptor.get("datatype") == "FP32": + decoded[name] = np.frombuffer(binary, dtype=" dict[str, object]: + return { + "x_m": box.x_m, + "y_m": box.y_m, + "z_m": box.z_m, + "length_m": box.length_m, + "width_m": box.width_m, + "height_m": box.height_m, + "yaw_rad": box.yaw_rad, + "class_id": box.class_id, + "model_class": box.model_class, + "score": box.score, + } + + +def _boxes_sha256(boxes: tuple[PointPillarsBox, ...]) -> str: + return hashlib.sha256( + _canonical_json([_box_payload(box) for box in boxes]) + ).hexdigest() + + +def _frame_summary( + *, + frame_index: int, + session_seconds: float, + source_point_count: int, + pose_index: int, + pose_binding_age_ms: float, + input_sha256: str, + inference_ms: float, + boxes: tuple[PointPillarsBox, ...], +) -> dict[str, object]: + class_counts = { + name: sum(box.model_class == name for box in boxes) + for name in ("Vehicle", "Pedestrian", "Cyclist") + } + return { + "schema_version": FRAME_SCHEMA, + "frame_id": f"{frame_index:06d}", + "frame_index": frame_index, + "session_seconds": session_seconds, + "source_point_count": source_point_count, + "pose_index": pose_index, + "pose_binding_age_ms": pose_binding_age_ms, + "input_xyzi_sha256": input_sha256, + "inference_ms": inference_ms, + "prediction_count": len(boxes), + "class_counts": class_counts, + "boxes_sha256": _boxes_sha256(boxes), + } + + +def _read_frame_summary( + path: Path, + *, + frame_index: int, + input_sha256: str, +) -> dict[str, Any]: + payload = _read_json(path) + if ( + payload.get("schema_version") != FRAME_SCHEMA + or payload.get("frame_id") != f"{frame_index:06d}" + or payload.get("frame_index") != frame_index + or payload.get("input_xyzi_sha256") != input_sha256 + or not isinstance(payload.get("class_counts"), dict) + or not isinstance(payload.get("boxes_sha256"), str) + ): + raise RuntimeError("cached L3.1 frame summary is invalid") + return payload + + +def _visual_frame( + *, + summary: dict[str, Any], + xyzi: np.ndarray, + boxes: tuple[PointPillarsBox, ...], + replay_inference_ms: float, + deterministic_replay: bool, +) -> dict[str, object]: + minimum_x, minimum_y, minimum_z, maximum_x, maximum_y, maximum_z = ( + POINTPILLARS_MODEL_POINT_CLOUD_RANGE + ) + mask = ( + (xyzi[:, 0] >= minimum_x) + & (xyzi[:, 0] <= maximum_x) + & (xyzi[:, 1] >= minimum_y) + & (xyzi[:, 1] <= maximum_y) + & (xyzi[:, 2] >= minimum_z) + & (xyzi[:, 2] <= maximum_z) + ) + visible_points = xyzi[mask] + visible_boxes = boxes[:MAXIMUM_VISUAL_BOXES] + return { + "schema_version": VISUAL_FRAME_SCHEMA, + "frame_id": summary["frame_id"], + "summary": { + **summary, + "replay_inference_ms": replay_inference_ms, + "deterministic_replay": deterministic_replay, + "visual_box_count": len(visible_boxes), + "visual_box_truncated": len(boxes) > len(visible_boxes), + }, + "points": { + "layout": "flat-xyzi", + "source_point_count": int(xyzi.shape[0]), + "model_range_point_count": int(visible_points.shape[0]), + "sampled_point_count": int(visible_points.shape[0]), + "values": visible_points.reshape(-1).tolist(), + }, + "prediction_boxes": [_box_payload(box) for box in visible_boxes], + "interpretation": { + "ground_truth_available": False, + "boxes_are_model_hypotheses": True, + "accuracy_claim_allowed": False, + }, + } + + +def _metrics( + summaries: list[dict[str, Any]], + *, + deterministic_matches: int, + deterministic_total: int, +) -> dict[str, object]: + latency = np.asarray([row["inference_ms"] for row in summaries], dtype=np.float64) + pose_age = np.asarray( + [row["pose_binding_age_ms"] for row in summaries], + dtype=np.float64, + ) + class_counts = { + name: sum(int(row["class_counts"][name]) for row in summaries) + for name in ("Vehicle", "Pedestrian", "Cyclist") + } + predictions = [int(row["prediction_count"]) for row in summaries] + return { + "frame_count": len(summaries), + "input_admission_fraction": 1.0, + "output_schema_valid_fraction": 1.0, + "frames_with_predictions": sum(value > 0 for value in predictions), + "frames_with_vehicle_predictions": sum( + int(row["class_counts"]["Vehicle"]) > 0 for row in summaries + ), + "prediction_count": sum(predictions), + "class_counts": class_counts, + "predictions_per_frame": _distribution(np.asarray(predictions)), + "inference_latency_ms": _distribution(latency), + "pose_binding_age_ms": _distribution(pose_age), + "deterministic_replay_fraction": ( + deterministic_matches / deterministic_total + if deterministic_total + else 0.0 + ), + "deterministic_replay_frames": deterministic_total, + "source_paced": False, + "input_drop_count": 0, + } + + +def _distribution(values: np.ndarray) -> dict[str, float]: + return { + "minimum": float(np.min(values)), + "p50": float(np.percentile(values, 50)), + "p95": float(np.percentile(values, 95)), + "maximum": float(np.max(values)), + "mean": float(np.mean(values)), + } + + +def _read_worker_package(path: Path) -> dict[str, Any]: + root = path.expanduser().resolve(strict=True) + manifest = _read_json(root / "manifest.json") + identity = manifest.get("identity") + artifacts = manifest.get("artifacts") + identity_sha256 = manifest.get("identity_sha256") + package_id = manifest.get("package_id") + if ( + root.is_symlink() + or manifest.get("schema_version") != WORKER_PACKAGE_SCHEMA + or not isinstance(identity, dict) + or not isinstance(artifacts, list) + or not isinstance(identity_sha256, str) + or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256 + or package_id != f"l3-pointpillars-worker-package-{identity_sha256}" + or root.name != package_id + or Path(__file__).resolve(strict=True).parents[1] != root + ): + raise RuntimeError("L3.1 worker package identity is invalid") + expected = set(identity.get("artifact_paths", [])) + actual = { + item.relative_to(root).as_posix() + for item in root.rglob("*") + if item.is_file() + } + if actual != expected | {"manifest.json"}: + raise RuntimeError("L3.1 worker package file set changed") + for descriptor in artifacts: + if not isinstance(descriptor, dict): + raise RuntimeError("L3.1 worker package artifact is invalid") + relative = descriptor.get("path") + member = root / str(relative) + if ( + not isinstance(relative, str) + or relative not in expected + or not member.is_file() + or member.is_symlink() + or descriptor.get("byte_length") != member.stat().st_size + or descriptor.get("sha256") != _sha256(member) + ): + raise RuntimeError("L3.1 worker package artifact changed") + return manifest + + +def _require_triton_ready(url: str) -> None: + for endpoint in ("/v2/health/ready", f"/v2/models/{MODEL_NAME}/ready"): + request = urllib.request.Request(f"{url.rstrip('/')}{endpoint}", method="GET") + try: + with urllib.request.urlopen(request, timeout=10) as response: + if response.status != 200: + raise RuntimeError("canonical Triton is not ready") + except OSError as exc: + raise RuntimeError("canonical Triton or PointPillars is not ready") from exc + + +def _artifact(path: Path, kind: str, *, relative_to: Path) -> dict[str, object]: + return { + "kind": kind, + "path": path.relative_to(relative_to).as_posix(), + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + + +def _directory_identity(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.glob("*.json"), key=lambda item: item.name): + digest.update(path.name.encode("ascii")) + digest.update(b"\0") + digest.update(bytes.fromhex(_sha256(path))) + return digest.hexdigest() + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"invalid JSON artifact: {path.name}") from exc + if not isinstance(value, dict): + raise RuntimeError(f"invalid JSON artifact: {path.name}") + return value + + +def _write_once(path: Path, payload: object) -> None: + encoded = _canonical_json(payload) + if path.exists(): + if path.read_bytes() != encoded: + raise RuntimeError(f"immutable artifact changed: {path.name}") + return + _atomic_bytes(path, encoded) + + +def _atomic_json(path: Path, payload: object) -> None: + _atomic_bytes(path, _canonical_json(payload)) + + +def _atomic_bytes(path: Path, payload: bytes) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +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() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/k1link/compute/l31_pointpillars_ravnoves.py b/src/k1link/compute/l31_pointpillars_ravnoves.py new file mode 100644 index 0000000..bad65ca --- /dev/null +++ b/src/k1link/compute/l31_pointpillars_ravnoves.py @@ -0,0 +1,135 @@ +"""Deterministic helpers for the L3.1 PointPillars transfer on RAVNOVES00.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +import numpy as np +import numpy.typing as npt + + +class L31PointPillarsRavnovesError(RuntimeError): + """The RAVNOVES transfer input violates the frozen L3.1 contract.""" + + +def nearest_pose_indices( + point_times_ns: npt.NDArray[np.int64], + pose_times_ns: npt.NDArray[np.int64], +) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]]: + """Bind each point frame to the nearest pose on the host monotonic clock.""" + + point_times = np.asarray(point_times_ns, dtype=np.int64) + pose_times = np.asarray(pose_times_ns, dtype=np.int64) + if ( + point_times.ndim != 1 + or pose_times.ndim != 1 + or not point_times.size + or not pose_times.size + or np.any(np.diff(point_times) < 0) + or np.any(np.diff(pose_times) < 0) + ): + raise L31PointPillarsRavnovesError("LiDAR/pose time axes are invalid") + + right = np.searchsorted(pose_times, point_times, side="left") + right = np.clip(right, 0, pose_times.size - 1) + left = np.clip(right - 1, 0, pose_times.size - 1) + right_delta = np.abs(pose_times[right] - point_times) + left_delta = np.abs(point_times - pose_times[left]) + indices = np.where(left_delta <= right_delta, left, right).astype(np.int64) + age_ms = ( + np.abs(pose_times[indices] - point_times).astype(np.float64) / 1_000_000.0 + ) + if not np.isfinite(age_ms).all(): + raise L31PointPillarsRavnovesError("LiDAR/pose binding age is invalid") + return indices, age_ms + + +def sensor_frame_xyzi( + points_map_xyz: npt.NDArray[np.float64], + intensities: npt.NDArray[np.uint8], + *, + position_map_xyz: npt.NDArray[np.float64], + orientation_map_from_lidar_xyzw: npt.NDArray[np.float64], +) -> npt.NDArray[np.float32]: + """Convert verified map-frame K1 points into the model's sensor frame.""" + + points = np.asarray(points_map_xyz, dtype=np.float64) + intensity = np.asarray(intensities, dtype=np.uint8) + position = np.asarray(position_map_xyz, dtype=np.float64) + quaternion = np.asarray(orientation_map_from_lidar_xyzw, dtype=np.float64) + if ( + points.ndim != 2 + or points.shape[1:] != (3,) + or intensity.shape != (points.shape[0],) + or position.shape != (3,) + or quaternion.shape != (4,) + or not np.isfinite(points).all() + or not np.isfinite(position).all() + or not np.isfinite(quaternion).all() + ): + raise L31PointPillarsRavnovesError("K1 point/pose arrays are invalid") + norm = float(np.linalg.norm(quaternion)) + if not math.isfinite(norm) or not 0.99 <= norm <= 1.01: + raise L31PointPillarsRavnovesError("K1 pose quaternion is not normalized") + x, y, z, w = quaternion / norm + rotation_map_from_lidar = np.asarray( + [ + [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], + [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], + [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], + ], + dtype=np.float64, + ) + sensor_xyz = (points - position) @ rotation_map_from_lidar + result = np.empty((points.shape[0], 4), dtype=np.float32) + result[:, :3] = sensor_xyz.astype(np.float32) + result[:, 3] = intensity.astype(np.float32) / 255.0 + if not np.isfinite(result).all(): + raise L31PointPillarsRavnovesError("K1 sensor-frame XYZI is non-finite") + return result + + +def select_visual_frame_indices( + vehicle_counts: Sequence[int], + total_counts: Sequence[int], + *, + maximum_frames: int = 18, +) -> tuple[int, ...]: + """Select route-wide evidence, preferring frames with Vehicle predictions.""" + + vehicles = tuple(vehicle_counts) + totals = tuple(total_counts) + if ( + len(vehicles) != len(totals) + or not vehicles + or isinstance(maximum_frames, bool) + or maximum_frames < 1 + or any( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + for value in (*vehicles, *totals) + ) + or any(vehicle > total for vehicle, total in zip(vehicles, totals, strict=True)) + ): + raise L31PointPillarsRavnovesError("L3.1 visual selection input is invalid") + + count = min(maximum_frames, len(vehicles)) + boundaries = np.linspace(0, len(vehicles), count + 1, dtype=np.int64) + selected: list[int] = [] + for bin_index in range(count): + start = int(boundaries[bin_index]) + stop = int(boundaries[bin_index + 1]) + if stop <= start: + continue + center = (start + stop - 1) / 2.0 + chosen = max( + range(start, stop), + key=lambda index: ( + vehicles[index], + totals[index], + -abs(index - center), + -index, + ), + ) + selected.append(chosen) + return tuple(selected) diff --git a/src/k1link/web/advanced_laboratory_api.py b/src/k1link/web/advanced_laboratory_api.py index 370b614..6984699 100644 --- a/src/k1link/web/advanced_laboratory_api.py +++ b/src/k1link/web/advanced_laboratory_api.py @@ -10,8 +10,6 @@ from typing import Any, Final from fastapi import APIRouter, Query -from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity - from k1link.compute.e31_source_qualification import ( E31SourceQualification, E31SourceQualificationError, @@ -57,6 +55,8 @@ from k1link.compute.e40_perception_product_gate import ( E40PerceptionProductGateError, 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 LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = ( "missioncore.laboratory-advanced-catalog/v1" @@ -828,6 +828,7 @@ def build_advanced_laboratory_router( e39_root_provider: RootProvider = lambda: None, e40_root_provider: RootProvider = lambda: None, l3_visual_root_provider: RootProvider = lambda: None, + l31_ravnoves_root_provider: RootProvider = lambda: None, ) -> APIRouter: router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) @@ -909,6 +910,15 @@ def build_advanced_laboratory_router( "access": "read-only", } ) + l31_identity = latest_l31_identity(l31_ravnoves_root_provider) + if l31_identity is not None: + result["items"].append( + { + "work_id": "l31-pointpillars-ravnoves", + **l31_identity, + "access": "read-only", + } + ) return result @router.get("/e31/results") diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 38d81fa..9545c48 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -34,9 +34,6 @@ from k1link.sessions import ( SessionStore, ) from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router -from k1link.web.l3_pointpillars_visual_api import ( - build_l3_pointpillars_visual_router, -) from k1link.web.artifact_health_api import build_artifact_health_router from k1link.web.compute_contour_api import build_compute_contour_router from k1link.web.device_plugin_composition import load_installed_device_plugins @@ -45,6 +42,12 @@ 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.environment_api import build_environment_router +from k1link.web.l3_pointpillars_visual_api import ( + build_l3_pointpillars_visual_router, +) +from k1link.web.l31_pointpillars_ravnoves_api import ( + build_l31_pointpillars_ravnoves_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 @@ -609,6 +612,13 @@ app.include_router( / "l3" / "visual-audits" ), + l31_ravnoves_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "pointpillars-ravnoves" + ), ) ) app.include_router( @@ -622,6 +632,17 @@ app.include_router( ) ) ) +app.include_router( + build_l31_pointpillars_ravnoves_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "l3" + / "pointpillars-ravnoves" + ) + ) +) app.include_router( build_e30_review_router( materialization_root_provider=lambda: ( diff --git a/src/k1link/web/l31_pointpillars_ravnoves_api.py b/src/k1link/web/l31_pointpillars_ravnoves_api.py new file mode 100644 index 0000000..a5d23fe --- /dev/null +++ b/src/k1link/web/l31_pointpillars_ravnoves_api.py @@ -0,0 +1,392 @@ +"""Read-only projection of the sealed L3.1 PointPillars RAVNOVES 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 + +RootProvider = Callable[[], Path | None] + +RESULT_SCHEMA: Final = "missioncore.l31-pointpillars-ravnoves/v1" +CATALOG_SCHEMA: Final = "missioncore.l31-pointpillars-ravnoves-catalog/v1" +VISUAL_FRAME_SCHEMA: Final = ( + "missioncore.l31-pointpillars-ravnoves-visual-frame/v1" +) +RESULT_PROJECTION_SCHEMA: Final = ( + "missioncore.l31-pointpillars-ravnoves-result/v1" +) +RESULT_CATALOG_SCHEMA: Final = ( + "missioncore.l31-pointpillars-ravnoves-catalog-results/v1" +) +RESULT_ID: Final = re.compile(r"^l31-pointpillars-ravnoves-[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_CANDIDATES: Final = 16 +MAX_FRAMES: Final = 18 + + +def build_l31_pointpillars_ravnoves_router( + *, + root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter( + prefix="/api/v1/laboratory/l31/pointpillars-ravnoves", + 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]: + if not RESULT_ID.fullmatch(result_id) or not FRAME_ID.fullmatch(frame_id): + raise HTTPException(status_code=404, detail="L3.1 frame not found") + root = _configured_root(root_provider) + if root is None: + raise HTTPException(status_code=404, detail="L3.1 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 + ) + relative = descriptor["detail_path"] + if relative != f"visual-frames/{frame_id}.json": + raise RuntimeError("L3.1 visual path changed") + path = candidate / relative + payload = _read_json(path) + if ( + payload.get("schema_version") != VISUAL_FRAME_SCHEMA + or payload.get("frame_id") != frame_id + or descriptor["detail_sha256"] != _sha256(path) + or descriptor["detail_byte_length"] != path.stat().st_size + or not _valid_visual_payload(payload) + ): + raise RuntimeError("L3.1 visual identity changed") + except (RuntimeError, StopIteration): + raise HTTPException( + status_code=404, + detail="L3.1 frame not found", + ) from None + return {**copy.deepcopy(payload), "access": "read-only"} + + return router + + +def latest_l31_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 _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": "cross-domain-transfer-measured-visual-review-required", + "source_session_id": identity["source_session_id"], + "source_pack_id": identity["source_pack_id"], + "source_logical_content_sha256": identity[ + "source_logical_content_sha256" + ], + "model": copy.deepcopy(identity["model"]), + "execution": copy.deepcopy(identity["execution"]), + "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.1 candidate is invalid") + manifest = _read_json(candidate / "manifest.json") + identity = manifest.get("identity") + authority = manifest.get("authority") + catalog_descriptor = manifest.get("catalog") + limitations = manifest.get("limitations") + metrics = manifest.get("metrics") + if ( + manifest.get("schema_version") != RESULT_SCHEMA + or manifest.get("result_id") != candidate.name + or manifest.get("status") + != "k1-cross-domain-transfer-measured-visual-review-required" + or not isinstance(manifest.get("created_at_utc"), str) + or not isinstance(identity, dict) + or identity.get("source_session_id") + != "20260720T065719Z_viewer_live" + or not isinstance(authority, dict) + or authority.get("shadow_only") is not True + or authority.get("commands_enabled") is not False + or authority.get("navigation_or_safety_accepted") is not False + or authority.get("accuracy_accepted") is not False + or manifest.get("identity_sha256") + != hashlib.sha256(_canonical_json(identity)).hexdigest() + or candidate.name + != f"l31-pointpillars-ravnoves-{manifest.get('identity_sha256')}" + or not isinstance(metrics, dict) + or metrics.get("frame_count") != 4570 + or metrics.get("input_admission_fraction") != 1.0 + or metrics.get("output_schema_valid_fraction") != 1.0 + or not isinstance(limitations, list) + or not limitations + or not isinstance(catalog_descriptor, dict) + or catalog_descriptor.get("path") != "catalog.json" + or catalog_descriptor.get("kind") != "visual-frame-catalog" + ): + raise RuntimeError("L3.1 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.1 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 catalog.get("source_session_id") != identity["source_session_id"] + 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_frame_descriptor(item) for item in frames) + ): + raise RuntimeError("L3.1 catalog is invalid") + return {"manifest": manifest, "catalog": catalog} + + +def _valid_frame_descriptor(value: object) -> bool: + if not isinstance(value, dict): + return False + frame_id = value.get("frame_id") + class_counts = value.get("class_counts") + return ( + isinstance(frame_id, str) + and FRAME_ID.fullmatch(frame_id) is not None + and value.get("detail_path") == f"visual-frames/{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("frame_index"), int) + and value["frame_index"] >= 0 + and isinstance(value.get("source_point_count"), int) + and value["source_point_count"] > 0 + and isinstance(value.get("prediction_count"), int) + and value["prediction_count"] >= 0 + and isinstance(value.get("session_seconds"), (int, float)) + and value["session_seconds"] >= 0 + and isinstance(value.get("inference_ms"), (int, float)) + and 0 < value["inference_ms"] < 60_000 + and isinstance(class_counts, dict) + and set(class_counts) == {"Vehicle", "Pedestrian", "Cyclist"} + and all( + isinstance(count, int) and not isinstance(count, bool) and count >= 0 + for count in class_counts.values() + ) + ) + + +def _valid_visual_payload(value: dict[str, Any]) -> bool: + points = value.get("points") + boxes = value.get("prediction_boxes") + interpretation = value.get("interpretation") + if ( + not isinstance(points, dict) + or points.get("layout") != "flat-xyzi" + or not isinstance(boxes, list) + or len(boxes) > 512 + or not isinstance(interpretation, dict) + or interpretation.get("ground_truth_available") is not False + or interpretation.get("boxes_are_model_hypotheses") is not True + or interpretation.get("accuracy_claim_allowed") is not False + ): + return False + values = points.get("values") + sampled = points.get("sampled_point_count") + return ( + isinstance(values, list) + and isinstance(sampled, int) + and 0 < sampled <= 12_000 + and len(values) == sampled * 4 + and all( + isinstance(item, (int, float)) + and not isinstance(item, bool) + and -1_000_000 < item < 1_000_000 + for item in values + ) + and all(_valid_box(box) for box in boxes) + ) + + +def _valid_box(value: object) -> bool: + if not isinstance(value, dict): + return False + numeric = ( + "x_m", + "y_m", + "z_m", + "length_m", + "width_m", + "height_m", + "yaw_rad", + "score", + ) + return ( + value.get("model_class") in {"Vehicle", "Pedestrian", "Cyclist"} + and isinstance(value.get("class_id"), int) + and value["class_id"] in {0, 1, 2} + and all( + isinstance(value.get(key), (int, float)) + and not isinstance(value.get(key), bool) + and math_is_finite(float(value[key])) + for key in numeric + ) + and value["length_m"] > 0 + and value["width_m"] > 0 + and value["height_m"] > 0 + and 0.1 <= value["score"] <= 1.0 + ) + + +def math_is_finite(value: float) -> bool: + return value == value and value not in (float("inf"), float("-inf")) + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + root = value.expanduser().absolute() + if not root.is_dir() or root.is_symlink(): + return None + return root + + +def _candidates(root: Path) -> list[Path]: + candidates = [ + path + for path in root.iterdir() + if path.is_dir() + and not path.is_symlink() + and RESULT_ID.fullmatch(path.name) + ] + if len(candidates) > MAX_CANDIDATES: + raise RuntimeError("L3.1 candidate bound exceeded") + return candidates + + +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]: + try: + if ( + not path.is_file() + or path.is_symlink() + or not 0 < path.stat().st_size <= MAX_JSON_BYTES + ): + raise RuntimeError(f"{path.name} is unavailable") + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"{path.name} is invalid") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"{path.name} is not an object") + return payload + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _canonical_json(payload: object) -> bytes: + return json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") diff --git a/tests/test_advanced_laboratory_api.py b/tests/test_advanced_laboratory_api.py index 366e2ca..d35d846 100644 --- a/tests/test_advanced_laboratory_api.py +++ b/tests/test_advanced_laboratory_api.py @@ -53,6 +53,36 @@ def test_advanced_index_is_empty_when_not_configured() -> None: } +def test_advanced_index_publishes_l31_ravnoves_identity( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + result_id = f"l31-pointpillars-ravnoves-{'b' * 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-31T10:56:45.861Z", + } + + monkeypatch.setattr(advanced_api, "latest_l31_identity", fake_latest) + router = build_advanced_laboratory_router( + l31_ravnoves_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", + "result_id": result_id, + "created_at_utc": "2026-07-31T10:56:45.861Z", + "access": "read-only", + } + ] + + def test_advanced_index_reads_only_bounded_identity_documents( tmp_path: Path, ) -> None: diff --git a/tests/test_l31_pointpillars_ravnoves.py b/tests/test_l31_pointpillars_ravnoves.py new file mode 100644 index 0000000..b9cc790 --- /dev/null +++ b/tests/test_l31_pointpillars_ravnoves.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from k1link.compute.l31_pointpillars_ravnoves import ( + L31PointPillarsRavnovesError, + nearest_pose_indices, + select_visual_frame_indices, + sensor_frame_xyzi, +) + + +def test_nearest_pose_binding_prefers_earlier_pose_on_tie() -> None: + indices, age_ms = nearest_pose_indices( + np.asarray([10, 20, 31], dtype=np.int64) * 1_000_000, + np.asarray([5, 15, 30], dtype=np.int64) * 1_000_000, + ) + assert indices.tolist() == [0, 1, 2] + assert age_ms.tolist() == [5.0, 5.0, 1.0] + + +def test_sensor_frame_xyzi_inverts_map_pose() -> None: + points = np.asarray([[11.0, 20.0, 30.0], [10.0, 22.0, 30.0]]) + intensity = np.asarray([255, 0], dtype=np.uint8) + result = sensor_frame_xyzi( + points, + intensity, + position_map_xyz=np.asarray([10.0, 20.0, 30.0]), + orientation_map_from_lidar_xyzw=np.asarray([0.0, 0.0, 0.0, 1.0]), + ) + np.testing.assert_allclose( + result, + np.asarray([[1.0, 0.0, 0.0, 1.0], [0.0, 2.0, 0.0, 0.0]]), + ) + + +def test_visual_selection_prefers_vehicles_with_route_coverage() -> None: + selected = select_visual_frame_indices( + [0, 3, 1, 0, 0, 2], + [5, 3, 9, 0, 4, 2], + maximum_frames=3, + ) + assert selected == (1, 2, 5) + + +def test_visual_selection_rejects_impossible_class_count() -> None: + with pytest.raises(L31PointPillarsRavnovesError): + select_visual_frame_indices([2], [1]) diff --git a/tests/test_l31_pointpillars_ravnoves_api.py b/tests/test_l31_pointpillars_ravnoves_api.py new file mode 100644 index 0000000..9c869de --- /dev/null +++ b/tests/test_l31_pointpillars_ravnoves_api.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from fastapi import APIRouter +from fastapi.routing import APIRoute +from pytest import raises + +from k1link.web.l31_pointpillars_ravnoves_api import ( + build_l31_pointpillars_ravnoves_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 _canonical(payload: object) -> bytes: + return json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def _write(path: Path, payload: object) -> dict[str, object]: + content = _canonical(payload) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return { + "sha256": hashlib.sha256(content).hexdigest(), + "byte_length": len(content), + } + + +def _result(root: Path) -> tuple[str, str]: + frame_id = "000075" + identity = { + "source_session_id": "20260720T065719Z_viewer_live", + "source_pack_id": f"lidar-replay-pack-{'1' * 64}", + "source_logical_content_sha256": "2" * 64, + "model": {"name": "pointpillars", "source_model_sha256": "3" * 64}, + "execution": { + "worker_host_id": "worker-006", + "sequential": True, + "parallel_workers": 1, + }, + "authority": { + "shadow_only": True, + "commands_enabled": False, + "navigation_or_safety_accepted": False, + "accuracy_accepted": False, + }, + } + identity_sha = hashlib.sha256(_canonical(identity)).hexdigest() + result_id = f"l31-pointpillars-ravnoves-{identity_sha}" + candidate = root / result_id + detail = { + "schema_version": "missioncore.l31-pointpillars-ravnoves-visual-frame/v1", + "frame_id": frame_id, + "points": { + "layout": "flat-xyzi", + "sampled_point_count": 1, + "values": [1.0, 2.0, 3.0, 0.5], + }, + "prediction_boxes": [ + { + "x_m": 1.0, + "y_m": 2.0, + "z_m": 0.5, + "length_m": 4.0, + "width_m": 2.0, + "height_m": 1.5, + "yaw_rad": 0.1, + "class_id": 0, + "model_class": "Vehicle", + "score": 0.8, + } + ], + "interpretation": { + "ground_truth_available": False, + "boxes_are_model_hypotheses": True, + "accuracy_claim_allowed": False, + }, + } + detail_descriptor = _write( + candidate / "visual-frames" / f"{frame_id}.json", + detail, + ) + frame = { + "frame_id": frame_id, + "frame_index": 75, + "session_seconds": 7.5, + "source_point_count": 2143, + "prediction_count": 1, + "class_counts": {"Vehicle": 1, "Pedestrian": 0, "Cyclist": 0}, + "inference_ms": 26.3, + "detail_path": f"visual-frames/{frame_id}.json", + "detail_sha256": detail_descriptor["sha256"], + "detail_byte_length": detail_descriptor["byte_length"], + } + catalog = { + "schema_version": "missioncore.l31-pointpillars-ravnoves-catalog/v1", + "result_id": result_id, + "source_session_id": identity["source_session_id"], + "frame_count": 1, + "frames": [frame], + } + catalog_descriptor = _write(candidate / "catalog.json", catalog) + manifest = { + "schema_version": "missioncore.l31-pointpillars-ravnoves/v1", + "result_id": result_id, + "identity_sha256": identity_sha, + "identity": identity, + "created_at_utc": "2026-07-31T10:00:00Z", + "status": "k1-cross-domain-transfer-measured-visual-review-required", + "metrics": { + "frame_count": 4570, + "input_admission_fraction": 1.0, + "output_schema_valid_fraction": 1.0, + }, + "catalog": { + "path": "catalog.json", + "kind": "visual-frame-catalog", + **catalog_descriptor, + }, + "limitations": ["Independent 3D ground truth is unavailable."], + "authority": identity["authority"], + } + _write(candidate / "manifest.json", manifest) + return result_id, frame_id + + +def test_l31_catalog_and_visual_frame_are_read_only(tmp_path: Path) -> None: + result_id, frame_id = _result(tmp_path) + router = build_l31_pointpillars_ravnoves_router( + root_provider=lambda: tmp_path, + ) + catalog_route = _endpoint( + router, + "/api/v1/laboratory/l31/pointpillars-ravnoves/results", + ) + frame_route = _endpoint( + router, + ( + "/api/v1/laboratory/l31/pointpillars-ravnoves/" + "{result_id}/frames/{frame_id}" + ), + ) + + catalog = catalog_route(limit=1) # type: ignore[operator] + assert catalog["configured"] is True + assert catalog["invalid_total"] == 0 + assert catalog["items"][0]["result_id"] == result_id + assert catalog["items"][0]["status"] == ( + "cross-domain-transfer-measured-visual-review-required" + ) + assert catalog["items"][0]["frames"][0]["frame_id"] == frame_id + + frame = frame_route( # type: ignore[operator] + result_id=result_id, + frame_id=frame_id, + ) + assert frame["prediction_boxes"][0]["model_class"] == "Vehicle" + assert frame["interpretation"]["ground_truth_available"] is False + assert frame["access"] == "read-only" + + +def test_l31_visual_frame_fails_closed_after_mutation(tmp_path: Path) -> None: + result_id, frame_id = _result(tmp_path) + router = build_l31_pointpillars_ravnoves_router( + root_provider=lambda: tmp_path, + ) + frame_route = _endpoint( + router, + ( + "/api/v1/laboratory/l31/pointpillars-ravnoves/" + "{result_id}/frames/{frame_id}" + ), + ) + (tmp_path / result_id / "visual-frames" / f"{frame_id}.json").write_text( + "{}", + encoding="utf-8", + ) + + with raises(Exception) as caught: + frame_route( # type: ignore[operator] + result_id=result_id, + frame_id=frame_id, + ) + assert getattr(caught.value, "status_code", None) == 404