"""Validated full-epoch perception results and native recorded-video delivery.""" from __future__ import annotations import hashlib import json import math import os import re import stat import subprocess import threading from dataclasses import dataclass from pathlib import Path from typing import Any from k1link.sessions import SessionIntegrityError from .jobs import CameraComputeJob, validate_camera_compute_job RESULT_SCHEMA = "missioncore.recorded-perception-result/v2" IDENTITY_SCHEMA = "missioncore.recorded-perception-identity/v2" REPORT_SCHEMA = "missioncore.perception-run-report/v1" FRAME_SCHEMA = "missioncore.panoptic-frame/v1" MAX_JSON_BYTES = 64 * 1024 * 1024 MAX_FRAME_LINE_BYTES = 1024 * 1024 MAX_JOB_SCAN = 512 MAX_SAFE_INTEGER = (1 << 53) - 1 _SAFE_RESULT_ID = re.compile(r"^result-[a-f0-9]{64}$") _SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") _SHA256 = re.compile(r"^[a-f0-9]{64}$") @dataclass(frozen=True, slots=True) class PerceptionArtifact: kind: str path: Path media_type: str byte_length: int sha256: str schema_version: str | None @dataclass(frozen=True, slots=True) class RecordedPerceptionEpochResult: result_id: str result_root: Path job: CameraComputeJob created_at_utc: str calibration_sha256: str calibration_slot: str artifacts: tuple[PerceptionArtifact, ...] def artifact(self, kind: str) -> PerceptionArtifact: matches = tuple(item for item in self.artifacts if item.kind == kind) if len(matches) != 1: raise SessionIntegrityError(f"perception result artifact is unavailable: {kind}") return matches[0] @dataclass(frozen=True, slots=True) class RecordedPerceptionVideo: result_id: str session_id: str source_id: str public_source_id: str label: str path: Path media_type: str byte_length: int sha256: str timeline_start_seconds: float timeline_end_seconds: float @dataclass(frozen=True, slots=True) class _LatestResultCacheEntry: jobs_root_mtime_ns: int result_parent_mtime_ns: int file_fingerprint: tuple[tuple[str, int, int, int, int], ...] result: RecordedPerceptionEpochResult class RecordedPerceptionEpochStore: """Discover immutable full-epoch results and expose their panoptic video.""" def __init__(self, *, jobs_root: Path, results_root: Path, ffprobe_path: Path) -> None: self.jobs_root = jobs_root.expanduser().absolute() self.results_root = results_root.expanduser().absolute() self.ffprobe_path = ffprobe_path.expanduser().resolve(strict=True) self._lock = threading.Lock() self._video_probe_cache: dict[tuple[str, int, int], tuple[float, str, int, int]] = {} self._latest_result_cache: dict[str, _LatestResultCacheEntry] = {} def latest(self, session_id: str) -> RecordedPerceptionEpochResult | None: if _SAFE_RECORDING_ID.fullmatch(session_id) is None: raise ValueError("observation session id is invalid") with self._lock: return self._latest_unlocked(session_id) def video( self, session_id: str, result_id: str | None = None, ) -> RecordedPerceptionVideo | None: if _SAFE_RECORDING_ID.fullmatch(session_id) is None: raise ValueError("observation session id is invalid") if result_id is not None and _SAFE_RESULT_ID.fullmatch(result_id) is None: raise ValueError("perception result id is invalid") with self._lock: result = ( self._latest_unlocked(session_id) if result_id is None else self._resolve_unlocked(session_id, result_id) ) if result is None: return None artifact = result.artifact("panoptic-overlay-video") duration, codec, width, height = self._probe_video(artifact) expected = result.job.timeline_end_seconds - result.job.timeline_start_seconds if codec != "h264" or width != 800 or height != 600 or abs(duration - expected) > 1: raise SessionIntegrityError("panoptic video stream contract is invalid") suffix = result.job.source_id.removeprefix("sensor.camera.") return RecordedPerceptionVideo( result_id=result.result_id, session_id=result.job.session_id, source_id=result.job.source_id, public_source_id=f"recorded.perception.{suffix}", label=f"Сегментация · камера {suffix}", path=artifact.path, media_type='video/mp4; codecs="avc1.640028"', byte_length=artifact.byte_length, sha256=artifact.sha256, timeline_start_seconds=result.job.timeline_start_seconds, timeline_end_seconds=result.job.timeline_end_seconds, ) def _latest_unlocked(self, session_id: str) -> RecordedPerceptionEpochResult | None: cached = self._latest_result_cache.get(session_id) if cached is not None: if self._latest_cache_is_current(cached): return cached.result self._latest_result_cache.pop(session_id, None) try: job_roots = sorted(self.jobs_root.iterdir()) except FileNotFoundError: return None if len(job_roots) > MAX_JOB_SCAN: raise SessionIntegrityError("compute job catalog is outside bounds") matches: list[RecordedPerceptionEpochResult] = [] for job_root in job_roots: if job_root.is_symlink(): continue try: job = validate_camera_compute_job(job_root) except (OSError, SessionIntegrityError): continue if job.session_id != session_id: continue parent = self.results_root / job.job_id try: result_roots = sorted( path for path in parent.iterdir() if path.is_dir() and not path.is_symlink() and _SAFE_RESULT_ID.fullmatch(path.name) is not None ) except FileNotFoundError: continue for result_root in result_roots: try: matches.append(validate_recorded_perception_epoch_result(job_root, result_root)) except (OSError, SessionIntegrityError): continue if not matches: return None latest = max(matches, key=lambda value: (value.created_at_utc, value.result_id)) self._latest_result_cache[session_id] = self._latest_cache_entry(latest) return latest def _latest_cache_entry( self, result: RecordedPerceptionEpochResult, ) -> _LatestResultCacheEntry: result_parent = self.results_root / result.job.job_id return _LatestResultCacheEntry( jobs_root_mtime_ns=self.jobs_root.stat().st_mtime_ns, result_parent_mtime_ns=result_parent.stat().st_mtime_ns, file_fingerprint=_result_file_fingerprint(result), result=result, ) def _latest_cache_is_current(self, cached: _LatestResultCacheEntry) -> bool: """Reuse a fully validated immutable publication while it is unchanged. New jobs and new result generations invalidate through their parent directory mtimes. In-place changes to the manifest or any served result artifact invalidate through the bounded file fingerprint and force the full hash validation again. """ try: result_parent = self.results_root / cached.result.job.job_id return ( self.jobs_root.stat().st_mtime_ns == cached.jobs_root_mtime_ns and result_parent.stat().st_mtime_ns == cached.result_parent_mtime_ns and _result_file_fingerprint(cached.result) == cached.file_fingerprint ) except (OSError, SessionIntegrityError): return False def _resolve_unlocked( self, session_id: str, result_id: str, ) -> RecordedPerceptionEpochResult | None: latest = self._latest_unlocked(session_id) if latest is None or latest.result_id != result_id: return None return latest def _probe_video(self, artifact: PerceptionArtifact) -> tuple[float, str, int, int]: metadata = _confined_regular_file(artifact.path, artifact.path.parent) key = (artifact.sha256, metadata.st_mtime_ns, metadata.st_size) cached = self._video_probe_cache.get(key) if cached is not None: return cached try: completed = subprocess.run( [ str(self.ffprobe_path), "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height:format=duration", "-of", "json", str(artifact.path), ], capture_output=True, check=False, text=True, timeout=30, ) document = json.loads(completed.stdout) stream = document["streams"][0] value = ( float(document["format"]["duration"]), str(stream["codec_name"]), int(stream["width"]), int(stream["height"]), ) except (OSError, subprocess.SubprocessError, ValueError, KeyError, IndexError) as exc: raise SessionIntegrityError("panoptic video metadata is unavailable") from exc if completed.returncode != 0 or not math.isfinite(value[0]) or value[0] <= 0: raise SessionIntegrityError("panoptic video could not be decoded") self._video_probe_cache = {key: value} return value def validate_recorded_perception_epoch_result( job_root: Path, result_root: Path, ) -> RecordedPerceptionEpochResult: job = validate_camera_compute_job(job_root) root = result_root.expanduser().resolve(strict=True) if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None: raise SessionIntegrityError("full-epoch perception result root is invalid") result = _read_json_object(root / "result.json", root) identity = result.get("identity") identity_sha256 = result.get("identity_sha256") if ( result.get("schema_version") != RESULT_SCHEMA or result.get("result_id") != root.name or not isinstance(identity, dict) or identity.get("schema_version") != IDENTITY_SCHEMA or not isinstance(identity_sha256, str) or _SHA256.fullmatch(identity_sha256) is None or root.name != f"result-{identity_sha256}" or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256 or result.get("job_id") != job.job_id or result.get("input_sha256") != job.input_sha256 or result.get("session_id") != job.session_id or result.get("source_id") != job.source_id or result.get("codec_epoch") != job.codec_epoch or result.get("timestamp_basis") != "session-time-seconds" or result.get("timeline_start_seconds") != job.timeline_start_seconds or result.get("timeline_end_seconds") != job.timeline_end_seconds or result.get("frames_processed") != job.segment_count ): raise SessionIntegrityError("full-epoch perception result identity is inconsistent") if identity.get("job_id") != job.job_id or identity.get("input_sha256") != job.input_sha256: raise SessionIntegrityError("full-epoch perception identity is not bound to its job") calibration = identity.get("calibration") if ( not isinstance(calibration, dict) or not isinstance(calibration.get("content_identity_sha256"), str) or _SHA256.fullmatch(calibration["content_identity_sha256"]) is None or not isinstance(calibration.get("camera_slot"), str) or not calibration["camera_slot"] ): raise SessionIntegrityError("full-epoch perception calibration binding is invalid") artifacts = _validate_artifacts(root, result.get("artifacts")) report_artifact = _artifact(artifacts, "perception-run-report") report = _read_json_object(report_artifact.path, root) metrics = report.get("metrics") report_input = report.get("input") if ( report.get("schema_version") != REPORT_SCHEMA or report.get("state") != "published" or report.get("result_id") != root.name or not isinstance(metrics, dict) or metrics.get("frames_expected") != job.segment_count or metrics.get("frames_processed") != job.segment_count or metrics.get("frames_failed") != 0 or metrics.get("frames_skipped") != 0 or not isinstance(report_input, dict) or report_input.get("job_id") != job.job_id or report_input.get("input_sha256") != job.input_sha256 ): raise SessionIntegrityError("full-epoch perception run report is inconsistent") _validate_frame_metadata(_artifact(artifacts, "panoptic-frame-metadata"), job) created_at_utc = result.get("created_at_utc") if not isinstance(created_at_utc, str) or not 1 <= len(created_at_utc) <= 64: raise SessionIntegrityError("full-epoch perception creation time is invalid") return RecordedPerceptionEpochResult( result_id=root.name, result_root=root, job=job, created_at_utc=created_at_utc, calibration_sha256=calibration["content_identity_sha256"], calibration_slot=calibration["camera_slot"], artifacts=artifacts, ) def _validate_artifacts(root: Path, raw: object) -> tuple[PerceptionArtifact, ...]: expected = { "panoptic-overlay-video": ("perception.mp4", "video/mp4", None), "panoptic-mask-archive": ("masks.tar.gz", "application/gzip", None), "panoptic-frame-metadata": ("frames.jsonl", "application/x-ndjson", FRAME_SCHEMA), "worker-gpu-telemetry": ("gpu-telemetry.jsonl", "application/x-ndjson", None), "perception-run-report": ("run-report.json", "application/json", REPORT_SCHEMA), } if not isinstance(raw, list) or len(raw) != len(expected): raise SessionIntegrityError("full-epoch perception artifact set is invalid") artifacts: list[PerceptionArtifact] = [] seen: set[str] = set() for value in raw: if not isinstance(value, dict): raise SessionIntegrityError("full-epoch perception artifact is invalid") kind = value.get("kind") if not isinstance(kind, str) or kind in seen or kind not in expected: raise SessionIntegrityError("full-epoch perception artifact kind is invalid") seen.add(kind) expected_path, expected_media_type, expected_schema = expected[kind] path = root / expected_path metadata = _confined_regular_file(path, root) byte_length = value.get("byte_length") digest = value.get("sha256") if ( value.get("path") != expected_path or value.get("media_type") != expected_media_type or value.get("schema_version") != expected_schema or not isinstance(byte_length, int) or isinstance(byte_length, bool) or not 0 < byte_length <= MAX_SAFE_INTEGER or metadata.st_size != byte_length or not isinstance(digest, str) or _SHA256.fullmatch(digest) is None or _sha256_file(path) != digest ): raise SessionIntegrityError("full-epoch perception artifact identity changed") artifacts.append( PerceptionArtifact( kind=kind, path=path, media_type=expected_media_type, byte_length=byte_length, sha256=digest, schema_version=expected_schema, ) ) return tuple(artifacts) def _artifact(artifacts: tuple[PerceptionArtifact, ...], kind: str) -> PerceptionArtifact: matches = tuple(item for item in artifacts if item.kind == kind) if len(matches) != 1: raise SessionIntegrityError("full-epoch perception artifact is unavailable") return matches[0] def _validate_frame_metadata(artifact: PerceptionArtifact, job: CameraComputeJob) -> None: previous = -math.inf count = 0 try: with artifact.path.open("rb") as stream: for expected_index, line in enumerate(stream): if not line or len(line) > MAX_FRAME_LINE_BYTES: raise SessionIntegrityError("panoptic frame metadata line is outside bounds") value = json.loads(line) session_seconds = value.get("session_seconds") if isinstance(value, dict) else None if ( not isinstance(value, dict) or value.get("schema_version") != FRAME_SCHEMA or value.get("frame_index") != expected_index or value.get("sequence") != expected_index + 1 or not isinstance(session_seconds, (int, float)) or isinstance(session_seconds, bool) or not math.isfinite(float(session_seconds)) or float(session_seconds) <= previous or float(session_seconds) < job.timeline_start_seconds - 0.001 or float(session_seconds) > job.timeline_end_seconds + 0.001 or not isinstance(value.get("instances"), list) or not isinstance(value.get("semantic_classes"), list) ): raise SessionIntegrityError("panoptic frame metadata is inconsistent") previous = float(session_seconds) count += 1 except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise SessionIntegrityError("panoptic frame metadata is unavailable") from exc if count != job.segment_count: raise SessionIntegrityError("panoptic frame metadata count changed") def _read_json_object(path: Path, root: Path) -> dict[str, Any]: metadata = _confined_regular_file(path, root) if not 0 < metadata.st_size <= MAX_JSON_BYTES: raise SessionIntegrityError("full-epoch perception JSON is outside bounds") try: value = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise SessionIntegrityError("full-epoch perception JSON is unavailable") from exc if not isinstance(value, dict): raise SessionIntegrityError("full-epoch perception JSON is not an object") return value def _confined_regular_file(path: Path, root: Path) -> os.stat_result: try: resolved_root = root.resolve(strict=True) resolved = path.resolve(strict=True) metadata = path.lstat() except OSError as exc: raise SessionIntegrityError("full-epoch perception artifact is unavailable") from exc if ( stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or not resolved.is_relative_to(resolved_root) ): raise SessionIntegrityError("full-epoch perception artifact is not confined") return metadata def _result_file_fingerprint( result: RecordedPerceptionEpochResult, ) -> tuple[tuple[str, int, int, int, int], ...]: paths = (result.job.manifest_path, result.result_root / "result.json") + tuple( artifact.path for artifact in result.artifacts ) values: list[tuple[str, int, int, int, int]] = [] for path in paths: root = result.job.job_root if path == result.job.manifest_path else result.result_root metadata = _confined_regular_file(path, root) values.append( ( str(path), metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, ) ) return tuple(values) def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: while chunk := stream.read(1024 * 1024): digest.update(chunk) return digest.hexdigest() def _canonical_json(value: object) -> bytes: try: return json.dumps( value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False, ).encode("utf-8") except (TypeError, ValueError) as exc: raise SessionIntegrityError("full-epoch perception identity cannot be encoded") from exc