From 2c016b117d3a71f64b597aec0096fee85f845889 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 27 Jul 2026 14:42:22 +0300 Subject: [PATCH] feat(lab): publish E31 through E33 results --- .../src/core/laboratory/advancedResults.ts | 366 +++++++++++++++ .../src/workspaces/laboratory/E31Result.tsx | 108 +++++ .../src/workspaces/laboratory/E32Result.tsx | 105 +++++ .../src/workspaces/laboratory/E33Result.tsx | 107 +++++ .../laboratory/LaboratoryArchiveWorkspace.tsx | 111 ++++- .../laboratory/RecordedReplayEvidence.tsx | 36 ++ .../test/advancedLaboratoryResults.test.mjs | 171 +++++++ .../compute/e31_source_qualification.py | 19 + src/k1link/web/advanced_laboratory_api.py | 418 ++++++++++++++++++ src/k1link/web/app.py | 26 ++ tests/test_advanced_laboratory_api.py | 79 ++++ 11 files changed, 1544 insertions(+), 2 deletions(-) create mode 100644 apps/control-station/src/core/laboratory/advancedResults.ts create mode 100644 apps/control-station/src/workspaces/laboratory/E31Result.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/E32Result.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/E33Result.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/RecordedReplayEvidence.tsx create mode 100644 apps/control-station/test/advancedLaboratoryResults.test.mjs create mode 100644 src/k1link/web/advanced_laboratory_api.py create mode 100644 tests/test_advanced_laboratory_api.py diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts new file mode 100644 index 0000000..bcd7b94 --- /dev/null +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -0,0 +1,366 @@ +export interface E31LaboratoryResult { + resultId: string; + createdAtUtc: string | null; + sourceSessionId: string; + status: "accepted-diagnostic-source-profile"; + eligibleForE32: true; + profileId: string; + producerSha256: string; + metrics: { + frameCount: number; + availableBindingCount: number; + availableFraction: number; + lidarCameraP95Ms: number; + posePointP95Ms: number; + selectedOffsetMs: number; + correspondenceCount: number; + supportedFraction: number; + semanticSelfSampleCount: number; + semanticSelfCollateralCount: number; + exactGeometryCorrectionCount: number; + geometryPointMaskStatus: string; + }; + limitations: readonly string[]; + access: "read-only"; +} + +export interface E32LaboratoryResult { + resultId: string; + createdAtUtc: string | null; + sourceSessionId: string; + status: "accepted-diagnostic-track-geometry-replay"; + profileId: string; + producerSha256: string; + e31ResultId: string; + metrics: { + framesTotal: number; + framesSourceAvailable: number; + semanticPublished: number; + semanticMasked: number; + geometryPublished: number; + observationsArbitrated: number; + overlappingClaimsRemoved: number; + qualifiedPointsPublished: number; + qualifiedPointsWithheld: number; + frameProcessingP95Ms: number; + buildElapsedMs: number; + }; + access: "read-only"; +} + +export interface E33LaboratoryResult { + resultId: string; + createdAtUtc: string | null; + sourceSessionId: string; + status: "accepted-recorded-source-paced-shadow"; + e32ResultId: string; + pipelineId: string; + mode: string; + worker: { + node: string; + containerImage: string; + python: string; + numpy: string; + }; + metrics: { + sourceFrames: number; + deliveredFrames: number; + inputSuperseded: number; + resultSuperseded: number; + effectiveDeliveryFps: number; + deadlineMissFraction: number; + processingP95Ms: number; + releaseLagP95Ms: number; + resultAgeP95Ms: number; + processRssP95Mib: number; + gpuUtilizationP95Percent: number; + gpuVisible: boolean; + workQueueCapacity: number; + resultQueueCapacity: number; + wallToIdealRatio: number; + }; + access: "read-only"; +} + +export interface AdvancedLaboratoryResults { + e31: E31LaboratoryResult | null; + e32: E32LaboratoryResult | null; + e33: E33LaboratoryResult | null; +} + +export class AdvancedLaboratoryContractError extends Error { + constructor(message: string) { + super(message); + this.name = "AdvancedLaboratoryContractError"; + } +} + +type LaboratoryFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function optionalString(value: unknown, label: string): string | null { + return value === null ? null : stringValue(value, label); +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`); + } + return value; +} + +function integerValue(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isSafeInteger(parsed)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое число.`); + } + return parsed; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`); + } + return value; +} + +function trueValue(value: unknown, label: string): true { + if (value !== true) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалось true.`); + } + return true; +} + +function finiteNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`); + } + return value; +} + +function exactString( + value: unknown, + expected: T, + label: string, +): T { + if (value !== expected) { + throw new AdvancedLaboratoryContractError(`${label}: неверное значение.`); + } + return expected; +} + +function strings(value: unknown, label: string): readonly string[] { + if (!Array.isArray(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`); + } + return value.map((item, index) => stringValue(item, `${label}[${index}]`)); +} + +function contentId(value: unknown, prefix: string, label: string): string { + const parsed = stringValue(value, label); + if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) { + throw new AdvancedLaboratoryContractError(`${label}: неверный content id.`); + } + return parsed; +} + +function sha256(value: unknown, label: string): string { + const parsed = stringValue(value, label); + if (!/^[a-f0-9]{64}$/.test(parsed)) { + throw new AdvancedLaboratoryContractError(`${label}: неверный SHA-256.`); + } + return parsed; +} + +function diagnosticAuthority(value: unknown, label: string): void { + const authority = record(value, label); + if ( + authority.commands_enabled !== false + || authority.navigation_or_safety_accepted !== false + ) { + throw new AdvancedLaboratoryContractError(`${label}: запрещённые полномочия.`); + } +} + +function parseCatalog( + payload: unknown, + parseItem: (value: unknown) => T, +): T | null { + const value = record(payload, "Каталог LAB"); + exactString( + value.schema_version, + "missioncore.laboratory-advanced-catalog/v1", + "Каталог LAB.schema_version", + ); + booleanValue(value.configured, "Каталог LAB.configured"); + integerValue(value.candidate_total, "Каталог LAB.candidate_total"); + integerValue(value.invalid_total, "Каталог LAB.invalid_total"); + exactString(value.access, "read-only", "Каталог LAB.access"); + if (!Array.isArray(value.items)) { + throw new AdvancedLaboratoryContractError("Каталог LAB.items: ожидался массив."); + } + if (value.items.length > 1) { + throw new AdvancedLaboratoryContractError("Каталог LAB.items: нарушен limit=1."); + } + return value.items.length ? parseItem(value.items[0]) : null; +} + +function parseE31(value: unknown): E31LaboratoryResult { + const item = record(value, "E31"); + const metrics = record(item.metrics, "E31.metrics"); + diagnosticAuthority(item.authority, "E31.authority"); + return { + resultId: contentId(item.result_id, "e31-source-qualification", "E31.result_id"), + createdAtUtc: optionalString(item.created_at_utc, "E31.created_at_utc"), + sourceSessionId: stringValue(item.source_session_id, "E31.source_session_id"), + status: exactString( + item.status, + "accepted-diagnostic-source-profile", + "E31.status", + ), + eligibleForE32: trueValue(item.eligible_for_e32, "E31.eligible_for_e32"), + profileId: stringValue(item.profile_id, "E31.profile_id"), + producerSha256: sha256(item.producer_sha256, "E31.producer_sha256"), + metrics: { + frameCount: integerValue(metrics.frame_count, "E31.metrics.frame_count"), + availableBindingCount: integerValue(metrics.available_binding_count, "E31.metrics.available_binding_count"), + availableFraction: numberValue(metrics.available_fraction, "E31.metrics.available_fraction"), + lidarCameraP95Ms: numberValue(metrics.lidar_camera_p95_ms, "E31.metrics.lidar_camera_p95_ms"), + posePointP95Ms: numberValue(metrics.pose_point_p95_ms, "E31.metrics.pose_point_p95_ms"), + selectedOffsetMs: finiteNumber(metrics.selected_offset_ms, "E31.metrics.selected_offset_ms"), + correspondenceCount: integerValue(metrics.correspondence_count, "E31.metrics.correspondence_count"), + supportedFraction: numberValue(metrics.supported_fraction, "E31.metrics.supported_fraction"), + semanticSelfSampleCount: integerValue(metrics.semantic_self_sample_count, "E31.metrics.semantic_self_sample_count"), + semanticSelfCollateralCount: integerValue(metrics.semantic_self_collateral_count, "E31.metrics.semantic_self_collateral_count"), + exactGeometryCorrectionCount: integerValue(metrics.exact_geometry_correction_count, "E31.metrics.exact_geometry_correction_count"), + geometryPointMaskStatus: stringValue(metrics.geometry_point_mask_status, "E31.metrics.geometry_point_mask_status"), + }, + limitations: strings(item.limitations, "E31.limitations"), + access: exactString(item.access, "read-only", "E31.access"), + }; +} + +function parseE32(value: unknown): E32LaboratoryResult { + const item = record(value, "E32"); + const metrics = record(item.metrics, "E32.metrics"); + diagnosticAuthority(item.authority, "E32.authority"); + return { + resultId: contentId(item.result_id, "e32-track-geometry", "E32.result_id"), + createdAtUtc: optionalString(item.created_at_utc, "E32.created_at_utc"), + sourceSessionId: stringValue(item.source_session_id, "E32.source_session_id"), + status: exactString( + item.status, + "accepted-diagnostic-track-geometry-replay", + "E32.status", + ), + profileId: stringValue(item.profile_id, "E32.profile_id"), + producerSha256: sha256(item.producer_sha256, "E32.producer_sha256"), + e31ResultId: contentId(item.e31_result_id, "e31-source-qualification", "E32.e31_result_id"), + metrics: { + framesTotal: integerValue(metrics.frames_total, "E32.metrics.frames_total"), + framesSourceAvailable: integerValue(metrics.frames_source_available, "E32.metrics.frames_source_available"), + semanticPublished: integerValue(metrics.semantic_published, "E32.metrics.semantic_published"), + semanticMasked: integerValue(metrics.semantic_masked, "E32.metrics.semantic_masked"), + geometryPublished: integerValue(metrics.geometry_published, "E32.metrics.geometry_published"), + observationsArbitrated: integerValue(metrics.observations_arbitrated, "E32.metrics.observations_arbitrated"), + overlappingClaimsRemoved: integerValue(metrics.overlapping_claims_removed, "E32.metrics.overlapping_claims_removed"), + qualifiedPointsPublished: integerValue(metrics.qualified_points_published, "E32.metrics.qualified_points_published"), + qualifiedPointsWithheld: integerValue(metrics.qualified_points_withheld, "E32.metrics.qualified_points_withheld"), + frameProcessingP95Ms: numberValue(metrics.frame_processing_p95_ms, "E32.metrics.frame_processing_p95_ms"), + buildElapsedMs: numberValue(metrics.build_elapsed_ms, "E32.metrics.build_elapsed_ms"), + }, + access: exactString(item.access, "read-only", "E32.access"), + }; +} + +function parseE33(value: unknown): E33LaboratoryResult { + const item = record(value, "E33"); + const worker = record(item.worker, "E33.worker"); + const metrics = record(item.metrics, "E33.metrics"); + diagnosticAuthority(item.authority, "E33.authority"); + return { + resultId: contentId(item.result_id, "e33-worker-shadow", "E33.result_id"), + createdAtUtc: optionalString(item.created_at_utc, "E33.created_at_utc"), + sourceSessionId: stringValue(item.source_session_id, "E33.source_session_id"), + status: exactString( + item.status, + "accepted-recorded-source-paced-shadow", + "E33.status", + ), + e32ResultId: contentId(item.e32_result_id, "e32-track-geometry", "E33.e32_result_id"), + pipelineId: stringValue(item.pipeline_id, "E33.pipeline_id"), + mode: stringValue(item.mode, "E33.mode"), + worker: { + node: stringValue(worker.node, "E33.worker.node"), + containerImage: stringValue(worker.container_image, "E33.worker.container_image"), + python: stringValue(worker.python, "E33.worker.python"), + numpy: stringValue(worker.numpy, "E33.worker.numpy"), + }, + metrics: { + sourceFrames: integerValue(metrics.source_frames, "E33.metrics.source_frames"), + deliveredFrames: integerValue(metrics.delivered_frames, "E33.metrics.delivered_frames"), + inputSuperseded: integerValue(metrics.input_superseded, "E33.metrics.input_superseded"), + resultSuperseded: integerValue(metrics.result_superseded, "E33.metrics.result_superseded"), + effectiveDeliveryFps: numberValue(metrics.effective_delivery_fps, "E33.metrics.effective_delivery_fps"), + deadlineMissFraction: numberValue(metrics.deadline_miss_fraction, "E33.metrics.deadline_miss_fraction"), + processingP95Ms: numberValue(metrics.processing_p95_ms, "E33.metrics.processing_p95_ms"), + releaseLagP95Ms: numberValue(metrics.release_lag_p95_ms, "E33.metrics.release_lag_p95_ms"), + resultAgeP95Ms: numberValue(metrics.result_age_p95_ms, "E33.metrics.result_age_p95_ms"), + processRssP95Mib: numberValue(metrics.process_rss_p95_mib, "E33.metrics.process_rss_p95_mib"), + gpuUtilizationP95Percent: numberValue(metrics.gpu_utilization_p95_percent, "E33.metrics.gpu_utilization_p95_percent"), + gpuVisible: booleanValue(metrics.gpu_visible, "E33.metrics.gpu_visible"), + workQueueCapacity: integerValue(metrics.work_queue_capacity, "E33.metrics.work_queue_capacity"), + resultQueueCapacity: integerValue(metrics.result_queue_capacity, "E33.metrics.result_queue_capacity"), + wallToIdealRatio: numberValue(metrics.wall_to_ideal_ratio, "E33.metrics.wall_to_ideal_ratio"), + }, + access: exactString(item.access, "read-only", "E33.access"), + }; +} + +async function fetchOne( + path: string, + parser: (value: unknown) => T, + fetcher: LaboratoryFetch, + signal?: AbortSignal, +): Promise { + const response = await fetcher(path, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) { + throw new AdvancedLaboratoryContractError(`Каталог LAB недоступен: HTTP ${response.status}.`); + } + return parseCatalog(await response.json(), parser); +} + +export async function fetchAdvancedLaboratoryResults({ + fetcher = fetch, + signal, +}: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; +} = {}): Promise { + const [e31, e32, e33] = await Promise.all([ + fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal), + fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal), + fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal), + ]); + return { e31, e32, e33 }; +} diff --git a/apps/control-station/src/workspaces/laboratory/E31Result.tsx b/apps/control-station/src/workspaces/laboratory/E31Result.tsx new file mode 100644 index 0000000..f73c536 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E31Result.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from "react"; +import { StatusBadge } from "@nodedc/ui-react"; + +import { + LaboratoryEvidence, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { E31LaboratoryResult } from "../../core/laboratory/advancedResults"; +import { formatNumber } from "../../presentation"; + +export function E31Result({ + rigLabel, + result, + evidence, +}: { + rigLabel: string; + result: E31LaboratoryResult; + evidence: ReactNode; +}) { + const metrics = result.metrics; + return ( + + )} + evidence={( + + {evidence} + + )} + result={( +
+
+
+ РЕЗУЛЬТАТ И ВЫВОД +

Привязка воспроизводима в границах этого источника

+
+ Source profile принят +
+
+
+ Camera ↔ LiDAR p95 + {metrics.lidarCameraP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс + host-arrival binding +
+
+ Pose ↔ point p95 + {metrics.posePointP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс + по доступным кадрам +
+
+ Поддержка соответствий + {(metrics.supportedFraction * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}% + {formatNumber(metrics.correspondenceCount, 0)} соответствий +
+
+ Self-mask + {formatNumber(metrics.semanticSelfSampleCount, 0)} образцов + {formatNumber(metrics.semanticSelfCollateralCount, 0)} collateral +
+
+

Результат диагностический: он не переносится на другой монтаж сенсоров и не выдаёт командных полномочий.

+
+ )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/E32Result.tsx b/apps/control-station/src/workspaces/laboratory/E32Result.tsx new file mode 100644 index 0000000..daf0e03 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E32Result.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from "react"; +import { StatusBadge } from "@nodedc/ui-react"; + +import { + LaboratoryEvidence, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { E32LaboratoryResult } from "../../core/laboratory/advancedResults"; +import { formatNumber } from "../../presentation"; + +export function E32Result({ + rigLabel, + result, + evidence, +}: { + rigLabel: string; + result: E32LaboratoryResult; + evidence: ReactNode; +}) { + const metrics = result.metrics; + return ( + + )} + evidence={( + + {evidence} + + )} + result={( +
+
+
+ РЕЗУЛЬТАТ И ВЫВОД +

TrackGeometry построен без повторной настройки порогов

+
+ Детерминированный replay +
+
+
+ Опубликовано точек + {formatNumber(metrics.qualifiedPointsPublished, 0)} + {formatNumber(metrics.qualifiedPointsWithheld, 0)} withheld +
+
+ Арбитраж наблюдений + {formatNumber(metrics.observationsArbitrated, 0)} + {formatNumber(metrics.overlappingClaimsRemoved, 0)} overlap снято +
+
+ Semantic + {formatNumber(metrics.semanticPublished, 0)} + {formatNumber(metrics.semanticMasked, 0)} self-mask +
+
+ Frame processing p95 + {metrics.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс + {(metrics.buildElapsedMs / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} с build +
+
+

Отсутствие LiDAR-точек по-прежнему не трактуется как свободное пространство; результат не является навигационным ground truth.

+
+ )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/E33Result.tsx b/apps/control-station/src/workspaces/laboratory/E33Result.tsx new file mode 100644 index 0000000..9a15c55 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E33Result.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from "react"; +import { StatusBadge } from "@nodedc/ui-react"; + +import { + LaboratoryEvidence, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { E33LaboratoryResult } from "../../core/laboratory/advancedResults"; +import { formatNumber } from "../../presentation"; + +export function E33Result({ + rigLabel, + result, + evidence, +}: { + rigLabel: string; + result: E33LaboratoryResult; + evidence: ReactNode; +}) { + const metrics = result.metrics; + return ( + + )} + evidence={( + + {evidence} + + )} + result={( +
+
+
+ РЕЗУЛЬТАТ И ВЫВОД +

Полный replay выдержан в темпе источника

+
+ + {formatNumber(metrics.deliveredFrames, 0)} / {formatNumber(metrics.sourceFrames, 0)} + +
+
+
+ Эффективная частота + {metrics.effectiveDeliveryFps.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} FPS + скорость источника 1× +
+
+ Result age p95 + {metrics.resultAgeP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс + {(metrics.deadlineMissFraction * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% deadline miss +
+
+ Processing p95 + {metrics.processingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс + {metrics.releaseLagP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс release lag +
+
+ Ресурсы p95 + {metrics.processRssP95Mib.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} MiB + GPU {metrics.gpuUtilizationP95Percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}% +
+
+

Очереди остались bounded, пропусков нет. Результат подтверждает только worker shadow и не даёт навигационных или safety-полномочий.

+
+ )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index 8ade76c..5416810 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -27,6 +27,12 @@ import { fetchE30ReviewCatalog, type E30ReviewResult, } from "../../core/laboratory/e30Review"; +import { + fetchAdvancedLaboratoryResults, + type E31LaboratoryResult, + type E32LaboratoryResult, + type E33LaboratoryResult, +} from "../../core/laboratory/advancedResults"; import { fetchLidarLocalSurfaces, type LidarLocalSurfaceModel, @@ -35,6 +41,10 @@ import { formatNumber } from "../../presentation"; import { E30ReviewWorkspace } from "../E30ReviewWorkspace"; import { LidarQualityWorkspace } from "../LidarQualityWorkspace"; import type { WorkspaceRendererProps } from "../contracts"; +import { E31Result } from "./E31Result"; +import { E32Result } from "./E32Result"; +import { E33Result } from "./E33Result"; +import { RecordedReplayEvidence } from "./RecordedReplayEvidence"; type LaboratoryWorkspaceProps = WorkspaceRendererProps & { SpatialView: ComponentType; @@ -45,6 +55,9 @@ type LaboratoryWorkId = | "e28-local-surface" | "e29-camera-geometry" | "e30-evidence-review" + | "e31-source-binding" + | "e32-track-geometry" + | "e33-worker-shadow" | `session:${string}`; function digestFromContentId(value: string | null | undefined): string | null { @@ -535,6 +548,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { const [e28Model, setE28Model] = useState(null); const [e29Result, setE29Result] = useState(null); const [e30Result, setE30Result] = useState(null); + const [e31Result, setE31Result] = useState(null); + const [e32Result, setE32Result] = useState(null); + const [e33Result, setE33Result] = useState(null); const [evidenceLoading, setEvidenceLoading] = useState(true); const [evidenceError, setEvidenceError] = useState(null); const sessions = useObservationSessions({ @@ -566,18 +582,24 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { fetchLidarLocalSurfaces({ signal: controller.signal }), fetchE29EvidenceCatalog({ signal: controller.signal }), fetchE30ReviewCatalog({ signal: controller.signal }), - ]).then(([e28, e29, e30]) => { + fetchAdvancedLaboratoryResults({ signal: controller.signal }), + ]).then(([e28, e29, e30, advanced]) => { 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; const nextE30 = e30.status === "fulfilled" ? e30.value.items[0] ?? null : null; + const nextAdvanced = advanced.status === "fulfilled" ? advanced.value : null; setE28Model(nextE28); setE29Result(nextE29); setE30Result(nextE30); + setE31Result(nextAdvanced?.e31 ?? null); + setE32Result(nextAdvanced?.e32 ?? null); + setE33Result(nextAdvanced?.e33 ?? null); const failures = [ e28.status === "rejected" ? "E28" : null, e29.status === "rejected" ? "E29" : null, e30.status === "rejected" ? "E30" : null, + advanced.status === "rejected" ? "E31–E33" : null, ].filter(Boolean); setEvidenceError( failures.length @@ -623,8 +645,34 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { label: "LAB E30 · evidence review A2", }); } + if (e31Result && sourceSessions.has(e31Result.sourceSessionId)) { + items.push({ + id: "e31-source-binding", + label: "LAB E31 · source binding", + }); + } + if (e32Result && sourceSessions.has(e32Result.sourceSessionId)) { + items.push({ + id: "e32-track-geometry", + label: "LAB E32 · TrackGeometry v1", + }); + } + if (e33Result && sourceSessions.has(e33Result.sourceSessionId)) { + items.push({ + id: "e33-worker-shadow", + label: "LAB E33 · worker shadow 1×", + }); + } return items; - }, [e28Model, e29Result, e30Result, sourceSessions]); + }, [ + e28Model, + e29Result, + e30Result, + e31Result, + e32Result, + e33Result, + sourceSessions, + ]); const profiles = useMemo(() => { const items: LaboratoryOption[] = []; if (sensorWorks.length) { @@ -660,6 +708,15 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { const e30SourceSession = e30Result ? sourceSessions.get(e30Result.sourceSessionId) ?? null : null; + const e31SourceSession = e31Result + ? sourceSessions.get(e31Result.sourceSessionId) ?? null + : null; + const e32SourceSession = e32Result + ? sourceSessions.get(e32Result.sourceSessionId) ?? null + : null; + const e33SourceSession = e33Result + ? sourceSessions.get(e33Result.sourceSessionId) ?? null + : null; useEffect(() => { if ( @@ -721,6 +778,17 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { } if (next === "e30-evidence-review" && e30SourceSession) { void sessions.replay(e30SourceSession.id); + return; + } + const advancedSession = next === "e31-source-binding" + ? e31SourceSession + : next === "e32-track-geometry" + ? e32SourceSession + : next === "e33-worker-shadow" + ? e33SourceSession + : null; + if (advancedSession) { + void sessions.replay(advancedSession.id); } }; @@ -862,6 +930,45 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { result={e30Result} sourceSession={e30SourceSession} /> + ) : workId === "e31-source-binding" && e31Result && e31SourceSession ? ( + + )} + /> + ) : workId === "e32-track-geometry" && e32Result && e32SourceSession ? ( + + )} + /> + ) : workId === "e33-worker-shadow" && e33Result && e33SourceSession ? ( + + )} + /> ) : selectedSession ? ( ; +}; + +export function RecordedReplayEvidence({ + props, + sourceSession, + loading, + error, +}: { + props: ReplayEvidenceProps; + sourceSession: ObservationSessionSummary; + loading: boolean; + error: string | null; +}) { + if (props.recordedReplay?.sessionId === sourceSession.id) { + return ; + } + return ( +
+ {loading ?
+ ); +} diff --git a/apps/control-station/test/advancedLaboratoryResults.test.mjs b/apps/control-station/test/advancedLaboratoryResults.test.mjs new file mode 100644 index 0000000..8bd0027 --- /dev/null +++ b/apps/control-station/test/advancedLaboratoryResults.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let fetchAdvancedLaboratoryResults; +let AdvancedLaboratoryContractError; + +const authority = { + commands_enabled: false, + navigation_or_safety_accepted: false, +}; + +function catalog(item) { + return { + schema_version: "missioncore.laboratory-advanced-catalog/v1", + configured: true, + items: [item], + candidate_total: 1, + invalid_total: 0, + access: "read-only", + }; +} + +function e31() { + return { + result_id: `e31-source-qualification-${"1".repeat(64)}`, + created_at_utc: "2026-07-27T10:00:00Z", + source_session_id: "20260720T065719Z_viewer_live", + status: "accepted-diagnostic-source-profile", + eligible_for_e32: true, + profile_id: "e31-ravnoves00-source-qualification/v1", + producer_sha256: "a".repeat(64), + metrics: { + frame_count: 4489, + available_binding_count: 3928, + available_fraction: 0.875, + lidar_camera_p95_ms: 70.78, + pose_point_p95_ms: 21.85, + selected_offset_ms: 0, + correspondence_count: 87, + supported_fraction: 1, + semantic_self_sample_count: 8, + semantic_self_collateral_count: 0, + exact_geometry_correction_count: 2, + geometry_point_mask_status: "rejected", + }, + limitations: ["source scoped"], + authority, + access: "read-only", + }; +} + +function e32() { + return { + result_id: `e32-track-geometry-${"2".repeat(64)}`, + created_at_utc: "2026-07-27T10:10:00Z", + source_session_id: "20260720T065719Z_viewer_live", + status: "accepted-diagnostic-track-geometry-replay", + profile_id: "e32-exact-e29-to-track-geometry/v1", + producer_sha256: "b".repeat(64), + e31_result_id: e31().result_id, + metrics: { + frames_total: 4489, + frames_source_available: 3928, + semantic_published: 20119, + semantic_masked: 385, + geometry_published: 21318, + observations_arbitrated: 709, + overlapping_claims_removed: 4461, + qualified_points_published: 2119302, + qualified_points_withheld: 1558, + frame_processing_p95_ms: 3.081, + build_elapsed_ms: 10194.25, + }, + authority, + access: "read-only", + }; +} + +function e33() { + return { + result_id: `e33-worker-shadow-${"3".repeat(64)}`, + created_at_utc: "2026-07-27T10:36:13Z", + source_session_id: "20260720T065719Z_viewer_live", + status: "accepted-recorded-source-paced-shadow", + e32_result_id: e32().result_id, + pipeline_id: "e32-track-geometry-recorded-source-paced-worker-shadow/v1", + mode: "full-session-qualification", + worker: { + node: "DESKTOP-OPJ8J04", + container_image: "qualified@sha256:abc", + python: "3.12.3", + numpy: "1.26.4", + }, + metrics: { + source_frames: 4489, + delivered_frames: 4489, + input_superseded: 0, + result_superseded: 0, + effective_delivery_fps: 10.006, + deadline_miss_fraction: 0, + processing_p95_ms: 0.749, + release_lag_p95_ms: 0.461, + result_age_p95_ms: 2.668, + process_rss_p95_mib: 90.32, + gpu_utilization_p95_percent: 52, + gpu_visible: true, + work_queue_capacity: 2, + result_queue_capacity: 2, + wall_to_ideal_ratio: 1.0001, + }, + authority, + access: "read-only", + }; +} + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ + fetchAdvancedLaboratoryResults, + AdvancedLaboratoryContractError, + } = await server.ssrLoadModule("/src/core/laboratory/advancedResults.ts")); +}); + +after(async () => { + await server?.close(); +}); + +test("decodes E31, E32 and E33 from separate read-only catalogs", async () => { + const requests = []; + const items = [e31(), e32(), e33()]; + const decoded = await fetchAdvancedLaboratoryResults({ + fetcher: async (input, init) => { + requests.push({ input: String(input), method: init?.method }); + return new Response(JSON.stringify(catalog(items[requests.length - 1])), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + assert.equal(decoded.e31.metrics.availableBindingCount, 3928); + assert.equal(decoded.e32.metrics.qualifiedPointsPublished, 2119302); + assert.equal(decoded.e33.metrics.deliveredFrames, 4489); + assert.deepEqual(requests, [ + { input: "/api/v1/laboratory/e31/results?limit=1", method: "GET" }, + { input: "/api/v1/laboratory/e32/results?limit=1", method: "GET" }, + { input: "/api/v1/laboratory/e33/results?limit=1", method: "GET" }, + ]); +}); + +test("rejects authority escalation in an accepted-looking result", async () => { + const forged = { + ...e31(), + authority: { ...authority, commands_enabled: true }, + }; + await assert.rejects( + () => fetchAdvancedLaboratoryResults({ + fetcher: async (input) => new Response(JSON.stringify(catalog( + String(input).includes("/e31/") ? forged + : String(input).includes("/e32/") ? e32() : e33(), + )), { status: 200 }), + }), + AdvancedLaboratoryContractError, + ); +}); diff --git a/src/k1link/compute/e31_source_qualification.py b/src/k1link/compute/e31_source_qualification.py index 985db13..b40bbee 100644 --- a/src/k1link/compute/e31_source_qualification.py +++ b/src/k1link/compute/e31_source_qualification.py @@ -142,6 +142,25 @@ class _E30Chain: human_decisions: tuple[dict[str, Any], ...] +def read_e31_source_qualification(root: Path) -> E31SourceQualification: + """Open and fully validate one immutable E31 result.""" + + resolved = _safe_result_root(root, _RESULT_ID) + manifest = _read_json(resolved / E31_MANIFEST_NAME) + identity = _object(manifest.get("identity"), "E31 identity") + result = _read_existing(resolved, identity) + if ( + result.report.get("schema_version") != E31_SOURCE_QUALIFICATION_REPORT_SCHEMA + or result.report.get("result_id") != resolved.name + or result.report.get("authority") != _diagnostic_authority() + or result.manifest.get("status") != result.report.get("status") + or result.manifest.get("eligible_for_e32") + != result.report.get("eligible_for_e32") + ): + raise E31SourceQualificationError("E31 result report is inconsistent") + return result + + def build_e31_source_qualification( *, source_pack_root: Path, diff --git a/src/k1link/web/advanced_laboratory_api.py b/src/k1link/web/advanced_laboratory_api.py new file mode 100644 index 0000000..0367f6c --- /dev/null +++ b/src/k1link/web/advanced_laboratory_api.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from functools import lru_cache +from pathlib import Path +from typing import Any, Final + +from fastapi import APIRouter, Query + +from k1link.compute.e31_source_qualification import ( + E31SourceQualification, + E31SourceQualificationError, + read_e31_source_qualification, +) +from k1link.compute.e32_track_geometry_replay import ( + E32TrackGeometryReplay, + E32TrackGeometryReplayError, + read_e32_track_geometry_replay, +) +from k1link.compute.e33_worker_shadow import ( + E33WorkerShadowError, + E33WorkerShadowResult, + read_e33_worker_shadow_result, +) + +LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = ( + "missioncore.laboratory-advanced-catalog/v1" +) + +_E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$") +_E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$") +_E33_RESULT_ID = re.compile(r"^e33-worker-shadow-[a-f0-9]{64}$") + +RootProvider = Callable[[], Path | None] + + +def _result_signature(root: Path) -> tuple[int, ...]: + signature: list[int] = [] + for path in sorted(root.iterdir(), key=lambda item: item.name): + if not path.is_file() or path.is_symlink(): + continue + stat = path.stat() + signature.extend((stat.st_size, stat.st_mtime_ns)) + return tuple(signature) + + +@lru_cache(maxsize=16) +def _read_e31_cached( + root_text: str, + signature: tuple[int, ...], +) -> E31SourceQualification: + del signature + return read_e31_source_qualification(Path(root_text)) + + +@lru_cache(maxsize=16) +def _read_e32_cached( + root_text: str, + signature: tuple[int, ...], +) -> E32TrackGeometryReplay: + del signature + return read_e32_track_geometry_replay(Path(root_text)) + + +@lru_cache(maxsize=16) +def _read_e33_cached( + root_text: str, + signature: tuple[int, ...], +) -> E33WorkerShadowResult: + del signature + return read_e33_worker_shadow_result(Path(root_text)) + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + candidate = value.expanduser().absolute() + if candidate.is_symlink(): + return None + try: + root = candidate.resolve(strict=True) + except OSError: + return None + if not root.is_dir(): + return None + return root + + +def _candidates(root: Path, pattern: re.Pattern[str]) -> list[Path]: + return sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() + and not candidate.is_symlink() + and pattern.fullmatch(candidate.name) is not None + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) + + +def _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{label} is invalid") + return value + + +def _project_e31(result: E31SourceQualification) -> dict[str, object]: + identity = _object(result.manifest.get("identity"), "E31 identity") + source = _object(identity.get("source"), "E31 source") + method = _object(identity.get("method"), "E31 method") + source_time = _object(result.report.get("source_time"), "E31 source time") + lidar_camera = _object( + source_time.get("lidar_camera_abs_delta_ms"), + "E31 LiDAR/camera timing", + ) + pose_point = _object( + source_time.get("pose_point_abs_delta_ms"), + "E31 pose/point timing", + ) + offset = _object(result.report.get("offset_sensitivity"), "E31 offset") + self_mask = _object(result.report.get("self_mask"), "E31 self-mask") + semantic_mask = _object(self_mask.get("semantic_mask"), "E31 semantic mask") + geometry_mask = _object( + self_mask.get("geometry_point_mask"), + "E31 geometry mask", + ) + return { + "result_id": result.result_id, + "created_at_utc": result.manifest.get("created_at_utc"), + "source_session_id": source.get("session_id"), + "status": result.report.get("status"), + "eligible_for_e32": result.report.get("eligible_for_e32"), + "profile_id": method.get("profile_id"), + "producer_sha256": identity.get("producer_sha256"), + "metrics": { + "frame_count": source_time.get("frame_count"), + "available_binding_count": source_time.get("available_binding_count"), + "available_fraction": source_time.get("available_fraction"), + "lidar_camera_p95_ms": lidar_camera.get("p95"), + "pose_point_p95_ms": pose_point.get("p95"), + "selected_offset_ms": offset.get("selected_offset_ms"), + "correspondence_count": offset.get("correspondence_count"), + "supported_fraction": offset.get("baseline_supported_fraction"), + "semantic_self_sample_count": semantic_mask.get("source_sample_count"), + "semantic_self_collateral_count": semantic_mask.get( + "collateral_item_count" + ), + "exact_geometry_correction_count": len( + self_mask.get("exact_correction_item_ids", []) + ), + "geometry_point_mask_status": geometry_mask.get("status"), + }, + "limitations": copy.deepcopy(result.report.get("limitations")), + "authority": copy.deepcopy(result.report.get("authority")), + "access": "read-only", + } + + +def _project_e32(result: E32TrackGeometryReplay) -> dict[str, object]: + identity = _object(result.manifest.get("identity"), "E32 identity") + source = _object(identity.get("source"), "E32 source") + method = _object(identity.get("method"), "E32 method") + binding = _object( + identity.get("track_geometry_binding"), + "E32 track geometry binding", + ) + metrics = _object(result.report.get("metrics"), "E32 metrics") + frames = _object(metrics.get("frames"), "E32 frames") + objects = _object(metrics.get("objects"), "E32 objects") + ownership = _object(metrics.get("point_ownership"), "E32 point ownership") + point_rows = _object(metrics.get("qualified_point_rows"), "E32 point rows") + runtime = _object(metrics.get("runtime"), "E32 runtime") + frame_processing = _object( + runtime.get("frame_processing_ms"), + "E32 frame processing", + ) + return { + "result_id": result.result_id, + "created_at_utc": result.manifest.get("created_at_utc"), + "source_session_id": binding.get("source_session_id"), + "status": result.report.get("status"), + "profile_id": method.get("profile_id"), + "producer_sha256": identity.get("producer_sha256"), + "e31_result_id": source.get("e31_result_id"), + "metrics": { + "frames_total": frames.get("total"), + "frames_source_available": frames.get("source_available"), + "semantic_published": objects.get("semantic_published"), + "semantic_masked": objects.get("semantic_masked"), + "geometry_published": objects.get("geometry_published"), + "observations_arbitrated": ownership.get("observations_arbitrated"), + "overlapping_claims_removed": ownership.get( + "overlapping_claims_removed" + ), + "qualified_points_published": point_rows.get("e32_published"), + "qualified_points_withheld": point_rows.get( + "unqualified_ranges_withheld" + ), + "frame_processing_p95_ms": frame_processing.get("p95"), + "build_elapsed_ms": runtime.get("build_elapsed_ms"), + }, + "decision": copy.deepcopy(result.report.get("decision")), + "authority": copy.deepcopy(result.report.get("authority")), + "access": "read-only", + } + + +def _project_e33( + result: E33WorkerShadowResult, + linked_e32: E32TrackGeometryReplay, +) -> dict[str, object]: + identity = _object(result.result.get("identity"), "E33 identity") + linked_identity = _object( + linked_e32.manifest.get("identity"), + "linked E32 identity", + ) + binding = _object( + linked_identity.get("track_geometry_binding"), + "linked E32 binding", + ) + profile = _object(identity.get("profile"), "E33 profile") + worker = _object(identity.get("worker"), "E33 worker") + metrics = _object(result.report.get("metrics"), "E33 metrics") + accounting = _object(metrics.get("accounting"), "E33 accounting") + processing = _object(metrics.get("processing_ms"), "E33 processing") + release_lag = _object(metrics.get("release_lag_ms"), "E33 release lag") + result_age = _object(metrics.get("result_age_ms"), "E33 result age") + resources = _object(metrics.get("resources"), "E33 resources") + process_rss = _object(resources.get("process_rss_mib"), "E33 RSS") + gpu_utilization = _object( + resources.get("gpu_utilization_percent"), + "E33 GPU utilization", + ) + work_queue = _object(metrics.get("work_queue"), "E33 work queue") + result_queue = _object(metrics.get("result_queue"), "E33 result queue") + return { + "result_id": result.result_id, + "created_at_utc": result.result.get("created_at_utc"), + "source_session_id": binding.get("source_session_id"), + "status": result.result.get("acceptance_state"), + "e32_result_id": identity.get("e32_result_id"), + "pipeline_id": identity.get("pipeline"), + "mode": profile.get("mode"), + "worker": { + "node": worker.get("worker_node"), + "container_image": worker.get("container_image"), + "python": worker.get("python"), + "numpy": worker.get("numpy"), + }, + "metrics": { + "source_frames": accounting.get("source_frames"), + "delivered_frames": accounting.get("delivered"), + "input_superseded": accounting.get("input_superseded"), + "result_superseded": accounting.get("result_superseded"), + "effective_delivery_fps": metrics.get("effective_delivery_fps"), + "deadline_miss_fraction": metrics.get("deadline_miss_fraction"), + "processing_p95_ms": processing.get("p95"), + "release_lag_p95_ms": release_lag.get("p95"), + "result_age_p95_ms": result_age.get("p95"), + "process_rss_p95_mib": process_rss.get("p95"), + "gpu_utilization_p95_percent": gpu_utilization.get("p95"), + "gpu_visible": resources.get("gpu_visible"), + "work_queue_capacity": work_queue.get("capacity"), + "result_queue_capacity": result_queue.get("capacity"), + "wall_to_ideal_ratio": metrics.get("wall_to_ideal_ratio"), + }, + "acceptance": copy.deepcopy(result.report.get("acceptance")), + "authority": copy.deepcopy(result.report.get("authority")), + "access": "read-only", + } + + +def _empty_catalog(configured: bool) -> dict[str, object]: + return { + "schema_version": LABORATORY_ADVANCED_CATALOG_SCHEMA, + "configured": configured, + "items": [], + "candidate_total": 0, + "invalid_total": 0, + "access": "read-only", + } + + +def build_advanced_laboratory_router( + *, + e31_root_provider: RootProvider = lambda: None, + e32_root_provider: RootProvider = lambda: None, + e33_root_provider: RootProvider = lambda: None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/e31/results") + def list_e31_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + root = _configured_root(e31_root_provider) + if root is None: + return _empty_catalog(False) + candidates = _candidates(root, _E31_RESULT_ID) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in candidates: + try: + result = _read_e31_cached( + str(candidate.resolve()), + _result_signature(candidate), + ) + if result.report.get("eligible_for_e32") is not True: + raise ValueError("E31 result is not accepted") + if len(items) < limit: + items.append(_project_e31(result)) + except ( + E31SourceQualificationError, + KeyError, + OSError, + TypeError, + ValueError, + ): + invalid_total += 1 + return { + **_empty_catalog(True), + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + } + + @router.get("/e32/results") + def list_e32_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + root = _configured_root(e32_root_provider) + if root is None: + return _empty_catalog(False) + candidates = _candidates(root, _E32_RESULT_ID) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in candidates: + try: + result = _read_e32_cached( + str(candidate.resolve()), + _result_signature(candidate), + ) + if ( + result.report.get("status") + != "accepted-diagnostic-track-geometry-replay" + ): + raise ValueError("E32 result is not accepted") + if len(items) < limit: + items.append(_project_e32(result)) + except ( + E32TrackGeometryReplayError, + KeyError, + OSError, + TypeError, + ValueError, + ): + invalid_total += 1 + return { + **_empty_catalog(True), + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + } + + @router.get("/e33/results") + def list_e33_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + root = _configured_root(e33_root_provider) + e32_root = _configured_root(e32_root_provider) + if root is None or e32_root is None: + return _empty_catalog(False) + candidates = _candidates(root, _E33_RESULT_ID) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in candidates: + try: + result = _read_e33_cached( + str(candidate.resolve()), + _result_signature(candidate), + ) + identity = _object(result.result.get("identity"), "E33 identity") + e32_result_id = identity.get("e32_result_id") + if ( + not result.accepted + or not isinstance(e32_result_id, str) + or _E32_RESULT_ID.fullmatch(e32_result_id) is None + ): + raise ValueError("E33 result is not accepted") + linked_root = e32_root / e32_result_id + linked_e32 = _read_e32_cached( + str(linked_root.resolve(strict=True)), + _result_signature(linked_root), + ) + if len(items) < limit: + items.append(_project_e33(result, linked_e32)) + except ( + E32TrackGeometryReplayError, + E33WorkerShadowError, + KeyError, + OSError, + TypeError, + ValueError, + ): + invalid_total += 1 + return { + **_empty_catalog(True), + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + } + + return router diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 65167c3..22101a3 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -32,6 +32,7 @@ from k1link.sessions import ( SessionRecordingPreparationManager, SessionStore, ) +from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router from k1link.web.device_plugin_composition import load_installed_device_plugins from k1link.web.e30_engineering_api import build_e30_engineering_router from k1link.web.e30_human_review_api import build_e30_human_review_router @@ -496,6 +497,31 @@ app.include_router( ), ) ) +app.include_router( + build_advanced_laboratory_router( + e31_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e31" + / "source-qualifications" + ), + e32_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e32" + / "results" + ), + e33_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e33" + / "results" + ), + ) +) app.include_router( build_e30_review_router( materialization_root_provider=lambda: ( diff --git a/tests/test_advanced_laboratory_api.py b/tests/test_advanced_laboratory_api.py new file mode 100644 index 0000000..fd1c768 --- /dev/null +++ b/tests/test_advanced_laboratory_api.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter +from fastapi.routing import APIRoute + +from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router + + +def _endpoint(router: APIRouter, path: str) -> object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and "GET" in route.methods + ): + return route.endpoint + raise AssertionError(f"GET {path} route is missing") + + +def test_advanced_catalogs_are_empty_when_not_configured() -> None: + router = build_advanced_laboratory_router() + + for name in ("e31", "e32", "e33"): + route = _endpoint(router, f"/api/v1/laboratory/{name}/results") + catalog = route(limit=1) # type: ignore[operator] + assert catalog == { + "schema_version": "missioncore.laboratory-advanced-catalog/v1", + "configured": False, + "items": [], + "candidate_total": 0, + "invalid_total": 0, + "access": "read-only", + } + + +def test_advanced_catalogs_fail_closed_on_incomplete_results( + tmp_path: Path, +) -> None: + e31 = tmp_path / "e31" + e32 = tmp_path / "e32" + e33 = tmp_path / "e33" + for root in (e31, e32, e33): + root.mkdir() + (e31 / f"e31-source-qualification-{'1' * 64}").mkdir() + (e32 / f"e32-track-geometry-{'2' * 64}").mkdir() + (e33 / f"e33-worker-shadow-{'3' * 64}").mkdir() + router = build_advanced_laboratory_router( + e31_root_provider=lambda: e31, + e32_root_provider=lambda: e32, + e33_root_provider=lambda: e33, + ) + + for name in ("e31", "e32", "e33"): + route = _endpoint(router, f"/api/v1/laboratory/{name}/results") + catalog = route(limit=1) # type: ignore[operator] + assert catalog["configured"] is True + assert catalog["candidate_total"] == 1 + assert catalog["invalid_total"] == 1 + assert catalog["items"] == [] + + +def test_advanced_catalog_does_not_follow_a_configured_symlink( + tmp_path: Path, +) -> None: + actual = tmp_path / "actual" + actual.mkdir() + linked = tmp_path / "linked" + linked.symlink_to(actual, target_is_directory=True) + router = build_advanced_laboratory_router( + e31_root_provider=lambda: linked, + ) + + route = _endpoint(router, "/api/v1/laboratory/e31/results") + catalog = route(limit=1) # type: ignore[operator] + + assert catalog["configured"] is False