diff --git a/apps/control-station/src/core/laboratory/e29Evidence.ts b/apps/control-station/src/core/laboratory/e29Evidence.ts new file mode 100644 index 0000000..0066a72 --- /dev/null +++ b/apps/control-station/src/core/laboratory/e29Evidence.ts @@ -0,0 +1,540 @@ +export interface E29ReviewFrame { + frameIndex: number; + sourceFrameIndex: number; + sessionSeconds: number; + conflictCount: number; + semanticObservationCount: number; + geometryOnlyClusterCount: number; +} + +export interface E29EvidenceResult { + resultId: string; + createdAtUtc: string | null; + status: "diagnostic-replay-complete"; + identity: { + frameCount: number; + timelineStartSeconds: number; + timelineEndSeconds: number; + }; + metrics: { + frames: { + total: number; + sourceAvailable: number; + localSurfaceValid: number; + }; + semanticObservations: { + total: number; + current: number; + agreementFractionOfCurrent: number; + geometryStatus: { + agree: number; + cameraOnly: number; + conflict: number; + unknown: number; + }; + }; + geometryOnlyOccupied: { + clusterCount: number; + pointCount: number; + }; + runtime: { + buildElapsedMs: number; + frameProcessingP95Ms: number; + }; + }; + decision: { + cameraFirstContractImplemented: boolean; + parallelGeometryOnlyLayerImplemented: boolean; + productionPromotion: boolean; + nextGate: string; + }; + limitations: readonly string[]; + authority: { + commandsEnabled: false; + navigationOrSafetyAccepted: false; + }; + reviewFrames: readonly E29ReviewFrame[]; + linkedEvidence: { + sourceSessionId: string; + localSurfaceModelId: string; + sourcePackId: string; + sourceResultId: string; + publishedOnControlPlane: true; + workerCopyRequired: false; + }; + groundTruth: false; +} + +export interface E29EvidenceCatalog { + configured: boolean; + candidateTotal: number; + invalidTotal: number; + items: readonly E29EvidenceResult[]; +} + +export interface E29SemanticObservation { + trackId: number; + label: string; + geometryStatus: string; + geometryReason: string; + rangeM: number | null; + score: number; + support: { + classifiedPoints: number; + occupiedPoints: number; + surfacePoints: number; + }; +} + +export interface E29GeometryOnlyCluster { + nearestRangeM: number; + pointCount: number; + voxelCount: number; + heightRangeM: readonly [number, number]; +} + +export interface E29EvidenceFrame { + resultId: string; + frameIndex: number; + sourceFrameIndex: number; + sessionSeconds: number; + semanticObservations: readonly E29SemanticObservation[]; + geometryOnlyOccupied: readonly E29GeometryOnlyCluster[]; +} + +export class E29EvidenceContractError extends Error { + constructor(message: string) { + super(message); + this.name = "E29EvidenceContractError"; + } +} + +type E29Fetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new E29EvidenceContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new E29EvidenceContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new E29EvidenceContractError(`${label}: ожидалось число.`); + } + return value; +} + +function integerValue(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new E29EvidenceContractError(`${label}: ожидалось целое число.`); + } + return parsed; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new E29EvidenceContractError(`${label}: ожидался boolean.`); + } + return value; +} + +function falseValue(value: unknown, label: string): false { + if (booleanValue(value, label) !== false) { + throw new E29EvidenceContractError(`${label}: ожидалось false.`); + } + return false; +} + +function trueValue(value: unknown, label: string): true { + if (booleanValue(value, label) !== true) { + throw new E29EvidenceContractError(`${label}: ожидалось true.`); + } + return true; +} + +function nullableNumber(value: unknown, label: string): number | null { + return value === null ? null : numberValue(value, label); +} + +function tuple2(value: unknown, label: string): readonly [number, number] { + if (!Array.isArray(value) || value.length !== 2) { + throw new E29EvidenceContractError(`${label}: ожидалась пара чисел.`); + } + return [ + numberValue(value[0], `${label}[0]`), + numberValue(value[1], `${label}[1]`), + ]; +} + +function parseReviewFrame(value: unknown): E29ReviewFrame { + const source = record(value, "review_frame"); + return { + frameIndex: integerValue(source.frame_index, "review_frame.frame_index"), + sourceFrameIndex: integerValue( + source.source_frame_index, + "review_frame.source_frame_index", + ), + sessionSeconds: numberValue( + source.session_seconds, + "review_frame.session_seconds", + ), + conflictCount: integerValue( + source.conflict_count, + "review_frame.conflict_count", + ), + semanticObservationCount: integerValue( + source.semantic_observation_count, + "review_frame.semantic_observation_count", + ), + geometryOnlyClusterCount: integerValue( + source.geometry_only_cluster_count, + "review_frame.geometry_only_cluster_count", + ), + }; +} + +function parseEvidenceResult(value: unknown): E29EvidenceResult { + const source = record(value, "E29 result"); + const identity = record(source.identity, "E29 identity"); + const metrics = record(source.metrics, "E29 metrics"); + const frames = record(metrics.frames, "E29 metrics.frames"); + const semantic = record( + metrics.semantic_observations, + "E29 metrics.semantic_observations", + ); + const geometryStatus = record( + semantic.geometry_status, + "E29 metrics.semantic_observations.geometry_status", + ); + const geometryOnly = record( + metrics.geometry_only_occupied, + "E29 metrics.geometry_only_occupied", + ); + const runtime = record(metrics.runtime, "E29 metrics.runtime"); + const frameProcessing = record( + runtime.frame_processing_ms, + "E29 metrics.runtime.frame_processing_ms", + ); + const decision = record(source.decision, "E29 decision"); + const authority = record(source.authority, "E29 authority"); + const linked = record(source.linked_evidence, "E29 linked evidence"); + const status = stringValue(source.status, "E29 status"); + if (status !== "diagnostic-replay-complete") { + throw new E29EvidenceContractError("E29 status не завершён."); + } + if (!Array.isArray(source.limitations) || !Array.isArray(source.review_frames)) { + throw new E29EvidenceContractError("E29 collections имеют неверный формат."); + } + return { + resultId: stringValue(source.result_id, "E29 result_id"), + createdAtUtc: source.created_at_utc === null + ? null + : stringValue(source.created_at_utc, "E29 created_at_utc"), + status, + identity: { + frameCount: integerValue(identity.frame_count, "E29 identity.frame_count"), + timelineStartSeconds: numberValue( + identity.timeline_start_seconds, + "E29 identity.timeline_start_seconds", + ), + timelineEndSeconds: numberValue( + identity.timeline_end_seconds, + "E29 identity.timeline_end_seconds", + ), + }, + metrics: { + frames: { + total: integerValue(frames.total, "E29 metrics.frames.total"), + sourceAvailable: integerValue( + frames.source_available, + "E29 metrics.frames.source_available", + ), + localSurfaceValid: integerValue( + frames.local_surface_valid, + "E29 metrics.frames.local_surface_valid", + ), + }, + semanticObservations: { + total: integerValue( + semantic.total, + "E29 metrics.semantic_observations.total", + ), + current: integerValue( + semantic.current, + "E29 metrics.semantic_observations.current", + ), + agreementFractionOfCurrent: numberValue( + semantic.agreement_fraction_of_current, + "E29 metrics.semantic_observations.agreement_fraction_of_current", + ), + geometryStatus: { + agree: integerValue(geometryStatus.agree, "E29 geometry_status.agree"), + cameraOnly: integerValue( + geometryStatus["single-source-camera"], + "E29 geometry_status.single-source-camera", + ), + conflict: integerValue( + geometryStatus.conflict, + "E29 geometry_status.conflict", + ), + unknown: integerValue( + geometryStatus.unknown, + "E29 geometry_status.unknown", + ), + }, + }, + geometryOnlyOccupied: { + clusterCount: integerValue( + geometryOnly.cluster_count, + "E29 geometry_only_occupied.cluster_count", + ), + pointCount: integerValue( + geometryOnly.point_count, + "E29 geometry_only_occupied.point_count", + ), + }, + runtime: { + buildElapsedMs: numberValue( + runtime.build_elapsed_ms, + "E29 runtime.build_elapsed_ms", + ), + frameProcessingP95Ms: numberValue( + frameProcessing.p95, + "E29 runtime.frame_processing_ms.p95", + ), + }, + }, + decision: { + cameraFirstContractImplemented: booleanValue( + decision.camera_first_contract_implemented, + "E29 decision.camera_first_contract_implemented", + ), + parallelGeometryOnlyLayerImplemented: booleanValue( + decision.parallel_geometry_only_layer_implemented, + "E29 decision.parallel_geometry_only_layer_implemented", + ), + productionPromotion: booleanValue( + decision.production_promotion, + "E29 decision.production_promotion", + ), + nextGate: stringValue(decision.next_gate, "E29 decision.next_gate"), + }, + limitations: source.limitations.map((item, index) => ( + stringValue(item, `E29 limitations[${index}]`) + )), + authority: { + commandsEnabled: falseValue( + authority.commands_enabled, + "E29 authority.commands_enabled", + ), + navigationOrSafetyAccepted: falseValue( + authority.navigation_or_safety_accepted, + "E29 authority.navigation_or_safety_accepted", + ), + }, + reviewFrames: source.review_frames.map(parseReviewFrame), + linkedEvidence: { + sourceSessionId: stringValue( + linked.source_session_id, + "E29 linked_evidence.source_session_id", + ), + localSurfaceModelId: stringValue( + linked.local_surface_model_id, + "E29 linked_evidence.local_surface_model_id", + ), + sourcePackId: stringValue( + linked.source_pack_id, + "E29 linked_evidence.source_pack_id", + ), + sourceResultId: stringValue( + linked.source_result_id, + "E29 linked_evidence.source_result_id", + ), + publishedOnControlPlane: trueValue( + linked.published_on_control_plane, + "E29 linked_evidence.published_on_control_plane", + ), + workerCopyRequired: falseValue( + linked.worker_copy_required, + "E29 linked_evidence.worker_copy_required", + ), + }, + groundTruth: falseValue(source.ground_truth, "E29 ground_truth"), + }; +} + +export function parseE29EvidenceCatalog(value: unknown): E29EvidenceCatalog { + const source = record(value, "E29 catalog"); + if (source.schema_version !== "missioncore.laboratory-e29-catalog/v1") { + throw new E29EvidenceContractError("E29 catalog schema не поддерживается."); + } + if (!Array.isArray(source.items)) { + throw new E29EvidenceContractError("E29 catalog.items имеет неверный формат."); + } + return { + configured: booleanValue(source.configured, "E29 catalog.configured"), + candidateTotal: integerValue( + source.candidate_total, + "E29 catalog.candidate_total", + ), + invalidTotal: integerValue( + source.invalid_total, + "E29 catalog.invalid_total", + ), + items: source.items.map(parseEvidenceResult), + }; +} + +function parseSemanticObservation(value: unknown): E29SemanticObservation { + const source = record(value, "E29 semantic observation"); + const support = record(source.support, "E29 semantic observation.support"); + return { + trackId: integerValue(source.track_id, "E29 semantic observation.track_id"), + label: stringValue(source.label, "E29 semantic observation.label"), + geometryStatus: stringValue( + source.geometry_status, + "E29 semantic observation.geometry_status", + ), + geometryReason: stringValue( + source.geometry_reason, + "E29 semantic observation.geometry_reason", + ), + rangeM: nullableNumber(source.range_m, "E29 semantic observation.range_m"), + score: numberValue(source.score, "E29 semantic observation.score"), + support: { + classifiedPoints: integerValue( + support.classified_points_in_bbox, + "E29 support.classified_points_in_bbox", + ), + occupiedPoints: integerValue( + support.occupied_points_in_bbox, + "E29 support.occupied_points_in_bbox", + ), + surfacePoints: integerValue( + support.surface_points_in_bbox, + "E29 support.surface_points_in_bbox", + ), + }, + }; +} + +function parseGeometryOnly(value: unknown): E29GeometryOnlyCluster { + const source = record(value, "E29 geometry-only cluster"); + return { + nearestRangeM: numberValue( + source.nearest_range_m, + "E29 geometry-only.nearest_range_m", + ), + pointCount: integerValue( + source.point_count, + "E29 geometry-only.point_count", + ), + voxelCount: integerValue( + source.voxel_count, + "E29 geometry-only.voxel_count", + ), + heightRangeM: tuple2( + source.height_range_m, + "E29 geometry-only.height_range_m", + ), + }; +} + +export function parseE29EvidenceFrame(value: unknown): E29EvidenceFrame { + const source = record(value, "E29 frame response"); + if (source.schema_version !== "missioncore.laboratory-e29-frame/v1") { + throw new E29EvidenceContractError("E29 frame schema не поддерживается."); + } + const frame = record(source.frame, "E29 frame"); + if ( + frame.schema_version !== "missioncore.e29-camera-geometry-frame/v1" + || !Array.isArray(frame.semantic_observations) + || !Array.isArray(frame.geometry_only_occupied) + ) { + throw new E29EvidenceContractError("E29 frame имеет неверный формат."); + } + return { + resultId: stringValue(source.result_id, "E29 frame result_id"), + frameIndex: integerValue(frame.frame_index, "E29 frame.frame_index"), + sourceFrameIndex: integerValue( + frame.source_frame_index, + "E29 frame.source_frame_index", + ), + sessionSeconds: numberValue( + frame.session_seconds, + "E29 frame.session_seconds", + ), + semanticObservations: frame.semantic_observations.map( + parseSemanticObservation, + ), + geometryOnlyOccupied: frame.geometry_only_occupied.map(parseGeometryOnly), + }; +} + +async function responseJson(response: Response, fallback: string): Promise { + let payload: unknown = null; + try { + payload = await response.json(); + } catch { + // Preserve the status-aware fallback below. + } + if (!response.ok) { + const detail = payload && typeof payload === "object" && "detail" in payload + ? String((payload as { detail?: unknown }).detail) + : fallback; + throw new E29EvidenceContractError(detail); + } + return payload; +} + +export async function fetchE29EvidenceCatalog( + options: { signal?: AbortSignal; fetcher?: E29Fetch } = {}, +): Promise { + const fetcher = options.fetcher ?? fetch; + const response = await fetcher("/api/v1/laboratory/e29/results?limit=1", { + method: "GET", + headers: { Accept: "application/json" }, + signal: options.signal, + }); + return parseE29EvidenceCatalog( + await responseJson(response, "Не удалось получить LAB E29."), + ); +} + +export async function fetchE29EvidenceFrame( + resultId: string, + frameIndex: number, + options: { signal?: AbortSignal; fetcher?: E29Fetch } = {}, +): Promise { + if ( + !/^e29-camera-geometry-[a-f0-9]{64}$/.test(resultId) + || !Number.isSafeInteger(frameIndex) + || frameIndex < 0 + ) { + throw new E29EvidenceContractError("Некорректный LAB E29 frame id."); + } + const fetcher = options.fetcher ?? fetch; + const response = await fetcher( + `/api/v1/laboratory/e29/results/${resultId}/frames/${frameIndex}`, + { + method: "GET", + headers: { Accept: "application/json" }, + signal: options.signal, + }, + ); + return parseE29EvidenceFrame( + await responseJson(response, "Не удалось получить кадр LAB E29."), + ); +} diff --git a/apps/control-station/src/core/lidar/localSurface.ts b/apps/control-station/src/core/lidar/localSurface.ts index bae12b2..dfeb0f5 100644 --- a/apps/control-station/src/core/lidar/localSurface.ts +++ b/apps/control-station/src/core/lidar/localSurface.ts @@ -1488,7 +1488,7 @@ export async function fetchLidarLocalSurfaces( options: { signal?: AbortSignal; fetcher?: LidarFetch } = {}, ): Promise { const fetcher = options.fetcher ?? fetch; - const response = await fetcher("/api/v1/lidar/local-surfaces?limit=10", { + const response = await fetcher("/api/v1/lidar/local-surfaces?limit=1", { method: "GET", headers: { Accept: "application/json" }, signal: options.signal, diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 2d140db..d58b18b 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -2592,6 +2592,111 @@ font-size: 0.9rem; } +.laboratory-frame-review { + display: grid; + gap: 0.85rem; + border-radius: 1rem; + background: rgb(255 255 255 / 0.025); + padding: 1rem; +} + +.laboratory-frame-review > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.laboratory-frame-review h2 { + margin: 0.3rem 0 0; + color: var(--nodedc-text-primary); + font-size: 1rem; +} + +.laboratory-frame-review__picker { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: 0.4rem; +} + +.laboratory-frame-review__picker button { + display: grid; + gap: 0.2rem; + min-width: 0; + border: 0; + border-radius: 0.75rem; + background: rgb(255 255 255 / 0.035); + padding: 0.65rem 0.75rem; + color: var(--nodedc-text-secondary); + text-align: left; + cursor: pointer; +} + +.laboratory-frame-review__picker button:hover, +.laboratory-frame-review__picker button.is-active { + background: rgb(255 255 255 / 0.09); + color: var(--nodedc-text-primary); +} + +.laboratory-frame-review__picker span { + font-size: 0.65rem; + font-weight: 660; +} + +.laboratory-frame-review__picker small, +.laboratory-frame-review__detail small { + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.laboratory-frame-review__state { + display: flex; + min-height: 6rem; + align-items: center; + justify-content: center; + gap: 0.6rem; + color: var(--nodedc-text-muted); + font-size: 0.65rem; +} + +.laboratory-frame-review__detail { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.4rem; +} + +.laboratory-frame-review__detail > div { + display: grid; + align-content: start; + gap: 0.3rem; + min-width: 0; + border-radius: 0.75rem; + background: rgb(255 255 255 / 0.035); + padding: 0.7rem; +} + +.laboratory-frame-review__detail > div > span { + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.laboratory-frame-review__detail > div > strong { + color: var(--nodedc-text-primary); + font-size: 0.9rem; +} + +.laboratory-frame-review__detail .laboratory-frame-review__conflicts { + grid-column: 1 / -1; +} + +.laboratory-frame-review__conflicts p { + display: flex; + justify-content: space-between; + gap: 1rem; + margin: 0; + padding-top: 0.45rem; +} + @media (max-width: 1100px) { .laboratory-selector { grid-template-columns: minmax(0, 1fr); @@ -2601,6 +2706,14 @@ .laboratory-result-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .laboratory-frame-review__detail { + grid-template-columns: 1fr; + } + + .laboratory-frame-review__detail .laboratory-frame-review__conflicts { + grid-column: auto; + } } .dataset-entry { diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 4bf50c0..3c5fc37 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -44,6 +44,16 @@ import type { MissionRuntimeState, ObservationSourceDescriptor, } from "../core/runtime/contracts"; +import { + fetchE29EvidenceCatalog, + fetchE29EvidenceFrame, + type E29EvidenceFrame, + type E29EvidenceResult, +} from "../core/laboratory/e29Evidence"; +import { + fetchLidarLocalSurfaces, + type LidarLocalSurfaceModel, +} from "../core/lidar/localSurface"; import { RerunViewport, isRecordedPlaybackPresentationReady, @@ -1271,39 +1281,200 @@ function LaboratoryTask({ ); } -function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) { +function formatSeconds(value: number): string { + return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`; +} + +function E29LaboratoryResult({ + props, + rigLabel, + result, + sourceSession, + loading, + error, +}: { + props: WorkspaceRendererProps; + rigLabel: string; + result: E29EvidenceResult; + sourceSession: ObservationSessionSummary; + loading: boolean; + error: string | null; +}) { + const [selectedFrameIndex, setSelectedFrameIndex] = useState( + result.reviewFrames[0]?.frameIndex ?? 0, + ); + const [frame, setFrame] = useState(null); + const [frameLoading, setFrameLoading] = useState(false); + const [frameError, setFrameError] = useState(null); + const replayReady = props.recordedReplay?.sessionId === sourceSession.id; + const semantic = result.metrics.semanticObservations; + const geometryStatus = semantic.geometryStatus; + const conflicts = frame?.semanticObservations.filter( + (observation) => observation.geometryStatus === "conflict", + ) ?? []; + + useEffect(() => { + const controller = new AbortController(); + setFrameLoading(true); + setFrameError(null); + void fetchE29EvidenceFrame(result.resultId, selectedFrameIndex, { + signal: controller.signal, + }).then((next) => { + setFrame(next); + }).catch((caught: unknown) => { + if (controller.signal.aborted) return; + setFrame(null); + setFrameError( + caught instanceof Error ? caught.message : "Кадр E29 недоступен.", + ); + }).finally(() => { + if (!controller.signal.aborted) setFrameLoading(false); + }); + return () => controller.abort(); + }, [result.resultId, selectedFrameIndex]); + return ( <> +
+
+ ИСХОДНЫЕ ДАННЫЕ + LiDAR, траектория и камера RAVNOVES00 +
+ {replayReady ? ( + + ) : ( +
+ {loading ?
+ )} +
+
РЕЗУЛЬТАТ И ВЫВОД -

Архитектурная схема подтверждена, safety-gate не пройден

+

Camera-first контракт рассчитан, production gate не пройден

- Replay завершён + + {result.decision.productionPromotion ? "Допущено" : "Только диагностика"} +
-
Поддержка геометрией6 34132,31% current observations
-
Camera-only13 246Семантика сохранена
-
Postprocess p952,517 мс8,467 с полный build
-
Geometry-only21 321Занято / класс неизвестен
+
+ Поддержка геометрией + {formatNumber(geometryStatus.agree, 0)} + {(semantic.agreementFractionOfCurrent * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% current +
+
+ Только камера + {formatNumber(geometryStatus.cameraOnly, 0)} + Семантика без LiDAR-подтверждения +
+
+ Postprocess p95 + {result.metrics.runtime.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс + {formatSeconds(result.metrics.runtime.buildElapsedMs / 1000)} полный build +
+
+ Только геометрия + {formatNumber(result.metrics.geometryOnlyOccupied.clusterCount, 0)} + {formatNumber(result.metrics.geometryOnlyOccupied.pointCount, 0)} точек +
-

- Следующий gate — покадровый разбор конфликтов, camera-only пробелов и - geometry-only компонентов, затем source-paced shadow с deadline и staleness. -

+

{result.decision.nextGate}

+
+ +
+
+
+ КАДРЫ С КОНФЛИКТОМ +

Покадровое доказательство из camera-geometry-frames.jsonl

+
+ + {formatNumber(geometryStatus.conflict, 0)} конфликтов + +
+
+ {result.reviewFrames.slice(0, 16).map((review) => ( + + ))} +
+ {frameLoading ? ( +
+
+ ) : frameError || !frame ? ( +
+ + {frameError ?? "Кадр недоступен."} +
+ ) : ( +
+
+ Кадр источника + {formatNumber(frame.sourceFrameIndex, 0)} + {formatSeconds(frame.sessionSeconds)} +
+
+ Семантические наблюдения + {formatNumber(frame.semanticObservations.length, 0)} + {formatNumber(conflicts.length, 0)} требуют разбора +
+
+ Geometry-only компоненты + {formatNumber(frame.geometryOnlyOccupied.length, 0)} + Класс не назначается +
+
+ Фактические конфликты + {conflicts.length ? conflicts.map((observation) => ( +

+ {observation.label} · track {observation.trackId} + + {observation.geometryReason} · classified {observation.support.classifiedPoints} + {" · "}surface {observation.support.surfacePoints} + +

+ )) : В этом кадре конфликт не найден.} +
+
+ )}
); @@ -1421,6 +1592,10 @@ function PublishedLaboratoryResult({ function LabArchiveWorkspace(props: WorkspaceRendererProps) { const [profileId, setProfileId] = useState("sensor-fusion"); const [workId, setWorkId] = useState("e28-local-surface"); + const [e28Model, setE28Model] = useState(null); + const [e29Result, setE29Result] = useState(null); + const [evidenceLoading, setEvidenceLoading] = useState(true); + const [evidenceError, setEvidenceError] = useState(null); const sessions = useObservationSessions({ limit: 100, replayEnabled: props.sessionArchive.blockedReason === null, @@ -1429,9 +1604,47 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) { onReplaySettled: props.sessionArchive.onReplaySettled, }); const publishedWorks = useMemo( - () => sessions.items.filter((session) => session.lab !== null), + () => sessions.items.filter((session) => ( + session.lab !== null + && session.status === "ready" + && session.replayable + && session.modalities.includes("point-cloud") + )), [sessions.items], ); + const sourceSessions = useMemo( + () => new Map(sessions.items.map((session) => [session.id, session])), + [sessions.items], + ); + + useEffect(() => { + const controller = new AbortController(); + setEvidenceLoading(true); + setEvidenceError(null); + void Promise.allSettled([ + fetchLidarLocalSurfaces({ signal: controller.signal }), + fetchE29EvidenceCatalog({ signal: controller.signal }), + ]).then(([e28, e29]) => { + if (controller.signal.aborted) return; + const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null; + const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null; + setE28Model(nextE28); + setE29Result(nextE29); + const failures = [ + e28.status === "rejected" ? "E28" : null, + e29.status === "rejected" ? "E29" : null, + ].filter(Boolean); + setEvidenceError( + failures.length + ? `${failures.join(" и ")} не прошли серверную проверку и скрыты.` + : null, + ); + }).finally(() => { + if (!controller.signal.aborted) setEvidenceLoading(false); + }); + return () => controller.abort(); + }, []); + const rigLabel = useMemo(() => { if (props.deviceLabel) return props.deviceLabel; const coordinateFrame = publishedWorks @@ -1442,22 +1655,44 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) { : ""; return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг"; }, [props.deviceLabel, publishedWorks]); - const profiles: readonly LaboratoryOption[] = [ - { - id: "sensor-fusion", - label: `${rigLabel} · камера + LiDAR · worker D`, - }, - { - id: "published-perception", - label: `${rigLabel} · опубликованный perception pipeline`, - }, - ]; + const sensorWorks = useMemo(() => { + const items: LaboratoryOption[] = []; + if (e28Model) { + items.push({ + id: "e28-local-surface", + label: "LAB E28 · локальная поверхность L2.6", + }); + } + if ( + e29Result + && sourceSessions.has(e29Result.linkedEvidence.sourceSessionId) + ) { + items.push({ + id: "e29-camera-geometry", + label: "LAB E29 · camera-first + geometry", + }); + } + return items; + }, [e28Model, e29Result, sourceSessions]); + const profiles = useMemo(() => { + const items: LaboratoryOption[] = []; + if (sensorWorks.length) { + items.push({ + id: "sensor-fusion", + label: `${rigLabel} · камера + LiDAR · control plane`, + }); + } + if (publishedWorks.length) { + items.push({ + id: "published-perception", + label: `${rigLabel} · опубликованный perception pipeline`, + }); + } + return items; + }, [publishedWorks.length, rigLabel, sensorWorks.length]); const workOptions: readonly LaboratoryOption[] = profileId === "sensor-fusion" - ? [ - { id: "e28-local-surface", label: "LAB E28 · локальная поверхность L2.6" }, - { id: "e29-camera-geometry", label: "LAB E29 · camera-first + geometry" }, - ] + ? sensorWorks : publishedWorks.map((session) => ({ id: `session:${session.id}` as const, label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`, @@ -1468,18 +1703,49 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) { const selectedSession = selectedSessionId ? publishedWorks.find((session) => session.id === selectedSessionId) ?? null : null; + const e29SourceSession = e29Result + ? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? null + : null; useEffect(() => { - if (profileId !== "published-perception" || workId.startsWith("session:")) return; - const first = publishedWorks[0]; - if (!first) return; - setWorkId(`session:${first.id}`); - }, [profileId, publishedWorks, workId]); + if ( + evidenceLoading + || sessions.state === "idle" + || sessions.state === "loading" + ) return; + if (!profiles.some((profile) => profile.id === profileId)) { + const firstProfile = profiles[0]; + if (!firstProfile) return; + setProfileId(firstProfile.id); + if (firstProfile.id === "sensor-fusion") { + const firstWork = sensorWorks[0]; + if (firstWork) setWorkId(firstWork.id); + } else { + const first = publishedWorks[0]; + if (first) setWorkId(`session:${first.id}`); + } + return; + } + if (!workOptions.some((work) => work.id === workId)) { + const firstWork = workOptions[0]; + if (firstWork) setWorkId(firstWork.id); + } + }, [ + evidenceLoading, + profileId, + profiles, + publishedWorks, + sensorWorks, + sessions.state, + workId, + workOptions, + ]); const selectProfile = (next: LaboratoryProfileId) => { setProfileId(next); if (next === "sensor-fusion") { - setWorkId("e28-local-surface"); + const first = sensorWorks[0]; + if (first) setWorkId(first.id); return; } const first = publishedWorks[0]; @@ -1491,10 +1757,41 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) { const selectWork = (next: LaboratoryWorkId) => { setWorkId(next); - if (!next.startsWith("session:")) return; - void sessions.replay(next.slice("session:".length)); + if (next.startsWith("session:")) { + void sessions.replay(next.slice("session:".length)); + return; + } + if (next === "e29-camera-geometry" && e29SourceSession) { + void sessions.replay(e29SourceSession.id); + } }; + if ( + evidenceLoading + || sessions.state === "idle" + || sessions.state === "loading" + ) { + return ( +
+
+ ); + } + + if (!profiles.length) { + return ( +
+ + Подтверждённых лабораторных работ нет +

+ {evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."} +

+
+ ); + } + return (
props.navigation.openView("spatial-scene")} /> - ) : workId === "e29-camera-geometry" ? ( - + ) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? ( + ) : selectedSession ? ( - В этом профиле пока нет опубликованных работ -

Работы появятся после публикации проверенного LAB-provenance.

+ Работа не прошла ревизию +

Неподтверждённый результат скрыт из лабораторного каталога.

)} diff --git a/apps/control-station/test/e29Evidence.test.mjs b/apps/control-station/test/e29Evidence.test.mjs new file mode 100644 index 0000000..994c4ec --- /dev/null +++ b/apps/control-station/test/e29Evidence.test.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let parseE29EvidenceCatalog; +let parseE29EvidenceFrame; +let fetchE29EvidenceCatalog; +let fetchE29EvidenceFrame; +let E29EvidenceContractError; + +const resultId = `e29-camera-geometry-${"a".repeat(64)}`; + +function result(overrides = {}) { + return { + result_id: resultId, + created_at_utc: "2026-07-26T00:00:00Z", + status: "diagnostic-replay-complete", + identity: { + frame_count: 4489, + timeline_start_seconds: 35.4, + timeline_end_seconds: 484.0, + }, + metrics: { + frames: { + total: 4489, + source_available: 3928, + local_surface_valid: 3928, + }, + semantic_observations: { + total: 20513, + current: 19625, + agreement_fraction_of_current: 0.3231, + geometry_status: { + agree: 6341, + "single-source-camera": 13246, + conflict: 38, + unknown: 888, + }, + }, + geometry_only_occupied: { + cluster_count: 21321, + point_count: 2021343, + }, + runtime: { + build_elapsed_ms: 8466.6, + frame_processing_ms: { p95: 2.517 }, + }, + }, + decision: { + camera_first_contract_implemented: true, + parallel_geometry_only_layer_implemented: true, + production_promotion: false, + next_gate: "operator review", + }, + limitations: ["not ground truth"], + authority: { + commands_enabled: false, + navigation_or_safety_accepted: false, + }, + review_frames: [{ + frame_index: 343, + source_frame_index: 343, + session_seconds: 69.799, + conflict_count: 1, + semantic_observation_count: 6, + geometry_only_cluster_count: 5, + }], + linked_evidence: { + source_session_id: "20260720T065719Z_viewer_live", + local_surface_model_id: `k1-local-surface-${"b".repeat(64)}`, + source_pack_id: `e10-lidar-pack-${"c".repeat(64)}`, + source_result_id: `e10-integrated-perception-${"d".repeat(64)}`, + published_on_control_plane: true, + worker_copy_required: false, + }, + ground_truth: false, + access: "read-only", + ...overrides, + }; +} + +function catalog(overrides = {}) { + return { + schema_version: "missioncore.laboratory-e29-catalog/v1", + configured: true, + items: [result()], + candidate_total: 3, + invalid_total: 0, + access: "read-only", + ...overrides, + }; +} + +function frame(overrides = {}) { + return { + schema_version: "missioncore.laboratory-e29-frame/v1", + result_id: resultId, + frame: { + schema_version: "missioncore.e29-camera-geometry-frame/v1", + frame_index: 343, + source_frame_index: 343, + session_seconds: 69.799, + semantic_observations: [{ + track_id: 240042, + label: "car", + geometry_status: "conflict", + geometry_reason: "camera-object-region-observed-as-local-surface", + range_m: null, + score: 0.76, + support: { + classified_points_in_bbox: 8, + occupied_points_in_bbox: 0, + surface_points_in_bbox: 7, + }, + }], + geometry_only_occupied: [{ + nearest_range_m: 3.71, + point_count: 7, + voxel_count: 5, + height_range_m: [1.61, 1.86], + }], + }, + access: "read-only", + ...overrides, + }; +} + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ + parseE29EvidenceCatalog, + parseE29EvidenceFrame, + fetchE29EvidenceCatalog, + fetchE29EvidenceFrame, + E29EvidenceContractError, + } = await server.ssrLoadModule("/src/core/laboratory/e29Evidence.ts")); +}); + +after(async () => { + await server?.close(); +}); + +test("decodes only verified non-authoritative E29 evidence", () => { + const decoded = parseE29EvidenceCatalog(catalog()); + assert.equal(decoded.items.length, 1); + assert.equal(decoded.candidateTotal, 3); + assert.equal(decoded.items[0].metrics.semanticObservations.geometryStatus.conflict, 38); + assert.equal(decoded.items[0].linkedEvidence.publishedOnControlPlane, true); + + const decodedFrame = parseE29EvidenceFrame(frame()); + assert.equal(decodedFrame.semanticObservations[0].geometryStatus, "conflict"); + assert.equal(decodedFrame.geometryOnlyOccupied[0].pointCount, 7); +}); + +test("rejects command authority and unverified publication", () => { + assert.throws( + () => parseE29EvidenceCatalog(catalog({ + items: [result({ + authority: { + commands_enabled: true, + navigation_or_safety_accepted: false, + }, + })], + })), + E29EvidenceContractError, + ); + assert.throws( + () => parseE29EvidenceCatalog(catalog({ + items: [result({ + linked_evidence: { + ...result().linked_evidence, + published_on_control_plane: false, + }, + })], + })), + E29EvidenceContractError, + ); +}); + +test("fetches canonical catalog and exact review frame read-only", async () => { + const requests = []; + const fetchedCatalog = await fetchE29EvidenceCatalog({ + fetcher: async (input, init) => { + requests.push({ input: String(input), method: init?.method }); + return new Response(JSON.stringify(catalog()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + const fetchedFrame = await fetchE29EvidenceFrame(resultId, 343, { + fetcher: async (input, init) => { + requests.push({ input: String(input), method: init?.method }); + return new Response(JSON.stringify(frame()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + assert.equal(fetchedCatalog.items[0].identity.frameCount, 4489); + assert.equal(fetchedFrame.sourceFrameIndex, 343); + assert.deepEqual(requests, [ + { + input: "/api/v1/laboratory/e29/results?limit=1", + method: "GET", + }, + { + input: `/api/v1/laboratory/e29/results/${resultId}/frames/343`, + method: "GET", + }, + ]); +}); diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index fb8bab4..963cce4 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -34,6 +34,7 @@ from k1link.sessions import ( ) from k1link.web.device_plugin_composition import load_installed_device_plugins from k1link.web.environment_api import build_environment_router +from k1link.web.laboratory_api import build_laboratory_router from k1link.web.lidar_api import build_lidar_router from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError from k1link.web.plugin_runtime import ( @@ -464,6 +465,34 @@ app.include_router( ), ) ) +app.include_router( + build_laboratory_router( + e29_root_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e29" / "results" + ), + local_surface_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "k1-local-surface-v1" + / "models" + ), + source_pack_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e10" + / "lidar-packs" + ), + source_result_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e10" + / "worker-results" + ), + ) +) frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist" diff --git a/src/k1link/web/laboratory_api.py b/src/k1link/web/laboratory_api.py new file mode 100644 index 0000000..6b3de22 --- /dev/null +++ b/src/k1link/web/laboratory_api.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import re +from collections.abc import Callable, Mapping +from functools import lru_cache +from pathlib import Path +from typing import Any, Final + +from fastapi import APIRouter, HTTPException, Query + +from k1link.compute.semantic_geometry_fusion import ( + CAMERA_GEOMETRY_FRAME_SCHEMA, + CAMERA_GEOMETRY_FRAMES_NAME, + CAMERA_GEOMETRY_FUSION_SCHEMA, + CAMERA_GEOMETRY_MANIFEST_NAME, + CAMERA_GEOMETRY_REPORT_NAME, + CAMERA_GEOMETRY_REPORT_SCHEMA, +) + +LABORATORY_E29_CATALOG_SCHEMA: Final = ( + "missioncore.laboratory-e29-catalog/v1" +) +LABORATORY_E29_DETAIL_SCHEMA: Final = "missioncore.laboratory-e29-detail/v1" +LABORATORY_E29_FRAME_SCHEMA: Final = "missioncore.laboratory-e29-frame/v1" + +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_E29_RESULT_ID = re.compile(r"^e29-camera-geometry-[a-f0-9]{64}$") +_LOCAL_SURFACE_ID = re.compile(r"^k1-local-surface-[a-f0-9]{64}$") +_SOURCE_PACK_ID = re.compile(r"^e10-lidar-pack-[a-f0-9]{64}$") +_SOURCE_RESULT_ID = re.compile(r"^e10-integrated-perception-[a-f0-9]{64}$") +_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$") + +_MAX_MANIFEST_BYTES: Final = 256 * 1024 +_MAX_REPORT_BYTES: Final = 2 * 1024 * 1024 +_MAX_FRAME_BYTES: Final = 2 * 1024 * 1024 +_MAX_REVIEW_FRAMES: Final = 64 + +RootProvider = Callable[[], Path | None] + + +class LaboratoryEvidenceError(ValueError): + """Raised when published laboratory evidence is incomplete or changed.""" + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +def _read_json(path: Path, *, maximum_bytes: int) -> dict[str, Any]: + if path.is_symlink() or not path.is_file(): + raise LaboratoryEvidenceError("laboratory JSON artifact is unavailable") + size = path.stat().st_size + if size <= 0 or size > maximum_bytes: + raise LaboratoryEvidenceError("laboratory JSON artifact is out of bounds") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise LaboratoryEvidenceError("laboratory JSON artifact is invalid") from exc + if not isinstance(value, dict): + raise LaboratoryEvidenceError("laboratory JSON artifact must be an object") + return value + + +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() + + +def _safe_artifact_path(root: Path, value: object) -> Path: + if not isinstance(value, str) or not value or Path(value).name != value: + raise LaboratoryEvidenceError("laboratory artifact path is invalid") + path = root / value + if path.is_symlink() or not path.is_file(): + raise LaboratoryEvidenceError("laboratory artifact is unavailable") + return path + + +def _artifact_map( + root: Path, + manifest: Mapping[str, Any], +) -> dict[str, tuple[Path, Mapping[str, Any]]]: + raw = manifest.get("artifacts") + if not isinstance(raw, list) or len(raw) != 2: + raise LaboratoryEvidenceError("laboratory artifact list is invalid") + artifacts: dict[str, tuple[Path, Mapping[str, Any]]] = {} + for item in raw: + if not isinstance(item, dict): + raise LaboratoryEvidenceError("laboratory artifact entry is invalid") + role = item.get("role") + byte_length = item.get("byte_length") + digest = item.get("sha256") + if ( + not isinstance(role, str) + or role in artifacts + or not isinstance(byte_length, int) + or byte_length <= 0 + or not isinstance(digest, str) + or _SHA256.fullmatch(digest) is None + ): + raise LaboratoryEvidenceError("laboratory artifact metadata is invalid") + path = _safe_artifact_path(root, item.get("path")) + if path.stat().st_size != byte_length: + raise LaboratoryEvidenceError("laboratory artifact size changed") + artifacts[role] = (path, item) + if set(artifacts) != {"camera-geometry-frames", "camera-geometry-report"}: + raise LaboratoryEvidenceError("laboratory artifact roles are invalid") + return artifacts + + +def _result_signature(root: Path) -> tuple[int, ...]: + paths = ( + root / CAMERA_GEOMETRY_MANIFEST_NAME, + root / CAMERA_GEOMETRY_REPORT_NAME, + root / CAMERA_GEOMETRY_FRAMES_NAME, + ) + signature: list[int] = [] + for path in paths: + stat = path.stat() + signature.extend((stat.st_size, stat.st_mtime_ns)) + return tuple(signature) + + +def _review_frame(row: Mapping[str, Any]) -> dict[str, object] | None: + observations = row.get("semantic_observations") + geometry = row.get("geometry_only_occupied") + if not isinstance(observations, list) or not isinstance(geometry, list): + raise LaboratoryEvidenceError("E29 frame collections are invalid") + conflicts = sum( + 1 + for observation in observations + if isinstance(observation, dict) + and observation.get("geometry_status") == "conflict" + ) + if conflicts == 0: + return None + frame_index = row.get("frame_index") + session_seconds = row.get("session_seconds") + if ( + not isinstance(frame_index, int) + or frame_index < 0 + or not isinstance(session_seconds, (int, float)) + ): + raise LaboratoryEvidenceError("E29 review frame identity is invalid") + return { + "frame_index": frame_index, + "source_frame_index": row.get("source_frame_index"), + "session_seconds": float(session_seconds), + "conflict_count": conflicts, + "semantic_observation_count": len(observations), + "geometry_only_cluster_count": len(geometry), + } + + +@lru_cache(maxsize=16) +def _read_result_cached( + root_text: str, + signature: tuple[int, ...], +) -> tuple[dict[str, Any], tuple[int, ...]]: + del signature + root = Path(root_text) + if root.is_symlink() or not root.is_dir() or _E29_RESULT_ID.fullmatch(root.name) is None: + raise LaboratoryEvidenceError("E29 result id is invalid") + manifest = _read_json( + root / CAMERA_GEOMETRY_MANIFEST_NAME, + maximum_bytes=_MAX_MANIFEST_BYTES, + ) + artifacts = _artifact_map(root, manifest) + report_path, report_artifact = artifacts["camera-geometry-report"] + frames_path, frames_artifact = artifacts["camera-geometry-frames"] + report = _read_json(report_path, maximum_bytes=_MAX_REPORT_BYTES) + identity = manifest.get("identity") + identity_sha256 = manifest.get("identity_sha256") + if ( + manifest.get("schema_version") != CAMERA_GEOMETRY_FUSION_SCHEMA + or manifest.get("result_id") != root.name + or not isinstance(identity, dict) + or identity.get("schema_version") != CAMERA_GEOMETRY_FUSION_SCHEMA + or not isinstance(identity_sha256, str) + or _SHA256.fullmatch(identity_sha256) is None + or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256 + or root.name != f"e29-camera-geometry-{identity_sha256}" + or report.get("schema_version") != CAMERA_GEOMETRY_REPORT_SCHEMA + or report.get("result_id") != root.name + or report.get("identity") != identity + or report.get("status") != "diagnostic-replay-complete" + or report.get("ground_truth") is not False + ): + raise LaboratoryEvidenceError("E29 result identity is invalid") + if _sha256(report_path) != report_artifact.get("sha256"): + raise LaboratoryEvidenceError("E29 report digest changed") + + expected_frames = identity.get("frame_count") + if not isinstance(expected_frames, int) or expected_frames <= 0: + raise LaboratoryEvidenceError("E29 frame count is invalid") + offsets: list[int] = [] + review_frames: list[dict[str, object]] = [] + digest = hashlib.sha256() + with frames_path.open("rb") as stream: + frame_index = 0 + while True: + offset = stream.tell() + raw = stream.readline(_MAX_FRAME_BYTES + 1) + if not raw: + break + if len(raw) > _MAX_FRAME_BYTES: + raise LaboratoryEvidenceError("E29 frame is out of bounds") + digest.update(raw) + try: + row = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise LaboratoryEvidenceError("E29 frame is invalid") from exc + if ( + not isinstance(row, dict) + or row.get("schema_version") != CAMERA_GEOMETRY_FRAME_SCHEMA + or row.get("frame_index") != frame_index + ): + raise LaboratoryEvidenceError("E29 frame sequence changed") + offsets.append(offset) + if len(review_frames) < _MAX_REVIEW_FRAMES: + review = _review_frame(row) + if review is not None: + review_frames.append(review) + frame_index += 1 + if ( + len(offsets) != expected_frames + or digest.hexdigest() != frames_artifact.get("sha256") + ): + raise LaboratoryEvidenceError("E29 frame evidence changed") + + item = { + "result_id": root.name, + "created_at_utc": manifest.get("created_at_utc"), + "status": report["status"], + "identity": identity, + "metrics": report.get("metrics"), + "decision": report.get("decision"), + "limitations": report.get("limitations"), + "authority": report.get("authority"), + "review_frames": review_frames, + "ground_truth": False, + "access": "read-only", + } + return item, tuple(offsets) + + +def _linked_evidence( + item: Mapping[str, Any], + *, + local_surface_root: Path, + source_pack_root: Path, + source_result_root: Path, +) -> dict[str, object]: + identity = item.get("identity") + if not isinstance(identity, dict): + raise LaboratoryEvidenceError("E29 identity is unavailable") + model_id = identity.get("local_surface_model_id") + pack_id = identity.get("source_pack_id") + source_result_id = identity.get("source_result_id") + if ( + not isinstance(model_id, str) + or _LOCAL_SURFACE_ID.fullmatch(model_id) is None + or not isinstance(pack_id, str) + or _SOURCE_PACK_ID.fullmatch(pack_id) is None + or not isinstance(source_result_id, str) + or _SOURCE_RESULT_ID.fullmatch(source_result_id) is None + ): + raise LaboratoryEvidenceError("E29 linked evidence ids are invalid") + model_root = local_surface_root / model_id + pack_root = source_pack_root / pack_id + result_root = source_result_root / source_result_id + required = ( + model_root / "manifest.json", + model_root / "local-surface.json", + model_root / "local-surface.npz", + pack_root / "manifest.json", + pack_root / "lidar-pack.npz", + result_root / "result.json", + result_root / "fusion-frames.jsonl", + ) + if any(path.is_symlink() or not path.is_file() for path in required): + raise LaboratoryEvidenceError("E29 linked evidence is incomplete") + local_surface_report = _read_json( + model_root / "local-surface.json", + maximum_bytes=_MAX_REPORT_BYTES, + ) + session_id = local_surface_report.get("session_id") + if ( + local_surface_report.get("model_id") != model_id + or not isinstance(session_id, str) + or _SESSION_ID.fullmatch(session_id) is None + ): + raise LaboratoryEvidenceError("E29 source session is invalid") + return { + "source_session_id": session_id, + "local_surface_model_id": model_id, + "source_pack_id": pack_id, + "source_result_id": source_result_id, + "published_on_control_plane": True, + "worker_copy_required": False, + } + + +def _roots( + *, + e29_root_provider: RootProvider, + local_surface_root_provider: RootProvider, + source_pack_root_provider: RootProvider, + source_result_root_provider: RootProvider, +) -> tuple[Path, Path, Path, Path] | None: + values = ( + e29_root_provider(), + local_surface_root_provider(), + source_pack_root_provider(), + source_result_root_provider(), + ) + if any(value is None for value in values): + return None + roots = tuple(value.resolve() for value in values if value is not None) + if len(roots) != 4 or any(not root.is_dir() for root in roots): + return None + return roots[0], roots[1], roots[2], roots[3] + + +def build_laboratory_router( + *, + e29_root_provider: RootProvider = lambda: None, + local_surface_root_provider: RootProvider = lambda: None, + source_pack_root_provider: RootProvider = lambda: None, + source_result_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/e29/results") + def list_e29_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + roots = _roots( + e29_root_provider=e29_root_provider, + local_surface_root_provider=local_surface_root_provider, + source_pack_root_provider=source_pack_root_provider, + source_result_root_provider=source_result_root_provider, + ) + if roots is None: + return { + "schema_version": LABORATORY_E29_CATALOG_SCHEMA, + "configured": False, + "items": [], + "candidate_total": 0, + "invalid_total": 0, + "access": "read-only", + } + e29_root, local_surface_root, source_pack_root, source_result_root = roots + candidates = sorted( + ( + candidate + for candidate in e29_root.iterdir() + if candidate.is_dir() + and _E29_RESULT_ID.fullmatch(candidate.name) is not None + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in candidates: + try: + item, _ = _read_result_cached( + str(candidate.resolve()), + _result_signature(candidate), + ) + document = copy.deepcopy(item) + document["linked_evidence"] = _linked_evidence( + document, + local_surface_root=local_surface_root, + source_pack_root=source_pack_root, + source_result_root=source_result_root, + ) + if len(items) < limit: + items.append(document) + except (LaboratoryEvidenceError, OSError): + invalid_total += 1 + return { + "schema_version": LABORATORY_E29_CATALOG_SCHEMA, + "configured": True, + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/e29/results/{result_id}") + def get_e29_result(result_id: str) -> dict[str, object]: + roots = _roots( + e29_root_provider=e29_root_provider, + local_surface_root_provider=local_surface_root_provider, + source_pack_root_provider=source_pack_root_provider, + source_result_root_provider=source_result_root_provider, + ) + if roots is None or _E29_RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="LAB E29 result не найден") + e29_root, local_surface_root, source_pack_root, source_result_root = roots + candidate = e29_root / result_id + if not candidate.is_dir(): + raise HTTPException(status_code=404, detail="LAB E29 result не найден") + try: + item, _ = _read_result_cached( + str(candidate.resolve()), + _result_signature(candidate), + ) + document = copy.deepcopy(item) + document["linked_evidence"] = _linked_evidence( + document, + local_surface_root=local_surface_root, + source_pack_root=source_pack_root, + source_result_root=source_result_root, + ) + return { + "schema_version": LABORATORY_E29_DETAIL_SCHEMA, + "result": document, + } + except (LaboratoryEvidenceError, OSError) as exc: + raise HTTPException( + status_code=409, + detail="LAB E29 evidence не прошло проверку целостности", + ) from exc + + @router.get("/e29/results/{result_id}/frames/{frame_index}") + def get_e29_frame(result_id: str, frame_index: int) -> dict[str, object]: + roots = _roots( + e29_root_provider=e29_root_provider, + local_surface_root_provider=local_surface_root_provider, + source_pack_root_provider=source_pack_root_provider, + source_result_root_provider=source_result_root_provider, + ) + if ( + roots is None + or _E29_RESULT_ID.fullmatch(result_id) is None + or frame_index < 0 + ): + raise HTTPException(status_code=404, detail="LAB E29 frame не найден") + e29_root, local_surface_root, source_pack_root, source_result_root = roots + candidate = e29_root / result_id + if not candidate.is_dir(): + raise HTTPException(status_code=404, detail="LAB E29 frame не найден") + try: + item, offsets = _read_result_cached( + str(candidate.resolve()), + _result_signature(candidate), + ) + _linked_evidence( + item, + local_surface_root=local_surface_root, + source_pack_root=source_pack_root, + source_result_root=source_result_root, + ) + if frame_index >= len(offsets): + raise HTTPException(status_code=404, detail="LAB E29 frame не найден") + frames_path = candidate / CAMERA_GEOMETRY_FRAMES_NAME + with frames_path.open("rb") as stream: + stream.seek(offsets[frame_index]) + raw = stream.readline(_MAX_FRAME_BYTES + 1) + if not raw or len(raw) > _MAX_FRAME_BYTES: + raise LaboratoryEvidenceError("E29 frame is out of bounds") + row = json.loads(raw) + if ( + not isinstance(row, dict) + or row.get("schema_version") != CAMERA_GEOMETRY_FRAME_SCHEMA + or row.get("frame_index") != frame_index + ): + raise LaboratoryEvidenceError("E29 frame identity changed") + return { + "schema_version": LABORATORY_E29_FRAME_SCHEMA, + "result_id": result_id, + "frame": row, + "access": "read-only", + } + except HTTPException: + raise + except (LaboratoryEvidenceError, OSError, json.JSONDecodeError) as exc: + raise HTTPException( + status_code=409, + detail="LAB E29 frame не прошёл проверку целостности", + ) from exc + + return router diff --git a/src/k1link/web/lidar_api.py b/src/k1link/web/lidar_api.py index f4be93c..1f97522 100644 --- a/src/k1link/web/lidar_api.py +++ b/src/k1link/web/lidar_api.py @@ -528,6 +528,8 @@ def build_lidar_router( reverse=True, ) for candidate in candidates: + if len(items) >= limit: + break try: model = K1LocalSurfaceV1(candidate) try: @@ -539,9 +541,11 @@ def build_lidar_router( return { "schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA, "configured": True, - "items": items[:limit], + "items": items, "valid_total": len(items), "invalid_total": invalid_total, + "candidate_total": len(candidates), + "scan_complete": len(items) + invalid_total == len(candidates), "access": "read-only", } diff --git a/tests/test_laboratory_api.py b/tests/test_laboratory_api.py new file mode 100644 index 0000000..5550818 --- /dev/null +++ b/tests/test_laboratory_api.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from fastapi import APIRouter +from fastapi.routing import APIRoute + +from k1link.compute.semantic_geometry_fusion import ( + CAMERA_GEOMETRY_FRAME_SCHEMA, + CAMERA_GEOMETRY_FRAMES_NAME, + CAMERA_GEOMETRY_FUSION_SCHEMA, + CAMERA_GEOMETRY_MANIFEST_NAME, + CAMERA_GEOMETRY_REPORT_NAME, + CAMERA_GEOMETRY_REPORT_SCHEMA, +) +from k1link.web.laboratory_api import build_laboratory_router + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +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_json(path: Path, value: object) -> None: + path.write_bytes(_canonical_json(value)) + + +def _laboratory_evidence(tmp_path: Path) -> tuple[Path, Path, Path, Path, str]: + hash_a = "a" * 64 + hash_b = "b" * 64 + hash_c = "c" * 64 + model_id = f"k1-local-surface-{hash_a}" + source_pack_id = f"e10-lidar-pack-{hash_b}" + source_result_id = f"e10-integrated-perception-{hash_c}" + identity = { + "schema_version": CAMERA_GEOMETRY_FUSION_SCHEMA, + "local_surface_model_id": model_id, + "source_pack_id": source_pack_id, + "source_result_id": source_result_id, + "frame_count": 2, + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + result_id = f"e29-camera-geometry-{identity_sha256}" + + e29_root = tmp_path / "e29" + result_root = e29_root / result_id + result_root.mkdir(parents=True) + frames = [ + { + "schema_version": CAMERA_GEOMETRY_FRAME_SCHEMA, + "frame_index": 0, + "source_frame_index": 100, + "session_seconds": 10.0, + "semantic_observations": [ + { + "track_id": 7, + "semantic_class": "car", + "geometry_status": "conflict", + "range_m": 4.5, + } + ], + "geometry_only_occupied": [], + }, + { + "schema_version": CAMERA_GEOMETRY_FRAME_SCHEMA, + "frame_index": 1, + "source_frame_index": 101, + "session_seconds": 10.1, + "semantic_observations": [], + "geometry_only_occupied": [{"cluster_id": 8, "range_m": 2.0}], + }, + ] + frames_path = result_root / CAMERA_GEOMETRY_FRAMES_NAME + frames_path.write_bytes(b"".join(_canonical_json(row) + b"\n" for row in frames)) + report = { + "schema_version": CAMERA_GEOMETRY_REPORT_SCHEMA, + "result_id": result_id, + "identity": identity, + "status": "diagnostic-replay-complete", + "ground_truth": False, + "metrics": { + "frames": {"total": 2}, + "semantic_observations": { + "total": 1, + "geometry_agree": 0, + "camera_only": 0, + "conflict": 1, + }, + "geometry_only": {"cluster_count": 1}, + "runtime": {"postprocess_p95_ms": 1.2, "build_elapsed_ms": 3.4}, + }, + "decision": {"status": "replay-experiment-only"}, + "limitations": ["synthetic evidence"], + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + } + report_path = result_root / CAMERA_GEOMETRY_REPORT_NAME + _write_json(report_path, report) + manifest = { + "schema_version": CAMERA_GEOMETRY_FUSION_SCHEMA, + "result_id": result_id, + "created_at_utc": "2026-07-26T00:00:00Z", + "identity": identity, + "identity_sha256": identity_sha256, + "artifacts": [ + { + "role": "camera-geometry-frames", + "path": frames_path.name, + "byte_length": frames_path.stat().st_size, + "sha256": _sha256(frames_path), + }, + { + "role": "camera-geometry-report", + "path": report_path.name, + "byte_length": report_path.stat().st_size, + "sha256": _sha256(report_path), + }, + ], + } + _write_json(result_root / CAMERA_GEOMETRY_MANIFEST_NAME, manifest) + + local_surface_root = tmp_path / "local-surface" + model_root = local_surface_root / model_id + model_root.mkdir(parents=True) + _write_json(model_root / "manifest.json", {"model_id": model_id}) + _write_json( + model_root / "local-surface.json", + { + "model_id": model_id, + "session_id": "20260720T065719Z_viewer_live", + }, + ) + (model_root / "local-surface.npz").write_bytes(b"verified-model") + + source_pack_root = tmp_path / "source-pack" + pack_root = source_pack_root / source_pack_id + pack_root.mkdir(parents=True) + _write_json(pack_root / "manifest.json", {"pack_id": source_pack_id}) + (pack_root / "lidar-pack.npz").write_bytes(b"verified-source-pack") + + source_result_root = tmp_path / "source-result" + source_root = source_result_root / source_result_id + source_root.mkdir(parents=True) + _write_json(source_root / "result.json", {"result_id": source_result_id}) + (source_root / "fusion-frames.jsonl").write_bytes(b"{}\n") + return ( + e29_root, + local_surface_root, + source_pack_root, + source_result_root, + result_id, + ) + + +def test_laboratory_catalog_and_frame_require_verified_linked_evidence( + tmp_path: Path, +) -> None: + e29_root, local_root, pack_root, source_root, result_id = ( + _laboratory_evidence(tmp_path) + ) + router = build_laboratory_router( + e29_root_provider=lambda: e29_root, + local_surface_root_provider=lambda: local_root, + source_pack_root_provider=lambda: pack_root, + source_result_root_provider=lambda: source_root, + ) + catalog_route = _endpoint(router, "/api/v1/laboratory/e29/results") + frame_route = _endpoint( + router, + "/api/v1/laboratory/e29/results/{result_id}/frames/{frame_index}", + ) + + catalog = catalog_route(limit=1) # type: ignore[operator] + frame = frame_route(result_id=result_id, frame_index=0) # type: ignore[operator] + + assert catalog["configured"] is True + assert catalog["candidate_total"] == 1 + assert catalog["invalid_total"] == 0 + assert len(catalog["items"]) == 1 + item = catalog["items"][0] + assert item["result_id"] == result_id + assert item["linked_evidence"]["source_session_id"] == ( + "20260720T065719Z_viewer_live" + ) + assert item["linked_evidence"]["published_on_control_plane"] is True + assert frame["frame"]["semantic_observations"][0]["geometry_status"] == ( + "conflict" + ) + assert str(tmp_path) not in repr(catalog) + assert str(tmp_path) not in repr(frame) + + +def test_laboratory_catalog_hides_tampered_or_incomplete_results( + tmp_path: Path, +) -> None: + e29_root, local_root, pack_root, source_root, result_id = ( + _laboratory_evidence(tmp_path) + ) + frames_path = e29_root / result_id / CAMERA_GEOMETRY_FRAMES_NAME + frames_path.write_bytes(frames_path.read_bytes() + b"{}\n") + router = build_laboratory_router( + e29_root_provider=lambda: e29_root, + local_surface_root_provider=lambda: local_root, + source_pack_root_provider=lambda: pack_root, + source_result_root_provider=lambda: source_root, + ) + catalog_route = _endpoint(router, "/api/v1/laboratory/e29/results") + + tampered = catalog_route(limit=1) # type: ignore[operator] + assert tampered["candidate_total"] == 1 + assert tampered["invalid_total"] == 1 + assert tampered["items"] == [] + + frames_path.write_bytes(frames_path.read_bytes()[:-3]) + ( + local_root + / f"k1-local-surface-{'a' * 64}" + / "local-surface.npz" + ).unlink() + incomplete = catalog_route(limit=1) # type: ignore[operator] + assert incomplete["candidate_total"] == 1 + assert incomplete["invalid_total"] == 1 + assert incomplete["items"] == []