From 6544d9e9189c50fcc9eeab968a735d3a8a8689f8 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 26 Aug 2026 20:50:22 +0300 Subject: [PATCH] feat(lab): publish fail-closed TGS evidence --- .../LaboratoryMetricEvidenceScene.tsx | 114 ++++- .../src/core/laboratory/advancedIndex.ts | 9 + .../laboratory/advancedLaboratoryResults.ts | 2 + .../src/core/laboratory/advancedResults.ts | 2 +- .../src/core/laboratory/m49TgsFailClosed.ts | 349 ++++++++++++++ .../src/styles/m4-replay-threat.css | 22 + .../laboratory/AdvancedLaboratoryResult.tsx | 4 + .../laboratory/M49TgsFailClosedEvidence.tsx | 152 ++++++ .../laboratory/M49TgsFailClosedResult.tsx | 92 ++++ .../laboratory/M4ReplayThreatVisual.tsx | 251 ++++++++-- .../laboratory/laboratoryArchiveProfiles.ts | 7 + .../useAdvancedLaboratoryCatalog.ts | 2 + .../test/advancedLaboratoryResults.test.mjs | 131 ++++++ .../test/m4ReplayThreat.test.mjs | 18 +- .../m49-tgs-fail-closed-evidence.json | 10 + config/laboratory-execution.json | 14 + config/laboratory-value-review.json | 7 + ...LITY_AND_STATIC_OBSTACLE_STACK_DECISION.md | 20 +- ...M49_TGS_FAIL_CLOSED_EVIDENCE_2026-08-26.md | 87 +++- .../perception/seal_m49_tgs_fail_closed.py | 36 ++ src/k1link/laboratory/m49_tgs_fail_closed.py | 440 ++++++++++++++++++ src/k1link/web/app.py | 12 + src/k1link/web/m49_tgs_fail_closed_api.py | 284 +++++++++++ tests/test_laboratory_evidence_registry.py | 3 +- tests/test_laboratory_execution.py | 4 + .../test_laboratory_value_review_registry.py | 3 +- tests/test_m49_tgs_fail_closed_api.py | 165 +++++++ 27 files changed, 2167 insertions(+), 73 deletions(-) create mode 100644 apps/control-station/src/core/laboratory/m49TgsFailClosed.ts create mode 100644 apps/control-station/src/workspaces/laboratory/M49TgsFailClosedEvidence.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/M49TgsFailClosedResult.tsx create mode 100644 config/laboratories/m49-tgs-fail-closed-evidence.json create mode 100644 experiments/perception/seal_m49_tgs_fail_closed.py create mode 100644 src/k1link/laboratory/m49_tgs_fail_closed.py create mode 100644 src/k1link/web/m49_tgs_fail_closed_api.py create mode 100644 tests/test_m49_tgs_fail_closed_api.py diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index 6d333fe..4c69adb 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -41,8 +41,25 @@ export interface LaboratoryMetricCorridorVisual { halfWidthM: number; } +export type LaboratoryMetricCellState = + | "unobserved" + | "ground-support" + | "nonground-occupied" + | "unknown-rejected"; + +export interface LaboratoryMetricCellEvidence { + centerBodyXyM: readonly [number, number]; + zBoundsM: readonly [number | null, number | null]; + state: LaboratoryMetricCellState; +} + export interface LaboratoryMetricLegendEntry { - id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling" | "low-step"; + id: LaboratoryMetricDecision + | LaboratoryMetricCellState + | "context" + | "local-surface" + | "rolling" + | "low-step"; label: string; } @@ -197,6 +214,9 @@ LaboratoryMetricEvidenceSceneHandle, pointSemanticClassIds?: readonly (number | null)[]; semanticClasses?: readonly RecordedEvidenceSemanticClass[]; semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[]; + classifiedCells?: readonly LaboratoryMetricCellEvidence[]; + classifiedCellSizeM?: number; + showClassifiedCells?: boolean; } >(function LaboratoryMetricEvidenceScene({ pointCloudBodyXyzM, @@ -214,6 +234,9 @@ LaboratoryMetricEvidenceSceneHandle, pointSemanticClassIds, semanticClasses, semanticPalette, + classifiedCells = [], + classifiedCellSizeM = 0.45, + showClassifiedCells = true, }, ref) { const hostRef = useRef(null); const sceneRef = useRef(null); @@ -374,6 +397,63 @@ LaboratoryMetricEvidenceSceneHandle, )); } + if (showClassifiedCells && classifiedCells.length) { + const cellsByState = new Map(); + for (const cell of classifiedCells) { + const cells = cellsByState.get(cell.state) ?? []; + cells.push(cell); + cellsByState.set(cell.state, cells); + } + for (const [state, cells] of cellsByState) { + const color = state === "ground-support" + ? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90]) + : state === "nonground-occupied" + ? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]) + : state === "unknown-rejected" + ? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]) + : tokenColor(host, "--nodedc-text-muted", [96, 99, 106]); + const geometry = new THREE.BoxGeometry( + classifiedCellSizeM * 0.92, + 1, + classifiedCellSizeM * 0.92, + ); + const material = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24, + depthWrite: false, + }); + const mesh = new THREE.InstancedMesh(geometry, material, cells.length); + const matrix = new THREE.Matrix4(); + const scale = new THREE.Vector3(1, 1, 1); + const rotation = new THREE.Quaternion(); + cells.forEach((cell, index) => { + const minimum = cell.zBoundsM[0]; + const maximum = cell.zBoundsM[1]; + const height = minimum === null || maximum === null + ? 0.018 + : Math.max(0.018, maximum - minimum); + const centerZ = minimum === null || maximum === null + ? -0.012 + : (minimum + maximum) / 2; + const [sceneX, sceneY, sceneZ] = scenePoint([ + cell.centerBodyXyM[0], + cell.centerBodyXyM[1], + centerZ, + ]); + scale.set(1, height, 1); + matrix.compose( + new THREE.Vector3(sceneX, sceneY, sceneZ), + rotation, + scale, + ); + mesh.setMatrixAt(index, matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + content.add(mesh); + } + } + for (const obstacle of obstacles) { if ( ( @@ -451,7 +531,10 @@ LaboratoryMetricEvidenceSceneHandle, pointSemanticClassIds, semanticClasses, semanticPalette, + classifiedCells, + classifiedCellSizeM, showCurrentIncrement, + showClassifiedCells, showLocalSurface, showRollingMap, showLowStep, @@ -563,7 +646,9 @@ LaboratoryMetricEvidenceSceneHandle, }); })(); const metricLegendEntries = laboratoryMetricLegendEntries({ - pointCloudCount: pointCloudBodyXyzM.length, + pointCloudCount: pointSemanticClassIds?.every((item) => item !== null) + ? 0 + : pointCloudBodyXyzM.length, localSurfaceCount: localSurfaceBodyXyzM.length, obstacles, showCurrentIncrement, @@ -571,6 +656,28 @@ LaboratoryMetricEvidenceSceneHandle, showRollingMap, showLowStep, }); + const classifiedLegendEntries = (() => { + if (!showClassifiedCells || !classifiedCells.length) return []; + const states = new Set(classifiedCells.map((cell) => cell.state)); + return [ + states.has("ground-support") + ? { id: "ground-support" as const, label: "Ground support" } + : null, + states.has("nonground-occupied") + ? { id: "nonground-occupied" as const, label: "Non-ground occupied" } + : null, + states.has("unknown-rejected") + ? { id: "unknown-rejected" as const, label: "Unknown / rejected" } + : null, + states.has("unobserved") + ? { id: "unobserved" as const, label: "Unobserved" } + : null, + ] + .filter((entry): entry is NonNullable => entry !== null) + .filter((entry) => !semanticLegendEntries.some( + (semanticEntry) => semanticEntry.label === entry.label, + )); + })(); return (
@@ -581,6 +688,9 @@ LaboratoryMetricEvidenceSceneHandle, {metricLegendEntries.map((entry) => ( {entry.label} ))} + {classifiedLegendEntries.map((entry) => ( + {entry.label} + ))} {semanticLegendEntries.map((entry) => ( > = { "m48r3-static-occupancy-shadow": "m48r3-static-occupancy-shadow", "m48s-fixed-class-detector": "m48s-fixed-class-detector-lab", "m48t-risk-quality-temporal": "(?:m48t-risk-quality-temporal-lab|m48q-native-risk-quality-lab)", + "m49-tgs-fail-closed-evidence": "m49-tgs-fail-closed", "m47-reference-graph-shadow": "m47-reference-graph-lab", "m4-replay-threat": "m4-threat-replay", "l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit", @@ -196,6 +200,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { m48r3StaticOccupancy: null, m48s: null, m48t: null, + m49Tgs: null, m4Threat: null, l3: null, l31: null, @@ -326,6 +331,7 @@ export function advancedLaboratoryResultAvailable( : workId === "m48r3-static-occupancy-shadow" ? results.m48r3StaticOccupancy !== null : workId === "m48s-fixed-class-detector" ? results.m48s !== null : workId === "m48t-risk-quality-temporal" ? results.m48t !== null + : workId === "m49-tgs-fail-closed-evidence" ? results.m49Tgs !== null : workId === "m47-reference-graph-shadow" ? results.m47Graph !== null : workId === "m4-replay-threat" ? results.m4Threat !== null : workId === "l3-pointpillars-visual-audit" ? results.l3 !== null @@ -393,6 +399,9 @@ export async function fetchAdvancedLaboratoryResult( } else if (workId === "m48t-risk-quality-temporal") { if (!resultId) throw new AdvancedLaboratoryContractError("M4.8T LAB identity не выбрана."); results.m48t = await fetchM48TRiskQualityResult(resultId, { fetcher, signal }); + } else if (workId === "m49-tgs-fail-closed-evidence") { + if (!resultId) throw new AdvancedLaboratoryContractError("M4.9 TGS LAB identity не выбрана."); + results.m49Tgs = await fetchM49TgsFailClosedResult(resultId, { fetcher, signal }); } else if (workId === "m47-reference-graph-shadow") { if (!resultId) { throw new AdvancedLaboratoryContractError("M4.7 LAB identity не выбрана."); diff --git a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts index 28a1a59..71857cf 100644 --- a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -40,6 +40,7 @@ import type { M48StaticOccupancyQualificationResult } from "./m48StaticOccupancy import type { M48R3StaticOccupancyShadowResult } from "./m48r3StaticOccupancyShadow"; import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector"; import type { M48TRiskQualityResult } from "./m48tRiskQuality"; +import type { M49TgsFailClosedResult } from "./m49TgsFailClosed"; export interface AdvancedLaboratoryResults { m47Graph: M47ReferenceGraphLabResult | null; @@ -49,6 +50,7 @@ export interface AdvancedLaboratoryResults { m48r3StaticOccupancy: M48R3StaticOccupancyShadowResult | null; m48s: M48SFixedClassDetectorResult | null; m48t: M48TRiskQualityResult | null; + m49Tgs: M49TgsFailClosedResult | null; m4Threat: M4ThreatReplayResult | null; l3: L3PointPillarsVisualAuditResult | null; l31: L31PointPillarsRavnovesResult | null; diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 6b7d7fe..6684ba9 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -969,7 +969,7 @@ export async function fetchAdvancedLaboratoryResults({ return { m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null, m48r3StaticOccupancy: null, - m48s: null, m48t: null, m4Threat: null, + m48s: null, m48t: null, m49Tgs: null, m4Threat: null, l3: null, l31: null, l32: null, l33: null, e31, e32, diff --git a/apps/control-station/src/core/laboratory/m49TgsFailClosed.ts b/apps/control-station/src/core/laboratory/m49TgsFailClosed.ts new file mode 100644 index 0000000..c560813 --- /dev/null +++ b/apps/control-station/src/core/laboratory/m49TgsFailClosed.ts @@ -0,0 +1,349 @@ +import type { LaboratoryFetch } from "./advancedResults"; + +const RESULT_ID = /^m49-tgs-fail-closed-[a-f0-9]{64}$/; + +export type M49TgsProfile = "current_increment" | "causal_rolling_1s"; +export type M49TgsStateCode = 0 | 1 | 2 | 3; +export type M49TgsPointStateCode = 1 | 2 | 3; + +export interface M49TgsAnchorSummary { + anchorFrameIndex: number; + slot: number; + pointCount: number; + groundPointCount: number; + nongroundPointCount: number; + rejectedPointCount: number; + groundCellCount: number; + nongroundCellCount: number; + rejectedCellCount: number; + unobservedCellCount: number; + allPointsAccounted: true; +} + +export interface M49TgsFailClosedResult { + resultId: string; + createdAtUtc: string; + source: { + sourceId: "RAVNOVES00"; + sourceSessionId: "20260720T065719Z_viewer_live"; + sourcePackSha256: string; + linkedVisualResultId: string; + anchorFrameIndices: readonly number[]; + }; + configuration: { + profileId: string; + configSha256: string; + coordinateFrame: "map-gravity-local"; + primaryProfile: "causal_rolling_1s"; + cellSizeM: number; + radiusM: number; + }; + execution: { + worker: "Worker 006"; + device: "cpu"; + gpuUsed: false; + wrapperElapsedSeconds: number; + }; + metrics: { + anchorCount: number; + anchorProfileCount: number; + allEligiblePointsAccounted: true; + costmapCellCount: number; + processWallCurrentP50Ms: number; + processWallCurrentMaxMs: number; + processWallRollingP50Ms: number; + processWallRollingMaxMs: number; + processMaxRssKib: number; + primary: readonly M49TgsAnchorSummary[]; + }; + acceptance: { + representationComplete: true; + allPointsAccounted: true; + aosAbsent: true; + gpuAbsent: true; + visualQualityAccepted: false; + traversabilityAccepted: false; + }; + decision: { + state: "visual-review-required"; + candidateRetained: true; + nextAction: string; + }; + limitations: readonly string[]; +} + +export interface M49TgsAnchorSpatial { + resultId: string; + anchorFrameIndex: number; + sourceSequence: number; + profile: M49TgsProfile; + coordinateFrame: "map-gravity-local"; + pointsXyzM: readonly (readonly [number, number, number])[]; + pointStates: readonly M49TgsPointStateCode[]; + costmap: { + cellSizeM: number; + radiusM: number; + centersXyM: readonly (readonly [number, number])[]; + states: readonly M49TgsStateCode[]; + zBoundsM: readonly (readonly [number | null, number | null])[]; + }; + allPointsAccounted: true; + aosUsed: false; +} + +export class M49TgsContractError extends Error {} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new M49TgsContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new M49TgsContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function exact(value: unknown, expected: T, label: string): T { + if (value !== expected) throw new M49TgsContractError(`${label}: нарушен контракт.`); + return expected; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new M49TgsContractError(`${label}: ожидалось число.`); + } + return value; +} + +function integer(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new M49TgsContractError(`${label}: ожидалось целое число.`); + } + return parsed; +} + +function sha256(value: unknown, label: string): string { + const parsed = text(value, label); + if (!/^[a-f0-9]{64}$/.test(parsed)) { + throw new M49TgsContractError(`${label}: неверный SHA-256.`); + } + return parsed; +} + +function resultId(value: unknown): string { + const parsed = text(value, "M49 result id"); + if (!RESULT_ID.test(parsed)) throw new M49TgsContractError("M49 identity недопустима."); + return parsed; +} + +function numbers(value: unknown, size: number, label: string): number[] { + if (!Array.isArray(value) || value.length !== size) { + throw new M49TgsContractError(`${label}: неверная размерность.`); + } + return value.map((item) => numberValue(item, label)); +} + +function anchor(value: unknown, label: string): M49TgsAnchorSummary { + const row = objectValue(value, label); + exact(row.profile_id, "causal_rolling_1s", `${label}.profile`); + exact(row.all_points_accounted, true, `${label}.accounting`); + const parsed: M49TgsAnchorSummary = { + anchorFrameIndex: integer(row.anchor_frame_index, `${label}.anchor`), + slot: integer(row.slot, `${label}.slot`), + pointCount: integer(row.point_count, `${label}.points`), + groundPointCount: integer(row.ground_point_count, `${label}.ground points`), + nongroundPointCount: integer(row.nonground_point_count, `${label}.nonground points`), + rejectedPointCount: integer(row.rejected_point_count, `${label}.rejected points`), + groundCellCount: integer(row.ground_cell_count, `${label}.ground cells`), + nongroundCellCount: integer(row.nonground_cell_count, `${label}.nonground cells`), + rejectedCellCount: integer(row.rejected_cell_count, `${label}.rejected cells`), + unobservedCellCount: integer(row.unobserved_cell_count, `${label}.unobserved cells`), + allPointsAccounted: true, + }; + if ( + parsed.groundPointCount + parsed.nongroundPointCount + parsed.rejectedPointCount + !== parsed.pointCount + || parsed.groundCellCount + parsed.nongroundCellCount + + parsed.rejectedCellCount + parsed.unobservedCellCount !== 2244 + ) { + throw new M49TgsContractError(`${label}: fail-closed accounting нарушен.`); + } + return parsed; +} + +export async function fetchM49TgsFailClosedResult( + id: string, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(id)) throw new M49TgsContractError("M49 identity недопустима."); + const response = await fetcher( + `/api/v1/laboratory/m49/tgs-fail-closed/${encodeURIComponent(id)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) throw new M49TgsContractError(`M49 TGS недоступен: HTTP ${response.status}.`); + const payload = objectValue(await response.json(), "M49 TGS"); + exact(payload.schema_version, "missioncore.m49-tgs-fail-closed-view/v1", "M49 schema"); + exact(payload.result_id, id, "M49 result"); + exact(payload.ground_truth, false, "M49 ground truth"); + exact(payload.access, "read-only", "M49 access"); + const source = objectValue(payload.source, "M49 source"); + const configuration = objectValue(payload.configuration, "M49 configuration"); + const execution = objectValue(payload.execution, "M49 execution"); + const metrics = objectValue(payload.metrics, "M49 metrics"); + const acceptance = objectValue(payload.acceptance, "M49 acceptance"); + const decision = objectValue(payload.decision, "M49 decision"); + if (!Array.isArray(source.anchor_frame_indices) || !Array.isArray(metrics.primary)) { + throw new M49TgsContractError("M49 anchors: ожидался массив."); + } + if (!Array.isArray(payload.limitations)) { + throw new M49TgsContractError("M49 limitations: ожидался массив."); + } + const anchorFrameIndices = source.anchor_frame_indices.map( + (item, index) => integer(item, `M49 anchor ${index}`), + ); + const primary = metrics.primary.map((item, index) => anchor(item, `M49 primary ${index}`)); + if ( + anchorFrameIndices.length !== 10 + || new Set(anchorFrameIndices).size !== 10 + || primary.length !== 10 + || integer(metrics.anchor_count, "M49 anchor count") !== 10 + || integer(metrics.anchor_profile_count, "M49 profile count") !== 20 + || integer(metrics.costmap_cell_count, "M49 costmap cells") !== 2244 + || primary.some((item, index) => item.anchorFrameIndex !== anchorFrameIndices[index]) + ) { + throw new M49TgsContractError("M49 anchor set или costmap contract нарушен."); + } + return { + resultId: resultId(payload.result_id), + createdAtUtc: text(payload.created_at_utc, "M49 created"), + source: { + sourceId: exact(source.source_id, "RAVNOVES00", "M49 source id"), + sourceSessionId: exact( + source.source_session_id, + "20260720T065719Z_viewer_live", + "M49 source session", + ), + sourcePackSha256: sha256(source.source_pack_sha256, "M49 source pack"), + linkedVisualResultId: text(source.linked_visual_result_id, "M49 visual result"), + anchorFrameIndices, + }, + configuration: { + profileId: text(configuration.profile_id, "M49 profile"), + configSha256: sha256(configuration.config_sha256, "M49 config"), + coordinateFrame: exact(configuration.coordinate_frame, "map-gravity-local", "M49 frame"), + primaryProfile: exact(configuration.primary_profile, "causal_rolling_1s", "M49 primary"), + cellSizeM: numberValue(configuration.cell_size_m, "M49 cell size"), + radiusM: numberValue(configuration.radius_m, "M49 radius"), + }, + execution: { + worker: exact(execution.worker, "Worker 006", "M49 worker"), + device: exact(execution.device, "cpu", "M49 device"), + gpuUsed: exact(execution.gpu_used, false, "M49 GPU"), + wrapperElapsedSeconds: numberValue(execution.wrapper_elapsed_seconds, "M49 wall"), + }, + metrics: { + anchorCount: 10, + anchorProfileCount: 20, + allEligiblePointsAccounted: exact(metrics.all_eligible_points_accounted, true, "M49 accounting"), + costmapCellCount: 2244, + processWallCurrentP50Ms: numberValue(metrics.process_wall_current_p50_ms, "M49 current p50"), + processWallCurrentMaxMs: numberValue(metrics.process_wall_current_max_ms, "M49 current max"), + processWallRollingP50Ms: numberValue(metrics.process_wall_rolling_p50_ms, "M49 rolling p50"), + processWallRollingMaxMs: numberValue(metrics.process_wall_rolling_max_ms, "M49 rolling max"), + processMaxRssKib: integer(metrics.process_max_rss_kib, "M49 RSS"), + primary, + }, + acceptance: { + representationComplete: exact(acceptance.representation_complete, true, "M49 representation"), + allPointsAccounted: exact(acceptance.all_points_accounted, true, "M49 points"), + aosAbsent: exact(acceptance.aos_absent, true, "M49 AOS"), + gpuAbsent: exact(acceptance.gpu_absent, true, "M49 GPU absent"), + visualQualityAccepted: exact(acceptance.visual_quality_accepted, false, "M49 visual quality"), + traversabilityAccepted: exact(acceptance.traversability_accepted, false, "M49 traversability"), + }, + decision: { + state: exact(decision.state, "visual-review-required", "M49 decision"), + candidateRetained: exact(decision.candidate_retained, true, "M49 retained"), + nextAction: text(decision.next_action, "M49 next action"), + }, + limitations: payload.limitations.map((item, index) => text(item, `M49 limitation ${index}`)), + }; +} + +export async function fetchM49TgsAnchorSpatial( + id: string, + anchorFrameIndex: number, + profile: M49TgsProfile = "causal_rolling_1s", + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(id) || !Number.isSafeInteger(anchorFrameIndex) || anchorFrameIndex < 0) { + throw new M49TgsContractError("M49 anchor identity недопустима."); + } + const response = await fetcher( + `/api/v1/laboratory/m49/tgs-fail-closed/${encodeURIComponent(id)}/anchors/${anchorFrameIndex}/spatial?profile=${encodeURIComponent(profile)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) throw new M49TgsContractError(`M49 anchor недоступен: HTTP ${response.status}.`); + const payload = objectValue(await response.json(), "M49 anchor"); + exact(payload.schema_version, "missioncore.m49-tgs-anchor-spatial/v1", "M49 anchor schema"); + exact(payload.result_id, id, "M49 anchor result"); + exact(payload.coordinate_frame, "map-gravity-local", "M49 anchor frame"); + exact(payload.all_points_accounted, true, "M49 anchor accounting"); + exact(payload.aos_used, false, "M49 anchor AOS"); + if (!Array.isArray(payload.points_xyz_m) || !Array.isArray(payload.point_states)) { + throw new M49TgsContractError("M49 points: ожидался массив."); + } + const costmap = objectValue(payload.costmap, "M49 costmap"); + if (!Array.isArray(costmap.centers_xy_m) || !Array.isArray(costmap.states) || !Array.isArray(costmap.z_bounds_m)) { + throw new M49TgsContractError("M49 costmap arrays: нарушен контракт."); + } + const points = payload.points_xyz_m.map((item, index) => numbers(item, 3, `M49 point ${index}`) as [number, number, number]); + const pointStates = payload.point_states.map((item, index) => { + const state = integer(item, `M49 point state ${index}`); + if (state !== 1 && state !== 2 && state !== 3) throw new M49TgsContractError("M49 point state неизвестен."); + return state; + }); + if (points.length !== pointStates.length) throw new M49TgsContractError("M49 point accounting нарушен."); + const centers = costmap.centers_xy_m.map((item, index) => numbers(item, 2, `M49 cell ${index}`) as [number, number]); + const states = costmap.states.map((item, index) => { + const state = integer(item, `M49 cell state ${index}`); + if (state !== 0 && state !== 1 && state !== 2 && state !== 3) throw new M49TgsContractError("M49 cell state неизвестен."); + return state; + }); + const zBounds = costmap.z_bounds_m.map((item, index) => { + if (!Array.isArray(item) || item.length !== 2) throw new M49TgsContractError(`M49 z ${index}: размерность.`); + return item.map((value) => value === null ? null : numberValue(value, `M49 z ${index}`)) as [number | null, number | null]; + }); + if ( + centers.length !== 2244 + || centers.length !== states.length + || centers.length !== zBounds.length + || integer(payload.anchor_frame_index, "M49 anchor frame") !== anchorFrameIndex + || integer(payload.source_sequence, "M49 source sequence") !== anchorFrameIndex + ) { + throw new M49TgsContractError("M49 costmap accounting нарушен."); + } + return { + resultId: id, + anchorFrameIndex, + sourceSequence: anchorFrameIndex, + profile: exact(payload.profile, profile, "M49 profile"), + coordinateFrame: "map-gravity-local", + pointsXyzM: points, + pointStates, + costmap: { + cellSizeM: numberValue(costmap.cell_size_m, "M49 cell size"), + radiusM: numberValue(costmap.radius_m, "M49 radius"), + centersXyM: centers, + states, + zBoundsM: zBounds, + }, + allPointsAccounted: true, + aosUsed: false, + }; +} diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 519b0c7..b0d3d98 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -91,6 +91,11 @@ justify-content: space-between; } +.m4-replay-threat-evidence-viewer[data-mode-controls="content"] + .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] { + right: 3.8rem; +} + .m4-replay-threat-visual__pane-toolbar > *, .m4-replay-threat-visual__spatial-toolbar-end > * { pointer-events: auto; @@ -350,3 +355,20 @@ background: rgb(var(--nodedc-accent-rgb)); opacity: 0.72; } + +.laboratory-metric-evidence-scene__legend span[data-decision="ground-support"]::before { + background: rgb(var(--nodedc-success-rgb)); +} + +.laboratory-metric-evidence-scene__legend span[data-decision="nonground-occupied"]::before { + background: rgb(var(--nodedc-danger-rgb)); +} + +.laboratory-metric-evidence-scene__legend span[data-decision="unknown-rejected"]::before { + background: rgb(var(--nodedc-warning-rgb)); +} + +.laboratory-metric-evidence-scene__legend span[data-decision="unobserved"]::before { + background: var(--nodedc-text-muted); + opacity: 0.42; +} diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index 0cbbdf3..fb25a31 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -48,6 +48,7 @@ import { M48StaticOccupancyQualificationResultView } from "./M48StaticOccupancyQ import { M48R3StaticOccupancyShadowResultView } from "./M48R3StaticOccupancyShadowResult"; import { M48SFixedClassDetectorResultView } from "./M48SFixedClassDetectorResult"; import { M48TRiskQualityResultView } from "./M48TRiskQualityResult"; +import { M49TgsFailClosedResultView } from "./M49TgsFailClosedResult"; export { isAdvancedLaboratoryWorkId }; export type { AdvancedLaboratoryWorkId }; @@ -108,6 +109,9 @@ export function AdvancedLaboratoryResult({ if (workId === "m48t-risk-quality-temporal" && results.m48t) { return ; } + if (workId === "m49-tgs-fail-closed-evidence" && results.m49Tgs) { + return ; + } if (workId === "m47-reference-graph-shadow" && results.m47Graph) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/M49TgsFailClosedEvidence.tsx b/apps/control-station/src/workspaces/laboratory/M49TgsFailClosedEvidence.tsx new file mode 100644 index 0000000..bd22d9b --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M49TgsFailClosedEvidence.tsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { + RecordedEvidenceSemanticClass, + RecordedEvidenceSemanticPaletteEntry, +} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay"; +import { + fetchM49TgsAnchorSpatial, + type M49TgsAnchorSpatial, + type M49TgsFailClosedResult, + type M49TgsStateCode, +} from "../../core/laboratory/m49TgsFailClosed"; +import { + M4ReplayThreatVisual, + type M4ReplayClassifiedSpatialFrame, + type M4ReplayThreatReviewAnchor, +} from "./M4ReplayThreatVisual"; + +const CLASSES: readonly RecordedEvidenceSemanticClass[] = [ + { id: 1, label: "Ground support" }, + { id: 2, label: "Non-ground occupied" }, + { id: 3, label: "Unknown / rejected" }, +]; + +const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [ + { classId: 1, color: { kind: "token", token: "--nodedc-success-rgb" } }, + { classId: 2, color: { kind: "token", token: "--nodedc-danger-rgb" } }, + { classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } }, +]; + +function cellState(code: M49TgsStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] { + if (code === 1) return "ground-support"; + if (code === 2) return "nonground-occupied"; + if (code === 3) return "unknown-rejected"; + return "unobserved"; +} + +function message(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "M49 TGS spatial evidence недоступно."; +} + +export function M49TgsFailClosedEvidence({ + result, +}: { + result: M49TgsFailClosedResult; +}) { + const [activeSequence, setActiveSequence] = useState(null); + const [spatial, setSpatial] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const spatialCacheRef = useRef(new Map()); + const anchorSequences = useMemo( + () => new Set(result.metrics.primary.map((item) => item.anchorFrameIndex)), + [result.metrics.primary], + ); + const expectedAtSequence = activeSequence !== null && anchorSequences.has(activeSequence); + + useEffect(() => { + if (activeSequence === null || !anchorSequences.has(activeSequence)) { + setSpatial(null); + setLoading(false); + setError(null); + return; + } + const cacheKey = `${result.resultId}:${activeSequence}`; + const cached = spatialCacheRef.current.get(cacheKey); + if (cached) { + setSpatial(cached); + setLoading(false); + setError(null); + return; + } + const controller = new AbortController(); + setSpatial(null); + setLoading(true); + setError(null); + void fetchM49TgsAnchorSpatial( + result.resultId, + activeSequence, + "causal_rolling_1s", + { signal: controller.signal }, + ) + .then((next) => { + if (!controller.signal.aborted) { + spatialCacheRef.current.set(cacheKey, next); + setSpatial(next); + } + }) + .catch((caught: unknown) => { + if (!controller.signal.aborted) setError(message(caught)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [activeSequence, anchorSequences, result.resultId]); + + const reviewAnchors = useMemo( + () => result.metrics.primary.map((item) => ({ + id: `m49-tgs-${item.anchorFrameIndex}`, + sourceSequence: item.anchorFrameIndex, + extentXyxyNormalized: [0, 0, 0, 0], + matchedAtThreshold: false, + statusLabel: "визуальная проверка", + })), + [result.metrics.primary], + ); + + const classifiedFrame = useMemo(() => { + if (!spatial) return null; + return { + sourceSequence: spatial.sourceSequence, + pointsMapGravityLocalXyzM: spatial.pointsXyzM, + pointClassIds: spatial.pointStates, + cellsMapGravityLocal: spatial.costmap.centersXyM.map((center, index) => ({ + centerXyM: center, + zBoundsM: spatial.costmap.zBoundsM[index]!, + state: cellState(spatial.costmap.states[index]!), + })), + cellSizeM: spatial.costmap.cellSizeM, + classes: CLASSES, + palette: PALETTE, + }; + }, [spatial]); + + const handleSequenceChange = useCallback((sequence: number | null) => { + setActiveSequence(sequence); + }, []); + + return ( + + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M49TgsFailClosedResult.tsx b/apps/control-station/src/workspaces/laboratory/M49TgsFailClosedResult.tsx new file mode 100644 index 0000000..bedf80c --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M49TgsFailClosedResult.tsx @@ -0,0 +1,92 @@ +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { M49TgsFailClosedResult } from "../../core/laboratory/m49TgsFailClosed"; +import { M49TgsFailClosedEvidence } from "./M49TgsFailClosedEvidence"; + +function number(value: number, digits = 1): string { + return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); +} + +export function M49TgsFailClosedResultView({ + rigLabel, + result, +}: { + rigLabel: string; + result: M49TgsFailClosedResult; +}) { + const worst = [...result.metrics.primary].sort( + (left, right) => ( + right.nongroundPointCount / Math.max(right.pointCount, 1) + - left.nongroundPointCount / Math.max(left.pointCount, 1) + ), + )[0]!; + const status = "Representation complete; визуальное качество ещё не принято"; + return ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 7ed68d0..b587052 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Button, Icon, @@ -12,6 +12,7 @@ import { import { ObservationTimeline } from "../../components/ObservationTimeline"; import { LaboratoryMetricEvidenceScene, + type LaboratoryMetricCellEvidence, type LaboratoryMetricEvidenceSceneHandle, type LaboratoryMetricSceneMode, } from "../../components/laboratory/LaboratoryMetricEvidenceScene"; @@ -103,6 +104,31 @@ export interface M4ReplayThreatReviewAnchor { sourceSequence: number; extentXyxyNormalized: readonly [number, number, number, number]; matchedAtThreshold: boolean; + statusLabel?: string; +} + +export interface M4ReplayClassifiedSpatialFrame { + sourceSequence: number; + pointsMapGravityLocalXyzM: readonly (readonly [number, number, number])[]; + pointClassIds: readonly (number | null)[]; + cellsMapGravityLocal: readonly { + centerXyM: readonly [number, number]; + zBoundsM: readonly [number | null, number | null]; + state: LaboratoryMetricCellEvidence["state"]; + }[]; + cellSizeM: number; + classes: readonly RecordedEvidenceSemanticClass[]; + palette: readonly RecordedEvidenceSemanticPaletteEntry[]; +} + +export interface M4ReplayClassifiedSpatialLayer { + label: string; + pointLayerLabel: string; + cellLayerLabel: string; + expectedAtSequence: boolean; + frame: M4ReplayClassifiedSpatialFrame | null; + loading: boolean; + error: string | null; } const EMPTY_REVIEW_ANCHORS: readonly M4ReplayThreatReviewAnchor[] = []; @@ -115,6 +141,9 @@ export function M4ReplayThreatVisual({ reviewLabel = "Контрольные примеры M4.8R1", timelineEndpointRoot, evidenceLabel = "M4.6", + initialSpatialMode = null, + classifiedSpatialLayer, + onActiveSequenceChange, }: { resultId: string; semantic?: M4ReplayThreatSemanticLayer; @@ -123,9 +152,14 @@ export function M4ReplayThreatVisual({ reviewLabel?: string; timelineEndpointRoot?: string; evidenceLabel?: string; + initialSpatialMode?: LaboratoryMetricSceneMode | null; + classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer; + onActiveSequenceChange?: (sequence: number | null) => void; }) { const [mediaMode, setMediaMode] = useState("video"); - const [spatialMode, setSpatialMode] = useState(null); + const [spatialMode, setSpatialMode] = useState( + initialSpatialMode, + ); const [showCurrentIncrement, setShowCurrentIncrement] = useState(true); const [showLocalSurface, setShowLocalSurface] = useState(true); const [showRollingMap, setShowRollingMap] = useState(true); @@ -220,6 +254,9 @@ export function M4ReplayThreatVisual({ }, [resultId]); if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame; const frame = timelineFrame.activeFrame ?? lastFrameRef.current; + useEffect(() => { + onActiveSequenceChange?.(frame?.sequence ?? null); + }, [frame?.sequence, onActiveSequenceChange]); const lastSpatialFrameRef = useRef<{ resultId: string; frame: M4ThreatTimelineFrame; @@ -303,12 +340,12 @@ export function M4ReplayThreatVisual({ ); }, [frame, metadata.timeline, showStaticObstacles]); const activeBoxes = useMemo( - () => [ + () => classifiedSpatialLayer ? [] : [ ...boxes(frame?.cameraProposals ?? []), ...staticObstacleBoxes, ...reviewAnchorBoxes, ], - [frame, reviewAnchorBoxes, staticObstacleBoxes], + [classifiedSpatialLayer, frame, reviewAnchorBoxes, staticObstacleBoxes], ); const semanticClasses = useMemo( () => semantic?.taxonomy.map((item) => ({ @@ -363,6 +400,68 @@ export function M4ReplayThreatVisual({ return status === 2 || status === 3 ? classId : null; }); }, [semantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]); + const classifiedSpatialFrame = !displayingBufferedFrame + && classifiedSpatialLayer?.frame?.sourceSequence === frame?.sequence + && spatialFrame?.sequence === frame?.sequence + ? classifiedSpatialLayer?.frame ?? null + : null; + const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0; + const mapGravityLocalSensorToBodyGround = useCallback(( + point: readonly [number, number, number], + ): readonly [number, number, number] => { + const basis = spatialFrame?.bodyFrame?.basisMapFromBody; + const rotated: readonly [number, number, number] = basis ? [ + basis[0][0] * point[0] + basis[1][0] * point[1] + basis[2][0] * point[2], + basis[0][1] * point[0] + basis[1][1] * point[1] + basis[2][1] * point[2], + basis[0][2] * point[0] + basis[1][2] * point[1] + basis[2][2] * point[2], + ] : point; + // TGS evidence is translation-only map-gravity-local with the current LiDAR + // as its origin. The metric scene uses the body ground projection as z=0. + return [rotated[0], rotated[1], rotated[2] + nominalSensorHeightM]; + }, [nominalSensorHeightM, spatialFrame?.bodyFrame?.basisMapFromBody]); + const classifiedPointsBody = useMemo( + () => classifiedSpatialFrame?.pointsMapGravityLocalXyzM.map( + mapGravityLocalSensorToBodyGround, + ) ?? [], + [classifiedSpatialFrame, mapGravityLocalSensorToBodyGround], + ); + const classifiedCellsBody = useMemo( + () => classifiedSpatialFrame?.cellsMapGravityLocal.map((cell) => { + const body = mapGravityLocalSensorToBodyGround([ + cell.centerXyM[0], + cell.centerXyM[1], + 0, + ]); + const [minimumSensorRelativeZ, maximumSensorRelativeZ] = cell.zBoundsM; + return { + centerBodyXyM: [body[0], body[1]], + zBoundsM: [ + minimumSensorRelativeZ === null + ? null + : minimumSensorRelativeZ + nominalSensorHeightM, + maximumSensorRelativeZ === null + ? null + : maximumSensorRelativeZ + nominalSensorHeightM, + ], + state: cell.state, + }; + }) ?? [], + [classifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM], + ); + const classifiedCellCounts = useMemo(() => ({ + ground: classifiedSpatialFrame?.cellsMapGravityLocal.filter( + (cell) => cell.state === "ground-support", + ).length ?? 0, + occupied: classifiedSpatialFrame?.cellsMapGravityLocal.filter( + (cell) => cell.state === "nonground-occupied", + ).length ?? 0, + rejected: classifiedSpatialFrame?.cellsMapGravityLocal.filter( + (cell) => cell.state === "unknown-rejected", + ).length ?? 0, + unobserved: classifiedSpatialFrame?.cellsMapGravityLocal.filter( + (cell) => cell.state === "unobserved", + ).length ?? 0, + }), [classifiedSpatialFrame]); const sceneObstacles = useMemo(() => spatialFrame?.metricObstacles.map((obstacle) => ({ id: obstacle.componentId, decision: obstacle.assessment.decision, @@ -522,7 +621,32 @@ export function M4ReplayThreatVisual({
) : null; - const spatialLayerControls = ( + const spatialLayerControls = classifiedSpatialLayer ? ( +
+ + +
+ ) : (
({ value: String(index), - label: `${index + 1}/${reviewAnchors.length} · кадр ${anchor.sourceSequence + 1} · ${anchor.matchedAtThreshold ? "покрыт" : "пропуск"}`, + label: `${index + 1}/${reviewAnchors.length} · кадр ${anchor.sourceSequence + 1} · ${anchor.statusLabel ?? (anchor.matchedAtThreshold ? "покрыт" : "пропуск")}`, }))} variant="split" menuWidth="anchor" @@ -671,39 +795,48 @@ export function M4ReplayThreatVisual({
Spatial evidence - - {currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling - {metadata.timeline.occupancyProvenanceDelivery - ? ` · ${lowStepObstacles.length} low-step` - : ""} - - - {spatialFrame - ? `${spatialFrame.pointCloudSampleCount}/${spatialFrame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames` - : "квалифицированный spatial frame ещё не получен"} - {frame.worldStateAvailable - ? " · world-state delivered" - : ` · world-state gap (${frame.terminalOutcome})`} - {accumulatedCameraPoints - ? ` · camera points ${accumulatedCameraPoints.sampleCount}/${accumulatedCameraPoints.projectedPointCount} · causal ${accumulatedCameraPoints.windowSeconds.toFixed(1)} с / ${accumulatedCameraPoints.sourceFrameCount} frames` - : pointCloudOverlay - ? ` · camera points ${frame.cameraProjectedSampleCount}/${frame.cameraProjectedPointCount} exact-current · накопление загружается` - : showMediaPoints && cameraPointOverlay.error - ? " · накопленное camera cloud недоступно" - : ""} - {semantic && spatialSemanticFrame - ? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}` - : semantic ? " · semantic buffer" : ""} - + {classifiedSpatialLayer + ? classifiedSpatialFrame + ? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} cells` + : "TGS spatial buffer" + : `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`} + {classifiedSpatialLayer + ? classifiedSpatialFrame + ? "map-gravity-local · all eligible points accounted · causal rolling 1 s" + : classifiedSpatialLayer.error ?? `Открываем ${classifiedSpatialLayer.label}` + : ( + <> + {spatialFrame + ? `${spatialFrame.pointCloudSampleCount}/${spatialFrame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames` + : "квалифицированный spatial frame ещё не получен"} + {frame.worldStateAvailable + ? " · world-state delivered" + : ` · world-state gap (${frame.terminalOutcome})`} + {accumulatedCameraPoints + ? ` · camera points ${accumulatedCameraPoints.sampleCount}/${accumulatedCameraPoints.projectedPointCount} · causal ${accumulatedCameraPoints.windowSeconds.toFixed(1)} с / ${accumulatedCameraPoints.sourceFrameCount} frames` + : pointCloudOverlay + ? ` · camera points ${frame.cameraProjectedSampleCount}/${frame.cameraProjectedPointCount} exact-current · накопление загружается` + : showMediaPoints && cameraPointOverlay.error + ? " · накопленное camera cloud недоступно" + : ""} + {semantic && spatialSemanticFrame + ? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}` + : semantic ? " · semantic buffer" : ""} + + )}
- Virtual corridor - - {spatialFrame?.decisionCounts.threat ?? 0} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`} - - - {metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED - + {classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"} + {classifiedSpatialLayer + ? classifiedSpatialFrame + ? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved` + : classifiedSpatialLayer.loading || displayingBufferedFrame ? "loading" : "unavailable" + : `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`} + {classifiedSpatialLayer + ? classifiedSpatialFrame + ? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF` + : "visual review only · navigation authority OFF" + : `${metadata.timeline.corridor.forwardLengthM} м · body ${metadata.timeline.rig.lengthM}×${metadata.timeline.rig.widthM} м · REPLAY-SIMULATED`}
) : undefined; @@ -802,26 +935,50 @@ export function M4ReplayThreatVisual({ ) : null} - {spatialFrame ? ( + {spatialFrame && (!classifiedSpatialLayer || classifiedSpatialFrame) ? ( ) : null} + {classifiedSpatialLayer && !classifiedSpatialFrame ? ( +
+ {classifiedSpatialLayer.loading || displayingBufferedFrame + ?
+ ) : null} {frame && !frame.spatialAvailable ? (
{spatialFrame diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index df417a2..0b491e1 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -105,6 +105,13 @@ const KNOWN_WORKS: Readonly `${rig(rigLabel)} RIGHT · Camera + gravity-aligned LiDAR`, + experimentId: "m49-tgs-fail-closed-evidence", + experimentName: "TRAVEL TGS fail-closed traversability evidence", + variantName: "M4.9T4 · 10 anchors · causal rolling 1 s · AOS OFF", + }, "m47-reference-graph-shadow": { profileId: "rig-dual-evidence-virtual-corridor-v1", profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`, diff --git a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts index 1b37219..51842ec 100644 --- a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts +++ b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts @@ -25,6 +25,7 @@ function mergeResults( m48r3StaticOccupancy: next.m48r3StaticOccupancy ?? current.m48r3StaticOccupancy, m48s: next.m48s ?? current.m48s, m48t: next.m48t ?? current.m48t, + m49Tgs: next.m49Tgs ?? current.m49Tgs, m4Threat: next.m4Threat ?? current.m4Threat, l3: next.l3 ?? current.l3, l31: next.l31 ?? current.l31, @@ -124,6 +125,7 @@ export function useAdvancedLaboratoryCatalog({ "m48-small-static-passage-regression", "m48-static-occupancy-qualification", "m48r3-static-occupancy-shadow", + "m49-tgs-fail-closed-evidence", ].includes(selectedWorkId) && !indexedResultId ) return; diff --git a/apps/control-station/test/advancedLaboratoryResults.test.mjs b/apps/control-station/test/advancedLaboratoryResults.test.mjs index 032c547..e895beb 100644 --- a/apps/control-station/test/advancedLaboratoryResults.test.mjs +++ b/apps/control-station/test/advancedLaboratoryResults.test.mjs @@ -7,6 +7,7 @@ let server; let fetchAdvancedLaboratoryResults; let fetchAdvancedLaboratoryIndex; let fetchAdvancedLaboratoryResult; +let fetchM49TgsAnchorSpatial; let AdvancedLaboratoryContractError; let buildLaboratoryCatalog; let buildLaboratoryProfiles; @@ -836,6 +837,77 @@ function e40() { }; } +function m49View() { + const anchors = [171, 306, 368, 402, 450, 509, 525, 744, 1122, 1856]; + return { + schema_version: "missioncore.m49-tgs-fail-closed-view/v1", + result_id: `m49-tgs-fail-closed-${"2".repeat(64)}`, + created_at_utc: "2026-08-26T17:09:43Z", + source: { + source_id: "RAVNOVES00", + source_session_id: "20260720T065719Z_viewer_live", + source_pack_sha256: "3".repeat(64), + linked_visual_result_id: `m4-threat-replay-${"4".repeat(64)}`, + anchor_frame_indices: anchors, + }, + configuration: { + profile_id: "m49-ravnoves00-tgs-fail-closed-evidence/v1", + config_sha256: "5".repeat(64), + coordinate_frame: "map-gravity-local", + primary_profile: "causal_rolling_1s", + cell_size_m: 0.45, + radius_m: 12, + }, + execution: { + worker: "Worker 006", + device: "cpu", + gpu_used: false, + wrapper_elapsed_seconds: 12.1, + }, + metrics: { + anchor_count: 10, + anchor_profile_count: 20, + all_eligible_points_accounted: true, + costmap_cell_count: 2244, + process_wall_current_p50_ms: 20, + process_wall_current_max_ms: 30, + process_wall_rolling_p50_ms: 30, + process_wall_rolling_max_ms: 40, + process_max_rss_kib: 9292, + primary: anchors.map((anchor, slot) => ({ + anchor_frame_index: anchor, + slot, + profile_id: "causal_rolling_1s", + point_count: 3, + ground_point_count: 1, + nonground_point_count: 1, + rejected_point_count: 1, + ground_cell_count: 1, + nonground_cell_count: 1, + rejected_cell_count: 1, + unobserved_cell_count: 2241, + all_points_accounted: true, + })), + }, + acceptance: { + representation_complete: true, + all_points_accounted: true, + aos_absent: true, + gpu_absent: true, + visual_quality_accepted: false, + traversability_accepted: false, + }, + decision: { + state: "visual-review-required", + candidate_retained: true, + next_action: "Review the ten anchors.", + }, + limitations: ["bounded diagnostic evidence"], + ground_truth: false, + access: "read-only", + }; +} + before(async () => { server = await createServer({ appType: "custom", @@ -850,6 +922,9 @@ before(async () => { fetchAdvancedLaboratoryIndex, fetchAdvancedLaboratoryResult, } = await server.ssrLoadModule("/src/core/laboratory/advancedIndex.ts")); + ({ fetchM49TgsAnchorSpatial } = await server.ssrLoadModule( + "/src/core/laboratory/m49TgsFailClosed.ts", + )); ({ buildLaboratoryCatalog, buildLaboratoryProfiles, @@ -1596,6 +1671,62 @@ test("selected advanced LAB fetches only its own strict catalog", async () => { ]); }); +test("selected M49 LAB preserves the sealed fail-closed contract", async () => { + const payload = m49View(); + const requests = []; + const decoded = await fetchAdvancedLaboratoryResult( + "m49-tgs-fail-closed-evidence", + { + resultId: payload.result_id, + fetcher: async (input, init) => { + requests.push({ input: String(input), method: init?.method }); + return new Response(JSON.stringify(payload), { status: 200 }); + }, + }, + ); + + assert.equal(decoded.m49Tgs.metrics.anchorCount, 10); + assert.equal(decoded.m49Tgs.metrics.costmapCellCount, 2244); + assert.equal(decoded.m49Tgs.acceptance.visualQualityAccepted, false); + assert.deepEqual(requests, [{ + input: `/api/v1/laboratory/m49/tgs-fail-closed/${payload.result_id}`, + method: "GET", + }]); +}); + +test("M49 anchor fetch keeps every point and all four costmap states", async () => { + const resultId = m49View().result_id; + const centers = Array.from({ length: 2244 }, (_, index) => [index * 0.45, 0]); + const states = Array.from({ length: 2244 }, (_, index) => index % 4); + const zBounds = states.map((state) => state === 0 ? [null, null] : [0, 0.2]); + const decoded = await fetchM49TgsAnchorSpatial(resultId, 171, "causal_rolling_1s", { + fetcher: async () => new Response(JSON.stringify({ + schema_version: "missioncore.m49-tgs-anchor-spatial/v1", + result_id: resultId, + anchor_frame_index: 171, + source_sequence: 171, + profile: "causal_rolling_1s", + coordinate_frame: "map-gravity-local", + points_xyz_m: [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + point_states: [1, 2, 3], + costmap: { + cell_size_m: 0.45, + radius_m: 12, + centers_xy_m: centers, + states, + z_bounds_m: zBounds, + }, + all_points_accounted: true, + aos_used: false, + access: "read-only", + }), { status: 200 }), + }); + + assert.deepEqual(decoded.pointStates, [1, 2, 3]); + assert.equal(decoded.costmap.states.length, 2244); + assert.deepEqual(new Set(decoded.costmap.states), new Set([0, 1, 2, 3])); +}); + test("keeps valid LAB catalogs available when one transport endpoint fails", async () => { const decoded = await fetchAdvancedLaboratoryResults({ fetcher: async (input) => { diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 5b1ec47..60d46b0 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -799,7 +799,23 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(metricScene, /OrbitControls/); assert.match(visual, /LOCAL SLAM/); assert.match(visual, /showLocalSurface/); - assert.match(visual, /pointCloudBodyXyzM=\{spatialFrame\.pointCloudBodyXyzM\}/); + assert.match( + visual, + /pointCloudBodyXyzM=\{classifiedSpatialFrame[\s\S]*\? classifiedPointsBody[\s\S]*: spatialFrame\.pointCloudBodyXyzM\}/, + ); + assert.match( + visual, + /const classifiedSpatialFrame = !displayingBufferedFrame[\s\S]*classifiedSpatialLayer\?\.frame\?\.sourceSequence === frame\?\.sequence[\s\S]*spatialFrame\?\.sequence === frame\?\.sequence/, + ); + assert.match( + visual, + /mapGravityLocalSensorToBodyGround[\s\S]*rotated\[2\] \+ nominalSensorHeightM/, + ); + assert.match( + visual, + /zBoundsM: \[[\s\S]*minimumSensorRelativeZ \+ nominalSensorHeightM[\s\S]*maximumSensorRelativeZ \+ nominalSensorHeightM/, + ); + assert.match(visual, /classifiedCells=\{classifiedCellsBody\}/); assert.match(metricScene, /Локальная SLAM-поверхность/); assert.match(visual, /showJumpToEnd=\{false\}/); assert.doesNotMatch(visual, /Назад на 5 секунд/); diff --git a/config/laboratories/m49-tgs-fail-closed-evidence.json b/config/laboratories/m49-tgs-fail-closed-evidence.json new file mode 100644 index 0000000..d82c03d --- /dev/null +++ b/config/laboratories/m49-tgs-fail-closed-evidence.json @@ -0,0 +1,10 @@ +{ + "schema_version": "missioncore.laboratory-evidence-definition/v1", + "work_id": "m49-tgs-fail-closed-evidence", + "evidence": { + "runtime_relative_root": "m49/tgs-fail-closed-results", + "result_id_prefix": "m49-tgs-fail-closed", + "document_name": "manifest.json", + "schema_version": "missioncore.m49-tgs-fail-closed-result/v1" + } +} diff --git a/config/laboratory-execution.json b/config/laboratory-execution.json index f59f658..3f86498 100644 --- a/config/laboratory-execution.json +++ b/config/laboratory-execution.json @@ -162,6 +162,20 @@ "run": "missioncore.laboratory-run/v1", "evidence": "missioncore.m48t-risk-quality-temporal-lab/v1" } + }, + { + "work_id": "m49-tgs-fail-closed-evidence", + "lifecycle": "experimental", + "isolation": "bounded-adapter", + "adapter_id": "experimental.m49-tgs-fail-closed-evidence/v1", + "input_roles": ["repository_root"], + "contracts": { + "source": "missioncore.m49-tgs-worker-evidence/v1", + "provider": "missioncore.travel-tgs-ground-segmentation/v1", + "graph": "missioncore.m49-fail-closed-costmap-evidence/v1", + "run": "missioncore.laboratory-run/v1", + "evidence": "missioncore.m49-tgs-fail-closed-result/v1" + } } ], "legacy_work_ids": [ diff --git a/config/laboratory-value-review.json b/config/laboratory-value-review.json index 8cd0e2d..57bf47b 100644 --- a/config/laboratory-value-review.json +++ b/config/laboratory-value-review.json @@ -267,6 +267,13 @@ "signal": "progress", "lifecycle": "current", "visual_evidence": "available" + }, + { + "catalog_id": "m49-tgs-fail-closed-evidence", + "evidence_id": "m49-tgs-fail-closed-9d5cb089bb5cc23f829acb47eda52eaa886db3b60f8fa7d57e02e27f642e837b", + "signal": "progress", + "lifecycle": "current", + "visual_evidence": "available" } ] } diff --git a/docs/23_TRAVERSABILITY_AND_STATIC_OBSTACLE_STACK_DECISION.md b/docs/23_TRAVERSABILITY_AND_STATIC_OBSTACLE_STACK_DECISION.md index 6732023..6c5b033 100644 --- a/docs/23_TRAVERSABILITY_AND_STATIC_OBSTACLE_STACK_DECISION.md +++ b/docs/23_TRAVERSABILITY_AND_STATIC_OBSTACLE_STACK_DECISION.md @@ -326,7 +326,11 @@ invocation. It emits a deterministic `0.45 m`, `12 m` local evidence grid while preserving unobserved cells. See [`experiments/perception/M49_TGS_FAIL_CLOSED_EVIDENCE_2026-08-26.md`](../experiments/perception/M49_TGS_FAIL_CLOSED_EVIDENCE_2026-08-26.md). Representation/accounting is accepted for the visual gate; obstacle and -traversability quality are not yet accepted. +traversability quality are not yet accepted. The sealed result is now published +in the canonical LAB as +`m49-tgs-fail-closed-9d5cb089bb5cc23f829acb47eda52eaa886db3b60f8fa7d57e02e27f642e837b`, +linked to the current M4 v3 recorded timeline. Publication adds no navigation +or actuation authority. ### T4 — Candidate C occupancy/ESDF probe @@ -345,12 +349,14 @@ traversability quality are not yet accepted. ## Immediate next action -Import the sealed gravity-aligned TGS evidence pack into one laboratory review -surface and review the ten anchors in metric 3D/costmap space. Preserve +Review the published gravity-aligned TGS evidence pack across all ten anchors +in metric 3D/costmap space. Preserve `GROUND_SUPPORT`, `NONGROUND_OCCUPIED`, `UNKNOWN_REJECTED` and `UNOBSERVED` as separate products; do not use AOS or infer free cells from absent republication. Add diagnostic camera projection only after the metric evidence -is accepted. Candidate A T2 replay remains blocked by the failed unmodified T1 -gate. No LOW-STEP tuning, new object model, camera resize, fisheye -rectification, manual dataset or parallel heavy Worker job is authorized by -this decision. +is accepted. If occupied evidence carpets the route, soft traversable +vegetation or usable gaps, reject TGS without tuning it against the review +anchors; otherwise advance it to a full recorded-source shadow. Candidate A T2 +replay remains blocked by the failed unmodified T1 gate. No LOW-STEP tuning, +new object model, camera resize, fisheye rectification, manual dataset or +parallel heavy Worker job is authorized by this decision. diff --git a/experiments/perception/M49_TGS_FAIL_CLOSED_EVIDENCE_2026-08-26.md b/experiments/perception/M49_TGS_FAIL_CLOSED_EVIDENCE_2026-08-26.md index 365e10c..ef9a5bf 100644 --- a/experiments/perception/M49_TGS_FAIL_CLOSED_EVIDENCE_2026-08-26.md +++ b/experiments/perception/M49_TGS_FAIL_CLOSED_EVIDENCE_2026-08-26.md @@ -1,7 +1,8 @@ # M4.9 gravity-aligned TGS fail-closed evidence — 2026-08-26 -Status: **evidence adapter completed**; visual obstacle/traversability quality, -full-source realtime, navigation and actuation remain disabled +Status: **evidence adapter and canonical LAB publication completed**; visual +obstacle/traversability quality, full-source realtime, navigation and actuation +remain disabled ## Decision @@ -13,7 +14,9 @@ accounts for every point inside TRAVEL's declared `1–80 m` processing range. This closes the representation and accounting gate. It does not accept visual quality. Several anchors still contain a high non-ground share, so the next gate is a 3D/costmap review of the sealed output rather than threshold tuning or -camera boxes. +camera boxes. The metric output is now available in the canonical LAB on port +`8000` as an operator-review surface; publication does not upgrade it to an +accepted terrain provider. ## Frozen identity @@ -125,15 +128,73 @@ Worker root: | `inputs/input-manifest.json` | `13,357` | `7a22e61606e1fa9ab61d47417f941b590a68b29e4529d4c290992ce7eb89a642` | | `tgs-timing.tsv` | `638` | `a8f439996205286335ac392b37f51b54056d04e9cb07e03648c52c1e8f2f6ae8` | +## Canonical LAB publication + +- LAB result: + `m49-tgs-fail-closed-9d5cb089bb5cc23f829acb47eda52eaa886db3b60f8fa7d57e02e27f642e837b`; +- linked current visual timeline: + `m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324`; +- source LiDAR pack SHA-256 remains + `0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944`; +- the earlier immutable seal beginning `200b57cc` is superseded only because it + linked the retired M4 v2 visual profile, whose timeline is incompatible with + the current v3 API. No Worker evidence byte or TGS result was changed. + +The LAB reuses the canonical recorded-realtime camera and spatial instrument. +It exposes the ten anchors, the full classified TGS point set, the four-state +costmap, `VIDEO/CAMERA`, `3D/PLAN`, independent point/costmap visibility and +exact per-anchor counts. Camera proposal boxes, AOS clusters, future frames and +navigation authority are absent. Browser acceptance confirmed that every +operator control is independent and that a loading anchor cannot temporarily +display unrelated M4 obstacle statistics. + +The published TGS coordinates are intentionally sensor-origin +`map-gravity-local`: the ground is therefore approximately `-1.25 m` below the +current LiDAR. The canonical LAB presentation converts these immutable values +to the scene's body-ground origin by adding the rig's sealed +`nominal_sensor_height_m = 1.25` after the gravity-to-body rotation. The same +offset is applied to point heights and costmap z-bounds; x/y evidence and the +sealed Worker result remain unchanged. A regression check across all ten +anchors puts the median displayed ground points in `-0.299…+0.135 m` and the +median ground-cell centres in `-0.308…+0.237 m`. On anchor `1856`, where the +presentation defect was reported, those medians are `+0.004 m` and `-0.063 m`. +The residual variation is measured terrain/pose variation, not the previous +systematic `1.25 m` lift. + +## Preliminary corridor triage + +The published evidence was also projected into the frozen virtual body +corridor (`-0.5…8.0 m` longitudinal, `±0.5 m` lateral) without changing the +sealed result. The table counts costmap cell centres inside that corridor: + +| Anchor | Current occupied | Rolling occupied | Rolling unobserved | Preliminary reading | +| ---: | ---: | ---: | ---: | --- | +| `171` | `0` | `8` | `6` | unresolved rolling band at `5.63…7.90 m` | +| `306` | `0` | `0` | `12` | no corridor obstruction | +| `368` | `1` | `1` | `16` | compact occupied support at `6.34 m` | +| `402` | `0` | `1` | `10` | compact occupied support at `1.89 m` | +| `450` | `0` | `0` | `8` | no corridor obstruction | +| `509` | `0` | `0` | `7` | no corridor obstruction | +| `525` | `0` | `0` | `9` | no corridor obstruction | +| `744` | `0` | `3` | `25` | weakly observed; requires explicit review | +| `1122` | `0` | `2` | `24` | rolling recovers compact obstacle support | +| `1856` | `1` | `1` | `13` | compact occupied support at `4.14 m` | + +This is a diagnostic, not ground truth. It shows why the causal window cannot +simply be removed: anchor `1122` is entirely unobserved in the current-only +corridor but gains ground and occupied evidence in rolling. It also identifies +the opposing risk: at anchor `171`, rolling adds eight occupied cells where the +camera/PLAN comparison appears to show an open route apart from side geometry. +Anchor `744` remains too weakly observed for acceptance. These two anchors keep +the visual gate open even though the remaining frames do not show a continuous +occupied carpet. + ## Next gate -Import the sealed `evidence.npz` into one laboratory review surface with: - -1. 3D points colored by the four evidence states; -2. top-down `0.45 m` costmap cells in the same local coordinate frame; -3. anchor selection and exact counts from `result.json`; -4. no AOS clusters, camera boxes, future frames or physical authority. - -The operator review must focus on the mandatory obstacle/gap cases and on the -large non-ground populations listed above. Camera projection follows only if -the metric evidence is accepted. +Review the published ten anchors in both metric `3D` and `PLAN`. The operator +must focus on the mandatory obstacle/gap cases and the large non-ground +populations listed above. Accept TGS for a full recorded-source shadow only if +red non-ground evidence remains on real geometry without carpeting the visible +route, soft traversable vegetation or usable gaps. Otherwise reject this +candidate without tuning thresholds against these ten anchors. Camera +projection follows only if the metric evidence is accepted. diff --git a/experiments/perception/seal_m49_tgs_fail_closed.py b/experiments/perception/seal_m49_tgs_fail_closed.py new file mode 100644 index 0000000..a93e7b9 --- /dev/null +++ b/experiments/perception/seal_m49_tgs_fail_closed.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Publish the verified Worker TGS pack as immutable Mission Core LAB evidence.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from k1link.laboratory.m49_tgs_fail_closed import ( + M49TgsFailClosedError, + seal_m49_tgs_fail_closed, +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--destination-root", type=Path, required=True) + parser.add_argument("--profile", type=Path, required=True) + parser.add_argument("--linked-visual-result-id", required=True) + arguments = parser.parse_args() + try: + result = seal_m49_tgs_fail_closed( + source_root=arguments.source_root, + destination_root=arguments.destination_root, + profile_path=arguments.profile, + linked_visual_result_id=arguments.linked_visual_result_id, + ) + except (M49TgsFailClosedError, OSError, ValueError) as exc: + parser.error(str(exc)) + print(result.result_id) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/k1link/laboratory/m49_tgs_fail_closed.py b/src/k1link/laboratory/m49_tgs_fail_closed.py new file mode 100644 index 0000000..f6b5e35 --- /dev/null +++ b/src/k1link/laboratory/m49_tgs_fail_closed.py @@ -0,0 +1,440 @@ +"""Seal and verify bounded gravity-aligned TRAVEL TGS evidence.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final + +M49_TGS_RESULT_SCHEMA: Final = "missioncore.m49-tgs-fail-closed-result/v1" +M49_TGS_REPORT_SCHEMA: Final = "missioncore.m49-tgs-fail-closed-report/v1" +M49_TGS_WORKER_RESULT_SCHEMA: Final = "missioncore.m49-tgs-fail-closed-evidence-result/v1" +M49_TGS_PREFIX: Final = "m49-tgs-fail-closed-" +M49_TGS_PROFILE_ID: Final = "m49-ravnoves00-tgs-fail-closed-evidence/v1" +M49_TGS_ANCHORS: Final = (171, 306, 368, 402, 450, 509, 525, 744, 1122, 1856) +M49_TGS_PROFILES: Final = ("current_increment", "causal_rolling_1s") +_MAX_JSON_BYTES: Final = 1024 * 1024 +_HASH_CHUNK_BYTES: Final = 1024 * 1024 + + +class M49TgsFailClosedError(RuntimeError): + """The TGS evidence pack is unavailable or failed its immutable contract.""" + + +@dataclass(frozen=True, slots=True) +class M49TgsFailClosedResult: + result_id: str + root: Path + manifest: dict[str, Any] + report: dict[str, Any] + + @property + def evidence_path(self) -> Path: + return self.root / "evidence.npz" + + +def seal_m49_tgs_fail_closed( + *, + source_root: Path, + destination_root: Path, + profile_path: Path, + linked_visual_result_id: str, + created_at_utc: str | None = None, +) -> M49TgsFailClosedResult: + source = _real_directory(source_root, "M49 Worker evidence") + destination = destination_root.expanduser().absolute() + destination.mkdir(parents=True, exist_ok=True) + if destination.is_symlink(): + raise M49TgsFailClosedError("M49 destination must not be a symlink") + profile = _json_file(profile_path, "M49 profile") + worker_result = _json_file(source / "result.json", "M49 Worker result") + worker_summary = _json_file(source / "worker-summary.json", "M49 Worker summary") + input_manifest = _json_file(source / "input-manifest.json", "M49 input manifest") + timing = _timing_metrics(source / "tgs-timing.tsv") + _validate_source(profile, worker_result, worker_summary, input_manifest, source) + if ( + not linked_visual_result_id.startswith("m4-threat-replay-") + or len(linked_visual_result_id) != len("m4-threat-replay-") + 64 + ): + raise M49TgsFailClosedError("M49 linked visual result is invalid") + + evidence_sha = _file_sha256(source / "evidence.npz") + identity = { + "schema_version": M49_TGS_RESULT_SCHEMA, + "source": { + "source_id": "RAVNOVES00", + "source_session_id": "20260720T065719Z_viewer_live", + "source_pack_sha256": worker_result["source_pack_sha256"], + "input_manifest_sha256": worker_result["input_manifest_sha256"], + "linked_visual_result_id": linked_visual_result_id, + "anchor_frame_indices": list(M49_TGS_ANCHORS), + }, + "configuration": { + "profile_id": M49_TGS_PROFILE_ID, + "config_sha256": worker_result["config_sha256"], + "coordinate_frame": "map-gravity-local", + "primary_profile": "causal_rolling_1s", + "cell_size_m": worker_result["costmap"]["cell_size_m"], + "radius_m": worker_result["costmap"]["radius_m"], + }, + "method": { + "execution_class": "deterministic", + "pipeline_id": "travel-tgs-gravity-aligned-fail-closed/v1", + "travel_revision": profile["source"]["travel_revision"], + "aos_used": False, + "missing_support_means_free": False, + "eligible_point_accounting": "exact-multiset-complement", + }, + "evidence": { + "sha256": evidence_sha, + "byte_length": (source / "evidence.npz").stat().st_size, + "state_codes": profile["state_codes"], + }, + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + "visual_quality_accepted": False, + }, + } + identity_sha256 = _canonical_sha256(identity) + result_id = f"{M49_TGS_PREFIX}{identity_sha256}" + target = destination / result_id + if target.exists(): + return read_m49_tgs_fail_closed(target) + + created = created_at_utc or datetime.now(tz=UTC).isoformat().replace("+00:00", "Z") + anchors = worker_result["anchors"] + primary = [row for row in anchors if row["profile_id"] == "causal_rolling_1s"] + report = { + "schema_version": M49_TGS_REPORT_SCHEMA, + "result_id": result_id, + "created_at_utc": created, + "source": identity["source"], + "configuration": { + **identity["configuration"], + "state_priority": profile["costmap"]["state_priority"], + "tgs": profile["tgs"], + }, + "method": { + **identity["method"], + "components": [ + { + "kind": "algorithm", + "name": "TRAVEL GroundSeg", + "version": profile["source"]["travel_revision"], + "role": "gravity-aligned ground/nonground separation", + }, + { + "kind": "algorithm", + "name": "fail-closed complement adapter", + "version": "v1", + "role": "retain rejected points and explicit unobserved cells", + }, + { + "kind": "runtime", + "name": "Worker 006 CPU qualification", + "version": worker_summary["code_revision"], + "role": "20 bounded TGS invocations without GPU", + }, + ], + }, + "execution": { + "worker": "Worker 006", + "device": "cpu", + "gpu_used": False, + "wrapper_elapsed_seconds": worker_summary["wall_seconds"], + "canonical_triton_id": worker_summary["canonical_triton_id"], + "canonical_triton_health": worker_summary["canonical_triton_health"], + "free_memory_gib_before": worker_summary["free_memory_gib_before"], + }, + "metrics": { + "anchor_count": len(M49_TGS_ANCHORS), + "anchor_profile_count": len(anchors), + "all_eligible_points_accounted": True, + "primary_profile": "causal_rolling_1s", + "primary": primary, + "costmap_cell_count": worker_result["costmap"]["cell_count"], + "process_wall_current_p50_ms": timing["current_increment"]["p50_ms"], + "process_wall_current_max_ms": timing["current_increment"]["max_ms"], + "process_wall_rolling_p50_ms": timing["causal_rolling_1s"]["p50_ms"], + "process_wall_rolling_max_ms": timing["causal_rolling_1s"]["max_ms"], + "process_max_rss_kib": timing["max_rss_kib"], + }, + "acceptance": { + "representation_complete": True, + "all_points_accounted": True, + "aos_absent": True, + "gpu_absent": True, + "visual_quality_accepted": False, + "traversability_accepted": False, + }, + "decision": { + "state": "visual-review-required", + "candidate_retained": True, + "next_action": ( + "Review exact gravity-aligned points and fail-closed costmap " + "on the ten immutable anchors." + ), + }, + "limitations": [ + "The ten anchors are bounded diagnostic evidence, not a full 4,489-frame replay.", + "No independent terrain or traversability truth is available.", + "No vehicle envelope exists, so occupied cells do not grant or deny physical passage.", + "Visual quality, realtime integration, navigation and actuation remain unaccepted.", + ], + "authority": { + "mode": "replay-simulated", + "commands_enabled": False, + "navigation_or_safety_accepted": False, + "visual_quality_accepted": False, + }, + "visual_review": { + "instrument": "m4-canonical-reference-graph", + "linked_visual_result_id": linked_visual_result_id, + "anchors": list(M49_TGS_ANCHORS), + "profiles": list(M49_TGS_PROFILES), + "default_profile": "causal_rolling_1s", + "point_states": profile["state_codes"], + }, + } + + with tempfile.TemporaryDirectory(prefix="mission-core-m49-tgs-", dir=destination) as raw: + staging = Path(raw) / result_id + staging.mkdir() + for name in ( + "evidence.npz", + "worker-summary.json", + "input-manifest.json", + "tgs-timing.tsv", + ): + shutil.copyfile(source / name, staging / name) + _write_json(staging / "report.json", report) + artifacts = [ + _artifact(staging / "report.json", "report", M49_TGS_REPORT_SCHEMA, "application/json"), + _artifact( + staging / "evidence.npz", "visual-spatial-evidence", None, "application/x-npz" + ), + _artifact(staging / "worker-summary.json", "runtime-summary", None, "application/json"), + _artifact(staging / "input-manifest.json", "source-manifest", None, "application/json"), + _artifact( + staging / "tgs-timing.tsv", "runtime-timing", None, "text/tab-separated-values" + ), + ] + manifest = { + "schema_version": M49_TGS_RESULT_SCHEMA, + "result_id": result_id, + "created_at_utc": created, + "identity_sha256": identity_sha256, + "identity": identity, + "artifacts": artifacts, + "authority": report["authority"], + "ground_truth": False, + } + _write_json(staging / "manifest.json", manifest) + os.replace(staging, target) + return read_m49_tgs_fail_closed(target) + + +def read_m49_tgs_fail_closed(root: Path) -> M49TgsFailClosedResult: + candidate = _real_directory(root, "M49 result") + if not candidate.name.startswith(M49_TGS_PREFIX): + raise M49TgsFailClosedError("M49 result identity is invalid") + manifest = _json_file(candidate / "manifest.json", "M49 manifest") + report = _json_file(candidate / "report.json", "M49 report") + if ( + manifest.get("schema_version") != M49_TGS_RESULT_SCHEMA + or manifest.get("result_id") != candidate.name + or report.get("schema_version") != M49_TGS_REPORT_SCHEMA + or report.get("result_id") != candidate.name + ): + raise M49TgsFailClosedError("M49 result contract changed") + identity = manifest.get("identity") + identity_sha = manifest.get("identity_sha256") + if ( + not isinstance(identity, dict) + or not isinstance(identity_sha, str) + or _canonical_sha256(identity) != identity_sha + or candidate.name != f"{M49_TGS_PREFIX}{identity_sha}" + ): + raise M49TgsFailClosedError("M49 identity proof changed") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or len(artifacts) != 5: + raise M49TgsFailClosedError("M49 artifact manifest changed") + for item in artifacts: + if not isinstance(item, dict): + raise M49TgsFailClosedError("M49 artifact descriptor changed") + path = candidate / str(item.get("path", "")) + if ( + path.is_symlink() + or not path.is_file() + or path.parent != candidate + or path.stat().st_size != item.get("byte_length") + or _file_sha256(path) != item.get("sha256") + ): + raise M49TgsFailClosedError("M49 artifact proof changed") + return M49TgsFailClosedResult(candidate.name, candidate, manifest, report) + + +def _validate_source( + profile: dict[str, Any], + worker_result: dict[str, Any], + worker_summary: dict[str, Any], + input_manifest: dict[str, Any], + source: Path, +) -> None: + if ( + profile.get("schema_version") != "missioncore.m49-tgs-fail-closed-evidence-profile/v1" + or profile.get("profile_id") != M49_TGS_PROFILE_ID + or tuple(profile.get("anchors", ())) != M49_TGS_ANCHORS + or profile.get("invariants", {}).get("aos_allowed") is not False + or profile.get("invariants", {}).get("missing_support_means_free") is not False + ): + raise M49TgsFailClosedError("M49 profile changed") + if ( + worker_result.get("schema_version") != M49_TGS_WORKER_RESULT_SCHEMA + or worker_result.get("status") != "passed" + or worker_result.get("summary", {}).get("aos_used") is not False + or worker_result.get("summary", {}).get("all_eligible_points_accounted") is not True + or worker_result.get("summary", {}).get("primary_profile") != "causal_rolling_1s" + or len(worker_result.get("anchors", ())) != 20 + ): + raise M49TgsFailClosedError("M49 Worker result changed") + if ( + input_manifest.get("schema_version") != "missioncore.m49-tgs-fail-closed-input/v1" + or input_manifest.get("coordinate_frame") != "map-gravity-local" + or len(input_manifest.get("records", ())) != 20 + ): + raise M49TgsFailClosedError("M49 input manifest changed") + if ( + worker_summary.get("gpu_requested") is not False + and worker_summary.get("gpu_requested") is not None + ): + raise M49TgsFailClosedError("M49 Worker GPU contract changed") + if ( + worker_summary.get("aos_used") is not False + or worker_summary.get("all_eligible_points_accounted") is not True + or worker_summary.get("canonical_triton_health") != "healthy" + ): + raise M49TgsFailClosedError("M49 Worker cleanup changed") + evidence = worker_result.get("evidence", {}) + if ( + evidence.get("path") != "evidence.npz" + or evidence.get("bytes") != (source / "evidence.npz").stat().st_size + or evidence.get("sha256") != _file_sha256(source / "evidence.npz") + or worker_result.get("input_manifest_sha256") + != _file_sha256(source / "input-manifest.json") + ): + raise M49TgsFailClosedError("M49 evidence proof changed") + + +def _artifact( + path: Path, + role: str, + schema_version: str | None, + media_type: str, +) -> dict[str, object]: + result: dict[str, object] = { + "role": role, + "path": path.name, + "byte_length": path.stat().st_size, + "sha256": _file_sha256(path), + "media_type": media_type, + } + if schema_version is not None: + result["schema_version"] = schema_version + return result + + +def _timing_metrics(path: Path) -> dict[str, Any]: + if path.is_symlink() or not path.is_file(): + raise M49TgsFailClosedError("M49 timing evidence is unavailable") + profiles: dict[str, list[float]] = {name: [] for name in M49_TGS_PROFILES} + maximum_rss = 0 + lines = path.read_text(encoding="utf-8-sig").splitlines() + if not lines or lines[0] != "profile\tslot\twall_seconds\tmax_rss_kib": + raise M49TgsFailClosedError("M49 timing evidence changed") + for line in lines[1:]: + fields = line.split("\t") + if len(fields) != 4 or fields[0] not in profiles: + raise M49TgsFailClosedError("M49 timing row changed") + profiles[fields[0]].append(float(fields[2])) + maximum_rss = max(maximum_rss, int(fields[3])) + if any(len(values) != len(M49_TGS_ANCHORS) for values in profiles.values()): + raise M49TgsFailClosedError("M49 timing coverage changed") + result: dict[str, Any] = {"max_rss_kib": maximum_rss} + for name, values in profiles.items(): + ordered = sorted(values) + result[name] = { + "p50_ms": ordered[len(ordered) // 2] * 1000, + "max_ms": max(ordered) * 1000, + } + return result + + +def _real_directory(path: Path, label: str) -> Path: + candidate = path.expanduser().absolute() + if candidate.is_symlink(): + raise M49TgsFailClosedError(f"{label} must not be a symlink") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise M49TgsFailClosedError(f"{label} is unavailable") from exc + if not resolved.is_dir(): + raise M49TgsFailClosedError(f"{label} is unavailable") + return resolved + + +def _json_file(path: Path, label: str) -> dict[str, Any]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > _MAX_JSON_BYTES: + raise M49TgsFailClosedError(f"{label} is unavailable") + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (json.JSONDecodeError, OSError) as exc: + raise M49TgsFailClosedError(f"{label} is invalid") from exc + if not isinstance(value, dict): + raise M49TgsFailClosedError(f"{label} is invalid") + return value + + +def _write_json(path: Path, value: object) -> None: + path.write_bytes(_canonical_json(value) + b"\n") + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _canonical_sha256(value: object) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(_HASH_CHUNK_BYTES), b""): + digest.update(chunk) + return digest.hexdigest() + + +__all__ = [ + "M49_TGS_ANCHORS", + "M49_TGS_PREFIX", + "M49_TGS_REPORT_SCHEMA", + "M49_TGS_RESULT_SCHEMA", + "M49TgsFailClosedError", + "M49TgsFailClosedResult", + "read_m49_tgs_fail_closed", + "seal_m49_tgs_fail_closed", +] diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 2b73b9f..aa76ebb 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -133,6 +133,7 @@ from k1link.web.m48s_fixed_class_detector_lab_api import ( build_m48s_fixed_class_detector_lab_router, ) from k1link.web.m48t_risk_quality_lab_api import build_m48t_risk_quality_lab_router +from k1link.web.m49_tgs_fail_closed_api import build_m49_tgs_fail_closed_router from k1link.web.map_api import ( MapGatewayConfiguration, MapGatewayProxy, @@ -992,6 +993,17 @@ app.include_router( ), ) ) +app.include_router( + build_m49_tgs_fail_closed_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "m49" + / "tgs-fail-closed-results" + ), + ) +) app.include_router( build_m48s_fixed_class_detector_lab_router( root_provider=lambda: ( diff --git a/src/k1link/web/m49_tgs_fail_closed_api.py b/src/k1link/web/m49_tgs_fail_closed_api.py new file mode 100644 index 0000000..6cdf906 --- /dev/null +++ b/src/k1link/web/m49_tgs_fail_closed_api.py @@ -0,0 +1,284 @@ +"""Read-only API for sealed gravity-aligned M49 TGS evidence.""" + +from __future__ import annotations + +import copy +import json +import math +import re +from collections.abc import Callable +from functools import lru_cache +from pathlib import Path +from typing import Final + +import numpy as np +from fastapi import APIRouter, HTTPException, Query, Response + +from k1link.laboratory.m49_tgs_fail_closed import ( + M49_TGS_ANCHORS, + M49_TGS_PREFIX, + M49TgsFailClosedError, + M49TgsFailClosedResult, + read_m49_tgs_fail_closed, +) + +RootProvider = Callable[[], Path | None] + +RESULT_ID: Final = re.compile(rf"^{re.escape(M49_TGS_PREFIX)}[a-f0-9]{{64}}$") +RESULT_VIEW_SCHEMA: Final = "missioncore.m49-tgs-fail-closed-view/v1" +RESULT_CATALOG_SCHEMA: Final = "missioncore.m49-tgs-fail-closed-catalog/v1" +ANCHOR_CATALOG_SCHEMA: Final = "missioncore.m49-tgs-anchor-catalog/v1" +ANCHOR_SPATIAL_SCHEMA: Final = "missioncore.m49-tgs-anchor-spatial/v1" +ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/tgs-fail-closed" +PROFILES: Final = ("current_increment", "causal_rolling_1s") + + +def build_m49_tgs_fail_closed_router(*, root_provider: RootProvider = lambda: None) -> APIRouter: + router = APIRouter(prefix=ENDPOINT_ROOT, tags=["laboratory"]) + + def result(result_id: str) -> M49TgsFailClosedResult: + candidate = _resolve_candidate(root_provider, result_id) + try: + return _read_result_cached(str(candidate), _signature(candidate)) + except (M49TgsFailClosedError, OSError, ValueError): + raise HTTPException(status_code=404, detail="M49 TGS result not found") from None + + @router.get("/results") + def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]: + root = _configured_root(root_provider) + if root is None: + return _empty_catalog(configured=False) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in sorted(root.iterdir()): + if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None: + continue + try: + sealed = _read_result_cached(str(candidate.resolve()), _signature(candidate)) + items.append(_project_result(sealed)) + except (M49TgsFailClosedError, OSError, ValueError): + invalid_total += 1 + items.sort( + key=lambda item: (str(item["created_at_utc"]), str(item["result_id"])), + reverse=True, + ) + return { + "schema_version": RESULT_CATALOG_SCHEMA, + "configured": True, + "items": items[:limit], + "candidate_total": len(items) + invalid_total, + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/{result_id}") + def get_result(result_id: str) -> dict[str, object]: + return _project_result(result(result_id)) + + @router.get("/{result_id}/anchors") + def get_anchors(result_id: str) -> dict[str, object]: + sealed = result(result_id) + return { + "schema_version": ANCHOR_CATALOG_SCHEMA, + "result_id": result_id, + "linked_visual_result_id": sealed.report["visual_review"]["linked_visual_result_id"], + "anchors": copy.deepcopy(sealed.report["metrics"]["primary"]), + "anchor_count": len(M49_TGS_ANCHORS), + "profiles": list(PROFILES), + "default_profile": "causal_rolling_1s", + "access": "read-only", + } + + @router.get("/{result_id}/anchors/{anchor_frame_index}/spatial") + def get_anchor_spatial( + result_id: str, + anchor_frame_index: int, + profile: str = Query(default="causal_rolling_1s"), + ) -> Response: + if profile not in PROFILES or anchor_frame_index not in M49_TGS_ANCHORS: + raise HTTPException(status_code=404, detail="M49 TGS anchor not found") + sealed = result(result_id) + try: + content = _anchor_json_cached( + str(sealed.evidence_path), + result_id, + anchor_frame_index, + profile, + _evidence_signature(sealed.evidence_path), + ) + except (KeyError, OSError, ValueError): + raise HTTPException( + status_code=503, detail="M49 TGS spatial evidence failed verification" + ) from None + return Response( + content=content, + media_type="application/json", + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }, + ) + + return router + + +@lru_cache(maxsize=4) +def _read_result_cached(result_root: str, signature: tuple[int, ...]) -> M49TgsFailClosedResult: + del signature + return read_m49_tgs_fail_closed(Path(result_root)) + + +@lru_cache(maxsize=24) +def _anchor_json_cached( + evidence_path: str, + result_id: str, + anchor_frame_index: int, + profile: str, + signature: tuple[int, int], +) -> bytes: + del signature + slot = M49_TGS_ANCHORS.index(anchor_frame_index) + with np.load(evidence_path, allow_pickle=False) as evidence: + offsets = evidence[f"{profile}_point_offsets"] + start = int(offsets[slot]) + end = int(offsets[slot + 1]) + points = evidence[f"{profile}_points_xyz_m"][start:end] + point_states = evidence[f"{profile}_point_states"][start:end] + centers = evidence["costmap_cell_centers_xy_m"] + cell_states = evidence[f"{profile}_costmap_states"][slot] + z_bounds = evidence[f"{profile}_costmap_z_bounds_m"][slot] + if ( + points.shape[1:] != (3,) + or point_states.shape != (points.shape[0],) + or centers.shape != (2244, 2) + or cell_states.shape != (2244,) + or z_bounds.shape != (2244, 2) + or not np.isfinite(points).all() + or not np.isfinite(centers).all() + or not np.isin(point_states, np.asarray([1, 2, 3], dtype=np.uint8)).all() + or not np.isin(cell_states, np.asarray([0, 1, 2, 3], dtype=np.uint8)).all() + ): + raise ValueError("M49 TGS spatial shape changed") + payload = { + "schema_version": ANCHOR_SPATIAL_SCHEMA, + "result_id": result_id, + "anchor_frame_index": anchor_frame_index, + "source_sequence": anchor_frame_index, + "profile": profile, + "coordinate_frame": "map-gravity-local", + "points_xyz_m": points.astype(float).tolist(), + "point_states": point_states.astype(int).tolist(), + "costmap": { + "cell_size_m": 0.45, + "radius_m": 12.0, + "centers_xy_m": centers.astype(float).tolist(), + "states": cell_states.astype(int).tolist(), + "z_bounds_m": [ + [ + float(row[0]) if math.isfinite(float(row[0])) else None, + float(row[1]) if math.isfinite(float(row[1])) else None, + ] + for row in z_bounds + ], + }, + "state_codes": { + "UNOBSERVED": 0, + "GROUND_SUPPORT": 1, + "NONGROUND_OCCUPIED": 2, + "UNKNOWN_REJECTED": 3, + }, + "all_points_accounted": True, + "aos_used": False, + "authority": { + "visual_quality_accepted": False, + "navigation_or_safety_accepted": False, + }, + "access": "read-only", + } + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _project_result(result: M49TgsFailClosedResult) -> dict[str, object]: + report = result.report + return { + "schema_version": RESULT_VIEW_SCHEMA, + "result_id": result.result_id, + "created_at_utc": result.manifest["created_at_utc"], + "source": copy.deepcopy(report["source"]), + "configuration": copy.deepcopy(report["configuration"]), + "method": copy.deepcopy(report["method"]), + "execution": copy.deepcopy(report["execution"]), + "metrics": copy.deepcopy(report["metrics"]), + "acceptance": copy.deepcopy(report["acceptance"]), + "decision": copy.deepcopy(report["decision"]), + "limitations": copy.deepcopy(report["limitations"]), + "authority": copy.deepcopy(report["authority"]), + "visual_review": copy.deepcopy(report["visual_review"]), + "ground_truth": False, + "access": "read-only", + } + + +def _resolve_candidate(provider: RootProvider, result_id: str) -> Path: + if RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="M49 TGS result not found") + root = _configured_root(provider) + if root is None: + raise HTTPException(status_code=404, detail="M49 TGS result not found") + candidate = root / result_id + if candidate.is_symlink() or not candidate.is_dir(): + raise HTTPException(status_code=404, detail="M49 TGS result not found") + resolved = candidate.resolve(strict=True) + if resolved.parent != root: + raise HTTPException(status_code=404, detail="M49 TGS result not found") + return resolved + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None or value.is_symlink() or not value.is_dir(): + return None + return value.resolve(strict=True) + + +def _signature(candidate: Path) -> tuple[int, ...]: + result: list[int] = [] + for name in ( + "manifest.json", + "report.json", + "evidence.npz", + "worker-summary.json", + "input-manifest.json", + "tgs-timing.tsv", + ): + path = candidate / name + if path.is_symlink() or not path.is_file(): + raise ValueError("M49 TGS artifact unavailable") + stat = path.stat() + result.extend((stat.st_size, stat.st_mtime_ns)) + return tuple(result) + + +def _evidence_signature(path: Path) -> tuple[int, int]: + stat = path.stat() + return stat.st_size, stat.st_mtime_ns + + +def _empty_catalog(*, configured: bool) -> dict[str, object]: + return { + "schema_version": RESULT_CATALOG_SCHEMA, + "configured": configured, + "items": [], + "candidate_total": 0, + "invalid_total": 0, + "access": "read-only", + } + + +__all__ = ["build_m49_tgs_fail_closed_router"] diff --git a/tests/test_laboratory_evidence_registry.py b/tests/test_laboratory_evidence_registry.py index d836972..de34304 100644 --- a/tests/test_laboratory_evidence_registry.py +++ b/tests/test_laboratory_evidence_registry.py @@ -127,7 +127,7 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None: repository_root / "config" / "laboratories" ) - assert len(registry.definitions) == 39 + assert len(registry.definitions) == 41 assert {item.work_id for item in registry.definitions} >= { "e31-source-binding", "e46j-raw-fisheye-realtime", @@ -144,6 +144,7 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None: "m48-static-occupancy-qualification", "m48s-fixed-class-detector", "m48t-risk-quality-temporal", + "m49-tgs-fail-closed-evidence", } m48 = next( item for item in registry.definitions if item.work_id == "m48-object-centric-quality" diff --git a/tests/test_laboratory_execution.py b/tests/test_laboratory_execution.py index 0708858..6a7751d 100644 --- a/tests/test_laboratory_execution.py +++ b/tests/test_laboratory_execution.py @@ -101,6 +101,7 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: "e47-semantic-slam-shadow", "m48s-fixed-class-detector", "m48t-risk-quality-temporal", + "m49-tgs-fail-closed-evidence", } by_work_id = {row.work_id: row for row in execution.definitions} assert by_work_id["m48-small-static-passage-regression"].evidence_contract == ( @@ -118,6 +119,8 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: assert by_work_id["m48s-fixed-class-detector"].isolation == "bounded-adapter" assert by_work_id["m48t-risk-quality-temporal"].lifecycle == "experimental" assert by_work_id["m48t-risk-quality-temporal"].isolation == "bounded-adapter" + assert by_work_id["m49-tgs-fail-closed-evidence"].lifecycle == "experimental" + assert by_work_id["m49-tgs-fail-closed-evidence"].isolation == "bounded-adapter" assert all( row.lifecycle == "canonical" for row in execution.definitions @@ -126,6 +129,7 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: "e47-semantic-slam-shadow", "m48s-fixed-class-detector", "m48t-risk-quality-temporal", + "m49-tgs-fail-closed-evidence", } ) assert len(execution.definitions) + len(execution.legacy_work_ids) == len( diff --git a/tests/test_laboratory_value_review_registry.py b/tests/test_laboratory_value_review_registry.py index a87ab5b..292ffb4 100644 --- a/tests/test_laboratory_value_review_registry.py +++ b/tests/test_laboratory_value_review_registry.py @@ -80,7 +80,7 @@ def test_product_value_review_registry_covers_reviewed_laboratory_families() -> root / "config" / "laboratory-value-review.json" ) - assert len(registry.entries) == 38 + assert len(registry.entries) == 39 assert {entry.catalog_id for entry in registry.entries} >= { "e28-local-surface", "e46d-temporal-failure-audit", @@ -91,4 +91,5 @@ def test_product_value_review_registry_covers_reviewed_laboratory_families() -> "m48-static-occupancy-qualification", "m48s-fixed-class-detector", "m48t-risk-quality-temporal", + "m49-tgs-fail-closed-evidence", } diff --git a/tests/test_m49_tgs_fail_closed_api.py b/tests/test_m49_tgs_fail_closed_api.py new file mode 100644 index 0000000..ba212d5 --- /dev/null +++ b/tests/test_m49_tgs_fail_closed_api.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.laboratory.m49_tgs_fail_closed import ( + M49_TGS_ANCHORS, + read_m49_tgs_fail_closed, + seal_m49_tgs_fail_closed, +) +from k1link.web.m49_tgs_fail_closed_api import build_m49_tgs_fail_closed_router + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def _sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _write_json(path: Path, value: object) -> None: + path.write_text(json.dumps(value, sort_keys=True), encoding="utf-8") + + +def _source(root: Path) -> Path: + root.mkdir() + centers = np.zeros((2244, 2), dtype=np.float32) + arrays: dict[str, np.ndarray] = {"costmap_cell_centers_xy_m": centers} + summaries: list[dict[str, object]] = [] + for profile in ("current_increment", "causal_rolling_1s"): + arrays[f"{profile}_points_xyz_m"] = np.asarray( + [[float(index), 0.0, 0.1] for index in range(10)], dtype=np.float32 + ) + arrays[f"{profile}_point_states"] = np.asarray([1, 2] * 5, dtype=np.uint8) + arrays[f"{profile}_point_offsets"] = np.arange(11, dtype=np.int64) + arrays[f"{profile}_costmap_states"] = np.zeros((10, 2244), dtype=np.uint8) + arrays[f"{profile}_costmap_ground_point_counts"] = np.zeros((10, 2244), dtype=np.int32) + arrays[f"{profile}_costmap_nonground_point_counts"] = np.zeros((10, 2244), dtype=np.int32) + arrays[f"{profile}_costmap_rejected_point_counts"] = np.zeros((10, 2244), dtype=np.int32) + arrays[f"{profile}_costmap_z_bounds_m"] = np.full((10, 2244, 2), np.nan, dtype=np.float32) + for slot, anchor in enumerate(M49_TGS_ANCHORS): + summaries.append( + { + "profile_id": profile, + "slot": slot, + "anchor_frame_index": anchor, + "point_count": 1, + "ground_point_count": 1 if slot % 2 == 0 else 0, + "nonground_point_count": 0 if slot % 2 == 0 else 1, + "rejected_point_count": 0, + "ground_cell_count": 0, + "nonground_cell_count": 0, + "rejected_cell_count": 0, + "unobserved_cell_count": 2244, + "all_points_accounted": True, + } + ) + np.savez_compressed(root / "evidence.npz", **arrays) + records = [ + { + "profile_id": profile, + "slot": slot, + "anchor_frame_index": anchor, + } + for profile in ("current_increment", "causal_rolling_1s") + for slot, anchor in enumerate(M49_TGS_ANCHORS) + ] + _write_json( + root / "input-manifest.json", + { + "schema_version": "missioncore.m49-tgs-fail-closed-input/v1", + "coordinate_frame": "map-gravity-local", + "records": records, + }, + ) + _write_json( + root / "worker-summary.json", + { + "schema_version": "missioncore.m49-tgs-worker-summary/v1", + "code_revision": "a" * 40, + "wall_seconds": 1.5, + "free_memory_gib_before": 42.0, + "canonical_triton_id": "b" * 64, + "canonical_triton_health": "healthy", + "all_eligible_points_accounted": True, + "aos_used": False, + }, + ) + timing = ["profile\tslot\twall_seconds\tmax_rss_kib"] + timing.extend( + f"{profile}\t{slot}\t0.02\t7000" + for profile in ("current_increment", "causal_rolling_1s") + for slot in range(10) + ) + (root / "tgs-timing.tsv").write_text("\n".join(timing) + "\n", encoding="utf-8") + _write_json( + root / "result.json", + { + "schema_version": "missioncore.m49-tgs-fail-closed-evidence-result/v1", + "status": "passed", + "config_sha256": "c" * 64, + "source_pack_sha256": "d" * 64, + "input_manifest_sha256": _sha(root / "input-manifest.json"), + "evidence": { + "path": "evidence.npz", + "bytes": (root / "evidence.npz").stat().st_size, + "sha256": _sha(root / "evidence.npz"), + }, + "costmap": { + "coordinate_frame": "map-gravity-local", + "cell_size_m": 0.45, + "radius_m": 12.0, + "cell_count": 2244, + }, + "summary": { + "aos_used": False, + "all_eligible_points_accounted": True, + "primary_profile": "causal_rolling_1s", + }, + "anchors": summaries, + }, + ) + return root + + +def test_m49_tgs_seal_and_api_are_immutable_and_fail_closed(tmp_path: Path) -> None: + source = _source(tmp_path / "source") + destination = tmp_path / "results" + linked = "m4-threat-replay-" + "e" * 64 + sealed = seal_m49_tgs_fail_closed( + source_root=source, + destination_root=destination, + profile_path=( + REPOSITORY_ROOT / "config" / "perception" / "m49-tgs-fail-closed-evidence-v1.json" + ), + linked_visual_result_id=linked, + created_at_utc="2026-08-26T17:30:00Z", + ) + assert read_m49_tgs_fail_closed(sealed.root).result_id == sealed.result_id + + app = FastAPI() + app.include_router(build_m49_tgs_fail_closed_router(root_provider=lambda: destination)) + client = TestClient(app) + catalog = client.get("/api/v1/laboratory/m49/tgs-fail-closed/results") + assert catalog.status_code == 200 + assert catalog.json()["items"][0]["result_id"] == sealed.result_id + anchors = client.get(f"/api/v1/laboratory/m49/tgs-fail-closed/{sealed.result_id}/anchors") + assert anchors.status_code == 200 + assert anchors.json()["anchor_count"] == 10 + spatial = client.get( + f"/api/v1/laboratory/m49/tgs-fail-closed/{sealed.result_id}/anchors/171/spatial" + ) + assert spatial.status_code == 200 + assert spatial.json()["point_states"] == [1] + assert spatial.json()["costmap"]["states"] == [0] * 2244 + assert spatial.json()["authority"]["navigation_or_safety_accepted"] is False + + (sealed.root / "worker-summary.json").write_text("{}", encoding="utf-8") + assert ( + client.get(f"/api/v1/laboratory/m49/tgs-fail-closed/{sealed.result_id}").status_code == 404 + )