fix(lab): enforce canonical replay runtime

This commit is contained in:
DCCONSTRUCTIONS
2026-08-29 22:42:30 +03:00
parent 525ab74168
commit bd2892140f
17 changed files with 1765 additions and 507 deletions
+71
View File
@@ -37,6 +37,7 @@ from k1link.sessions import (
SessionStore,
validate_recorded_media_timeline,
)
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_frame
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
from k1link.viewer.recorded import (
APPLICATION_ID as RECORDED_APPLICATION_ID,
@@ -824,6 +825,76 @@ def build_session_router(
**response_kwargs,
)
@router.get(
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
)
async def get_observation_session_canonical_lab_spatial_frame(
session_id: str,
generation: Annotated[str, Query(min_length=64, max_length=64)],
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
) -> JSONResponse:
"""Serve one body-frame sample for the canonical recorded-LAB clock.
The camera timeline owns playback. Spatial evidence is sampled from
the same immutable recording instead of starting a second Rerun clock.
"""
if SAFE_SHA256.fullmatch(generation) is None:
raise HTTPException(
status_code=412,
detail="Поколение spatial-записи не совпадает.",
)
if recording_preparation_manager is None:
raise HTTPException(
status_code=503,
detail="Сервис canonical LAB spatial playback не настроен.",
)
snapshot = recording_preparation_manager.status(session_id)
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
raise HTTPException(
status_code=409,
detail="Запись canonical LAB ещё не подготовлена.",
)
_require_matching_recording_generation(snapshot.recording.sha256, generation)
pinned = recording_preparation_manager.pin_ready(
session_id,
preparation_id=snapshot.preparation_id,
)
if pinned is None:
raise HTTPException(
status_code=412,
detail="Подготовленная spatial-запись была заменена.",
)
pinned_snapshot, release_recording = pinned
try:
recording = pinned_snapshot.recording
if recording is None:
raise HTTPException(
status_code=500,
detail="Подготовленная spatial-запись недоступна.",
)
payload = await run_in_threadpool(
canonical_lab_spatial_frame,
recording.path,
generation,
time_ns,
)
except (OSError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Canonical LAB spatial frame не прошёл проверку.",
) from exc
finally:
release_recording()
return JSONResponse(
payload,
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": f'"{generation}:{payload["source_time_ns"]}"',
"X-Content-Type-Options": "nosniff",
},
)
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
async def get_observation_session_blueprint(
session_id: str,
+106 -1
View File
@@ -11,8 +11,9 @@ from functools import lru_cache
from pathlib import Path, PurePosixPath
from typing import Any, Final
import numpy as np
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.responses import FileResponse, JSONResponse, Response
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
from k1link.laboratory.evidence_report import (
@@ -262,9 +263,113 @@ def _build_vegetation_lab_router(
},
)
@router.get("/{result_id}/route-tgs-anchor/{source_sequence}")
def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse:
candidate = _resolve_candidate(root_provider, definition, result_id)
manifest = _read_verified(candidate, definition)
review = manifest.get("route_review")
cases = review.get("cases") if isinstance(review, dict) else None
if (
not isinstance(cases, list)
or review.get("source_id") != "RAVNOVES004TREE"
or review.get("session_id") != "20260828T130511Z_viewer_live"
or not any(
isinstance(item, dict) and item.get("source_sequence") == source_sequence
for item in cases
)
):
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
artifacts = manifest.get("artifacts")
descriptor = next(
(
item
for item in artifacts if isinstance(item, dict)
and item.get("role") == "mixed-route-tgs-evidence"
and item.get("path") == "proofs/tgs-evidence.npz"
and item.get("media_type") == "application/x-npz"
),
None,
) if isinstance(artifacts, list) else None
if descriptor is None:
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
try:
payload = _route_tgs_anchor_payload(
candidate / "proofs" / "tgs-evidence.npz",
source_sequence,
)
except (KeyError, OSError, ValueError):
raise HTTPException(
status_code=503,
detail="Route TGS anchor failed verification",
) from None
return JSONResponse(
payload,
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": f'"{descriptor.get("sha256", "")}"',
"X-Content-Type-Options": "nosniff",
},
)
return router
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
before = path.stat()
with np.load(path, allow_pickle=False) as archive:
source_indices = archive["source_frame_indices"]
offsets = archive["current_increment_point_offsets"]
points = archive["current_increment_points_xyz_m"]
centers = archive["costmap_cell_centers_xy_m"]
states = archive["causal_rolling_1s_costmap_states"]
z_bounds = archive["causal_rolling_1s_costmap_z_bounds_m"]
if (
source_indices.shape != (10,)
or offsets.shape != (11,)
or points.ndim != 2
or points.shape[1] != 3
or centers.shape != (2244, 2)
or states.shape != (10, 2244)
or z_bounds.shape != (10, 2244, 2)
):
raise ValueError("Route TGS evidence shape changed")
matches = np.flatnonzero(source_indices == source_sequence - 1)
if matches.shape != (1,):
raise ValueError("Route TGS source sequence changed")
slot = int(matches[0])
start = int(offsets[slot])
end = int(offsets[slot + 1])
if not 0 <= start <= end <= points.shape[0]:
raise ValueError("Route TGS point offsets changed")
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
if not np.isfinite(selected_points).all() or not np.isin(selected_states, [0, 1, 2, 3]).all():
raise ValueError("Route TGS payload changed")
result = {
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
"source_sequence": source_sequence,
"slot": slot,
"current_points_xyz_m": selected_points.astype(float).tolist(),
"costmap": {
"cell_size_m": 0.45,
"centers_xy_m": centers.astype(float).tolist(),
"state_codes": selected_states.astype(int).tolist(),
"z_bounds_m": [
[
float(row[0]) if np.isfinite(row[0]) else None,
float(row[1]) if np.isfinite(row[1]) else None,
]
for row in selected_z_bounds
],
},
}
after = path.stat()
if before.st_size != after.st_size or before.st_mtime_ns != after.st_mtime_ns:
raise ValueError("Route TGS evidence changed during read")
return result
def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
member = f"masks/frame-{sequence + 1:06d}.png"
try: