From d722b0f82b7b3b922b8bf5c2de460d2d6df1cda0 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 26 Aug 2026 23:07:06 +0300 Subject: [PATCH] feat(lab): publish full TGS shadow evidence --- .../src/core/laboratory/advancedIndex.ts | 9 + .../laboratory/advancedLaboratoryResults.ts | 2 + .../src/core/laboratory/advancedResults.ts | 2 +- .../src/core/laboratory/m49TgsFullShadow.ts | 327 ++++++++++++++++++ .../laboratory/AdvancedLaboratoryResult.tsx | 4 + .../laboratory/M49TgsFullShadowEvidence.tsx | 164 +++++++++ .../laboratory/M49TgsFullShadowResult.tsx | 90 +++++ .../laboratory/M4ReplayThreatVisual.tsx | 71 ++-- .../laboratory/laboratoryArchiveProfiles.ts | 7 + .../useAdvancedLaboratoryCatalog.ts | 2 + .../test/m49TgsFullShadow.test.mjs | 85 +++++ .../test/m4ReplayThreat.test.mjs | 5 +- config/laboratories/m49-tgs-full-shadow.json | 10 + config/laboratory-execution.json | 14 + config/laboratory-value-review.json | 7 + src/k1link/laboratory/m49_tgs_full_shadow.py | 295 ++++++++++++++++ src/k1link/web/app.py | 12 + src/k1link/web/m49_tgs_full_shadow_api.py | 314 +++++++++++++++++ tests/test_laboratory_evidence_registry.py | 3 +- tests/test_laboratory_execution.py | 4 + .../test_laboratory_value_review_registry.py | 3 +- tests/test_m49_tgs_full_shadow.py | 106 ++++++ 22 files changed, 1508 insertions(+), 28 deletions(-) create mode 100644 apps/control-station/src/core/laboratory/m49TgsFullShadow.ts create mode 100644 apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/M49TgsFullShadowResult.tsx create mode 100644 apps/control-station/test/m49TgsFullShadow.test.mjs create mode 100644 config/laboratories/m49-tgs-full-shadow.json create mode 100644 src/k1link/laboratory/m49_tgs_full_shadow.py create mode 100644 src/k1link/web/m49_tgs_full_shadow_api.py create mode 100644 tests/test_m49_tgs_full_shadow.py diff --git a/apps/control-station/src/core/laboratory/advancedIndex.ts b/apps/control-station/src/core/laboratory/advancedIndex.ts index 7881307..9221b1e 100644 --- a/apps/control-station/src/core/laboratory/advancedIndex.ts +++ b/apps/control-station/src/core/laboratory/advancedIndex.ts @@ -47,6 +47,7 @@ import { fetchM48R3StaticOccupancyShadow } from "./m48r3StaticOccupancyShadow"; import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector"; import { fetchM48TRiskQualityResult } from "./m48tRiskQuality"; import { fetchM49TgsFailClosedResult } from "./m49TgsFailClosed"; +import { fetchM49TgsFullShadowResult } from "./m49TgsFullShadow"; export type AdvancedLaboratoryWorkId = | "m48-object-centric-quality" @@ -56,6 +57,7 @@ export type AdvancedLaboratoryWorkId = | "m48s-fixed-class-detector" | "m48t-risk-quality-temporal" | "m49-tgs-fail-closed-evidence" + | "m49-tgs-full-shadow" | "m47-reference-graph-shadow" | "m4-replay-threat" | "l3-pointpillars-visual-audit" @@ -105,6 +107,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ "m48s-fixed-class-detector", "m48t-risk-quality-temporal", "m49-tgs-fail-closed-evidence", + "m49-tgs-full-shadow", "m47-reference-graph-shadow", "m4-replay-threat", "l3-pointpillars-visual-audit", @@ -149,6 +152,7 @@ const RESULT_PREFIX: Readonly> = { "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", + "m49-tgs-full-shadow": "m49-tgs-full-shadow", "m47-reference-graph-shadow": "m47-reference-graph-lab", "m4-replay-threat": "m4-threat-replay", "l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit", @@ -201,6 +205,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { m48s: null, m48t: null, m49Tgs: null, + m49TgsFull: null, m4Threat: null, l3: null, l31: null, @@ -332,6 +337,7 @@ export function advancedLaboratoryResultAvailable( : 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 === "m49-tgs-full-shadow" ? results.m49TgsFull !== null : workId === "m47-reference-graph-shadow" ? results.m47Graph !== null : workId === "m4-replay-threat" ? results.m4Threat !== null : workId === "l3-pointpillars-visual-audit" ? results.l3 !== null @@ -402,6 +408,9 @@ export async function fetchAdvancedLaboratoryResult( } 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 === "m49-tgs-full-shadow") { + if (!resultId) throw new AdvancedLaboratoryContractError("M4.9 full TGS shadow identity не выбрана."); + results.m49TgsFull = await fetchM49TgsFullShadowResult(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 71857cf..ed6bed1 100644 --- a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -41,6 +41,7 @@ import type { M48R3StaticOccupancyShadowResult } from "./m48r3StaticOccupancySha import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector"; import type { M48TRiskQualityResult } from "./m48tRiskQuality"; import type { M49TgsFailClosedResult } from "./m49TgsFailClosed"; +import type { M49TgsFullShadowResult } from "./m49TgsFullShadow"; export interface AdvancedLaboratoryResults { m47Graph: M47ReferenceGraphLabResult | null; @@ -51,6 +52,7 @@ export interface AdvancedLaboratoryResults { m48s: M48SFixedClassDetectorResult | null; m48t: M48TRiskQualityResult | null; m49Tgs: M49TgsFailClosedResult | null; + m49TgsFull: M49TgsFullShadowResult | 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 6684ba9..42ac6b6 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, m49Tgs: null, m4Threat: null, + m48s: null, m48t: null, m49Tgs: null, m49TgsFull: null, m4Threat: null, l3: null, l31: null, l32: null, l33: null, e31, e32, diff --git a/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts b/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts new file mode 100644 index 0000000..0853824 --- /dev/null +++ b/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts @@ -0,0 +1,327 @@ +import type { LaboratoryFetch } from "./advancedResults"; + +const RESULT_ID = /^m49-tgs-full-shadow-[a-f0-9]{64}$/; + +export type M49TgsFullShadowStateCode = 0 | 1 | 2 | 3; + +export interface M49TgsFullShadowResult { + resultId: string; + createdAtUtc: string; + source: { + sourcePackSha256: string; + linkedVisualResultId: string; + }; + configuration: { + configSha256: string; + cellSizeM: number; + radiusM: number; + historySeconds: number; + }; + execution: { + wrapperElapsedSeconds: number; + }; + timeline: { + frameCount: 4489; + availableLidarFrameCount: 3928; + missingLidarFrameCount: 561; + durationSeconds: number; + effectiveFps: number; + }; + pointAccounting: { + eligible: number; + ground: number; + nonground: number; + rejected: number; + unaccounted: 0; + }; + performance: { + candidateTgsMs: { p50: number; p95: number; p99: number; max: number }; + completionAgeMs: { p50: number; p95: number; p99: number; max: number }; + capacityDropCount: number; + }; + acceptance: { + representationComplete: true; + visualQualityAccepted: false; + integratedGraphPerformanceAccepted: false; + allFramesAccounted: boolean; + allEligiblePointsAccounted: boolean; + }; + decision: { + state: string; + candidateRetained: boolean; + nextAction: string; + }; +} + +export interface M49TgsFullShadowSpatial { + resultId: string; + sourceSequence: number; + sourceFrameIndex: number; + sessionSeconds: number; + sampleAvailable: boolean; + costmap: { + cellSizeM: number; + radiusM: number; + centersXyM: readonly (readonly [number, number])[]; + states: readonly M49TgsFullShadowStateCode[]; + zBoundsM: readonly (readonly [number | null, number | null])[]; + }; + metrics: { + eligiblePointCount: number; + groundPointCount: number; + nongroundPointCount: number; + rejectedPointCount: number; + occupiedCellCount: number; + }; +} + +export interface M49TgsFullShadowSpatialChunk { + resultId: string; + start: number; + count: number; + frames: readonly M49TgsFullShadowSpatial[]; +} + +export class M49TgsFullShadowContractError extends Error {} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new M49TgsFullShadowContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new M49TgsFullShadowContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new M49TgsFullShadowContractError(`${label}: ожидалось число.`); + } + return value; +} + +function integer(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new M49TgsFullShadowContractError(`${label}: ожидалось целое число.`); + } + return parsed; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new M49TgsFullShadowContractError(`${label}: ожидался boolean.`); + } + return value; +} + +function exact(value: unknown, expected: T, label: string): T { + if (value !== expected) throw new M49TgsFullShadowContractError(`${label}: нарушен контракт.`); + return expected; +} + +function timing(value: unknown, label: string) { + const row = objectValue(value, label); + return { + p50: numberValue(row.p50, `${label}.p50`), + p95: numberValue(row.p95, `${label}.p95`), + p99: numberValue(row.p99, `${label}.p99`), + max: numberValue(row.max, `${label}.max`), + }; +} + +export async function fetchM49TgsFullShadowResult( + id: string, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(id)) throw new M49TgsFullShadowContractError("M49 full-shadow identity недопустима."); + const response = await fetcher( + `/api/v1/laboratory/m49/tgs-full-shadow/${encodeURIComponent(id)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) throw new M49TgsFullShadowContractError(`M49 full shadow недоступен: HTTP ${response.status}.`); + const payload = objectValue(await response.json(), "M49 full shadow"); + exact(payload.schema_version, "missioncore.m49-tgs-full-shadow-view/v1", "M49 full schema"); + exact(payload.result_id, id, "M49 full result"); + exact(payload.ground_truth, false, "M49 full ground truth"); + exact(payload.access, "read-only", "M49 full access"); + const source = objectValue(payload.source, "M49 full source"); + const configuration = objectValue(payload.configuration, "M49 full configuration"); + const execution = objectValue(payload.execution, "M49 full execution"); + const timeline = objectValue(payload.timeline, "M49 full timeline"); + const accounting = objectValue(payload.point_accounting, "M49 full accounting"); + const performance = objectValue(payload.performance, "M49 full performance"); + const acceptance = objectValue(payload.acceptance, "M49 full acceptance"); + const decision = objectValue(payload.decision, "M49 full decision"); + return { + resultId: text(payload.result_id, "M49 full result"), + createdAtUtc: text(payload.created_at_utc, "M49 full created"), + source: { + sourcePackSha256: text(source.source_pack_sha256, "M49 full source pack"), + linkedVisualResultId: text(source.linked_visual_result_id, "M49 full visual result"), + }, + configuration: { + configSha256: text(configuration.config_sha256, "M49 full config"), + cellSizeM: numberValue(configuration.cell_size_m, "M49 full cell size"), + radiusM: numberValue(configuration.radius_m, "M49 full radius"), + historySeconds: numberValue(configuration.history_seconds, "M49 full history"), + }, + execution: { wrapperElapsedSeconds: numberValue(execution.wrapper_elapsed_seconds, "M49 full wall") }, + timeline: { + frameCount: exact(integer(timeline.frame_count, "M49 full frames"), 4489, "M49 full frames"), + availableLidarFrameCount: exact(integer(timeline.available_lidar_frame_count, "M49 full available"), 3928, "M49 full available"), + missingLidarFrameCount: exact(integer(timeline.missing_lidar_frame_count, "M49 full missing"), 561, "M49 full missing"), + durationSeconds: numberValue(timeline.duration_seconds, "M49 full duration"), + effectiveFps: numberValue(timeline.effective_fps, "M49 full fps"), + }, + pointAccounting: { + eligible: integer(accounting.eligible, "M49 full eligible"), + ground: integer(accounting.ground, "M49 full ground"), + nonground: integer(accounting.nonground, "M49 full nonground"), + rejected: integer(accounting.rejected, "M49 full rejected"), + unaccounted: exact(integer(accounting.unaccounted, "M49 full unaccounted"), 0, "M49 full unaccounted"), + }, + performance: { + candidateTgsMs: timing(performance.candidate_tgs_ms, "M49 full TGS"), + completionAgeMs: timing(performance.completion_age_ms, "M49 full completion"), + capacityDropCount: integer(performance.capacity_drop_count, "M49 full drops"), + }, + acceptance: { + representationComplete: exact(acceptance.representation_complete, true, "M49 full representation"), + visualQualityAccepted: exact(acceptance.visual_quality_accepted, false, "M49 full visual"), + integratedGraphPerformanceAccepted: exact(acceptance.integrated_graph_performance_accepted, false, "M49 full integrated"), + allFramesAccounted: booleanValue(acceptance.all_frames_accounted, "M49 full frame accounting"), + allEligiblePointsAccounted: booleanValue(acceptance.all_eligible_points_accounted, "M49 full point accounting"), + }, + decision: { + state: text(decision.state, "M49 full decision"), + candidateRetained: booleanValue(decision.candidate_retained, "M49 full retained"), + nextAction: text(decision.next_action, "M49 full next action"), + }, + }; +} + +function pair(value: unknown, label: string): readonly [number, number] { + if (!Array.isArray(value) || value.length !== 2) { + throw new M49TgsFullShadowContractError(`${label}: неверная размерность.`); + } + return [numberValue(value[0], label), numberValue(value[1], label)]; +} + +function parseSpatial( + id: string, + sourceSequence: number, + payload: Record, + sharedCostmap?: Record, +): M49TgsFullShadowSpatial { + exact(payload.source_sequence, sourceSequence, "M49 full spatial sequence"); + const costmap = sharedCostmap ?? objectValue(payload.costmap, "M49 full costmap"); + const metrics = objectValue(payload.metrics, "M49 full frame metrics"); + const centers = costmap.centers_xy_m; + const statesRaw = sharedCostmap ? payload.states : costmap.states; + const zBoundsRaw = sharedCostmap ? payload.z_bounds_m : costmap.z_bounds_m; + if (!Array.isArray(centers) || !Array.isArray(statesRaw) || !Array.isArray(zBoundsRaw) + || centers.length !== 2244 || statesRaw.length !== 2244 || zBoundsRaw.length !== 2244) { + throw new M49TgsFullShadowContractError("M49 full costmap: неверная размерность."); + } + const states = statesRaw.map((value, index) => { + const parsed = integer(value, `M49 full state ${index}`); + if (parsed > 3) throw new M49TgsFullShadowContractError("M49 full state недопустим."); + return parsed as M49TgsFullShadowStateCode; + }); + const zBounds = zBoundsRaw.map((value, index) => { + if (!Array.isArray(value) || value.length !== 2) { + throw new M49TgsFullShadowContractError(`M49 full z ${index}: неверная размерность.`); + } + return value.map((item) => item === null + ? null + : numberValue(item, `M49 full z ${index}`)) as [number | null, number | null]; + }); + const sampleAvailable = booleanValue(payload.sample_available, "M49 full sample available"); + if (!sampleAvailable && states.some((state) => state !== 0)) { + throw new M49TgsFullShadowContractError("M49 missing LiDAR frame не остался UNOBSERVED."); + } + return { + resultId: id, + sourceSequence, + sourceFrameIndex: integer(payload.source_frame_index, "M49 full source frame"), + sessionSeconds: numberValue(payload.session_seconds, "M49 full session seconds"), + sampleAvailable, + costmap: { + cellSizeM: numberValue(costmap.cell_size_m, "M49 full cell size"), + radiusM: numberValue(costmap.radius_m, "M49 full radius"), + centersXyM: centers.map((value, index) => pair(value, `M49 full center ${index}`)), + states, + zBoundsM: zBounds, + }, + metrics: { + eligiblePointCount: integer(metrics.eligible_point_count, "M49 full eligible"), + groundPointCount: integer(metrics.ground_point_count, "M49 full ground"), + nongroundPointCount: integer(metrics.nonground_point_count, "M49 full nonground"), + rejectedPointCount: integer(metrics.rejected_point_count, "M49 full rejected"), + occupiedCellCount: integer(metrics.occupied_cell_count, "M49 full occupied cells"), + }, + }; +} + +export async function fetchM49TgsFullShadowSpatial( + id: string, + sourceSequence: number, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(id) || !Number.isSafeInteger(sourceSequence) || sourceSequence < 0 || sourceSequence >= 4489) { + throw new M49TgsFullShadowContractError("M49 full-shadow frame identity недопустима."); + } + const response = await fetcher( + `/api/v1/laboratory/m49/tgs-full-shadow/${encodeURIComponent(id)}/frames/${sourceSequence}/spatial`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) throw new M49TgsFullShadowContractError(`M49 full-shadow frame недоступен: HTTP ${response.status}.`); + const payload = objectValue(await response.json(), "M49 full spatial"); + exact(payload.schema_version, "missioncore.m49-tgs-full-shadow-spatial/v1", "M49 full spatial schema"); + exact(payload.result_id, id, "M49 full spatial result"); + return parseSpatial(id, sourceSequence, payload); +} + +export async function fetchM49TgsFullShadowSpatialChunk( + id: string, + start: number, + count = 24, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(id) || !Number.isSafeInteger(start) || start < 0 || start >= 4489 + || !Number.isSafeInteger(count) || count < 1 || count > 24) { + throw new M49TgsFullShadowContractError("M49 full-shadow chunk identity недопустима."); + } + const response = await fetcher( + `/api/v1/laboratory/m49/tgs-full-shadow/${encodeURIComponent(id)}/spatial/chunk?start=${start}&count=${count}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) throw new M49TgsFullShadowContractError(`M49 full-shadow chunk недоступен: HTTP ${response.status}.`); + const payload = objectValue(await response.json(), "M49 full spatial chunk"); + exact(payload.schema_version, "missioncore.m49-tgs-full-shadow-spatial-chunk/v1", "M49 full chunk schema"); + exact(payload.result_id, id, "M49 full chunk result"); + exact(payload.start, start, "M49 full chunk start"); + const returnedCount = integer(payload.count, "M49 full chunk count"); + if (!Array.isArray(payload.frames) || payload.frames.length !== returnedCount + || returnedCount !== Math.min(count, 4489 - start)) { + throw new M49TgsFullShadowContractError("M49 full chunk: неверная размерность."); + } + const costmap = objectValue(payload.costmap, "M49 full chunk costmap"); + return { + resultId: id, + start, + count: returnedCount, + frames: payload.frames.map((value, index) => parseSpatial( + id, + start + index, + objectValue(value, `M49 full chunk frame ${index}`), + costmap, + )), + }; +} diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index fb25a31..7a284bf 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -49,6 +49,7 @@ import { M48R3StaticOccupancyShadowResultView } from "./M48R3StaticOccupancyShad import { M48SFixedClassDetectorResultView } from "./M48SFixedClassDetectorResult"; import { M48TRiskQualityResultView } from "./M48TRiskQualityResult"; import { M49TgsFailClosedResultView } from "./M49TgsFailClosedResult"; +import { M49TgsFullShadowResultView } from "./M49TgsFullShadowResult"; export { isAdvancedLaboratoryWorkId }; export type { AdvancedLaboratoryWorkId }; @@ -112,6 +113,9 @@ export function AdvancedLaboratoryResult({ if (workId === "m49-tgs-fail-closed-evidence" && results.m49Tgs) { return ; } + if (workId === "m49-tgs-full-shadow" && results.m49TgsFull) { + return ; + } if (workId === "m47-reference-graph-shadow" && results.m47Graph) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx new file mode 100644 index 0000000..4c97c99 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { + RecordedEvidenceSemanticClass, + RecordedEvidenceSemanticPaletteEntry, +} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay"; +import { + fetchM49TgsFullShadowSpatialChunk, + type M49TgsFullShadowResult, + type M49TgsFullShadowSpatial, + type M49TgsFullShadowSpatialChunk, + type M49TgsFullShadowStateCode, +} from "../../core/laboratory/m49TgsFullShadow"; +import { + M4ReplayThreatVisual, + type M4ReplayClassifiedSpatialFrame, +} 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" } }, +]; + +const CHUNK_FRAMES = 24; +const RETAINED_CHUNKS = 4; + +function cellState(code: M49TgsFullShadowStateCode): 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 + : "Полный TGS spatial frame недоступен."; +} + +export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowResult }) { + const [activeSequence, setActiveSequence] = useState(null); + const [chunks, setChunks] = useState>( + () => new Map(), + ); + const [error, setError] = useState(null); + const inFlightRef = useRef(new Map()); + const activeChunkStart = activeSequence === null + ? null + : Math.floor(activeSequence / CHUNK_FRAMES) * CHUNK_FRAMES; + const activeChunkStartRef = useRef(activeChunkStart); + activeChunkStartRef.current = activeChunkStart; + + useEffect(() => { + for (const controller of inFlightRef.current.values()) controller.abort(); + inFlightRef.current.clear(); + setChunks(new Map()); + setError(null); + return () => { + for (const controller of inFlightRef.current.values()) controller.abort(); + inFlightRef.current.clear(); + }; + }, [result.resultId]); + + useEffect(() => { + if (activeChunkStart === null) return; + const desiredStarts = [activeChunkStart, activeChunkStart + CHUNK_FRAMES] + .filter((start) => start < result.timeline.frameCount); + const desired = new Set(desiredStarts); + for (const [start, controller] of inFlightRef.current) { + if (desired.has(start)) continue; + controller.abort(); + inFlightRef.current.delete(start); + } + for (const start of desiredStarts) { + if (chunks.has(start) || inFlightRef.current.has(start)) continue; + const controller = new AbortController(); + inFlightRef.current.set(start, controller); + void fetchM49TgsFullShadowSpatialChunk(result.resultId, start, CHUNK_FRAMES, { + signal: controller.signal, + }) + .then((chunk) => { + if (controller.signal.aborted) return; + setChunks((current) => { + const next = new Map(current); + next.set(start, chunk); + const center = activeChunkStartRef.current ?? start; + const retained = [...next.keys()] + .sort((left, right) => Math.abs(left - center) - Math.abs(right - center)) + .slice(0, RETAINED_CHUNKS); + return new Map(retained.map((key) => [key, next.get(key)!])); + }); + if (start === activeChunkStartRef.current) setError(null); + }) + .catch((caught: unknown) => { + if (!controller.signal.aborted && start === activeChunkStartRef.current) { + setError(message(caught)); + } + }) + .finally(() => { + if (inFlightRef.current.get(start) === controller) inFlightRef.current.delete(start); + }); + break; + } + }, [activeChunkStart, chunks, result.resultId, result.timeline.frameCount]); + + const spatial = useMemo(() => { + if (activeSequence === null || activeChunkStart === null) return null; + return chunks.get(activeChunkStart)?.frames.find( + (frame) => frame.sourceSequence === activeSequence, + ) ?? null; + }, [activeChunkStart, activeSequence, chunks]); + const loading = activeSequence !== null && !spatial && !error; + + const classifiedFrame = useMemo(() => { + if (!spatial) return null; + return { + sourceSequence: spatial.sourceSequence, + sampleAvailable: spatial.sampleAvailable, + sourcePointCount: spatial.metrics.eligiblePointCount, + pointsMapGravityLocalXyzM: [], + pointClassIds: [], + 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/M49TgsFullShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowResult.tsx new file mode 100644 index 0000000..ad0a19b --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowResult.tsx @@ -0,0 +1,90 @@ +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { M49TgsFullShadowResult } from "../../core/laboratory/m49TgsFullShadow"; +import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence"; + +function number(value: number, digits = 1): string { + return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); +} + +export function M49TgsFullShadowResultView({ + rigLabel, + result, +}: { + rigLabel: string; + result: M49TgsFullShadowResult; +}) { + const accepted = result.decision.candidateRetained; + const status = accepted + ? "Source-paced CPU shadow принят; визуальное и integrated-graph качество ещё проверяются" + : "Source-paced CPU shadow не прошёл performance gate"; + return ( + rejected > ground > unobserved; no free inference", identitySha256: result.resultId.split("-").at(-1) ?? null }, + ], + }} + /> + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index b587052..a880072 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -109,6 +109,8 @@ export interface M4ReplayThreatReviewAnchor { export interface M4ReplayClassifiedSpatialFrame { sourceSequence: number; + sampleAvailable?: boolean; + sourcePointCount?: number; pointsMapGravityLocalXyzM: readonly (readonly [number, number, number])[]; pointClassIds: readonly (number | null)[]; cellsMapGravityLocal: readonly { @@ -129,6 +131,7 @@ export interface M4ReplayClassifiedSpatialLayer { frame: M4ReplayClassifiedSpatialFrame | null; loading: boolean; error: string | null; + replacePointCloud?: boolean; } const EMPTY_REVIEW_ANCHORS: readonly M4ReplayThreatReviewAnchor[] = []; @@ -255,8 +258,8 @@ export function M4ReplayThreatVisual({ if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame; const frame = timelineFrame.activeFrame ?? lastFrameRef.current; useEffect(() => { - onActiveSequenceChange?.(frame?.sequence ?? null); - }, [frame?.sequence, onActiveSequenceChange]); + onActiveSequenceChange?.(timelineFrame.activeSequence); + }, [onActiveSequenceChange, timelineFrame.activeSequence]); const lastSpatialFrameRef = useRef<{ resultId: string; frame: M4ThreatTimelineFrame; @@ -400,16 +403,18 @@ 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 + const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence + ? spatialFrame + : null; + const classifiedSpatialFrame = classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence ? classifiedSpatialLayer?.frame ?? null : null; + const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true; 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 basis = activeSpatialFrame?.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], @@ -418,7 +423,7 @@ export function M4ReplayThreatVisual({ // 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]); + }, [activeSpatialFrame?.bodyFrame?.basisMapFromBody, nominalSensorHeightM]); const classifiedPointsBody = useMemo( () => classifiedSpatialFrame?.pointsMapGravityLocalXyzM.map( mapGravityLocalSensorToBodyGround, @@ -776,7 +781,11 @@ export function M4ReplayThreatVisual({ ? splitPrimarySize : 100; - const overlay = metadata.timeline && frame ? ( + const overlaySequence = timelineFrame.activeSequence ?? frame?.sequence ?? null; + const overlaySessionSeconds = overlaySequence === null + ? null + : (metadata.timeline?.frameTimesNs[overlaySequence] ?? 0) / 1_000_000_000; + const overlay = metadata.timeline && overlaySequence !== null ? (
RAVNOVES00 · recorded realtime - frame {frame.sequence + 1}/{metadata.timeline.frameCount} + frame {overlaySequence + 1}/{metadata.timeline.frameCount} - +{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с + +{((overlaySessionSeconds ?? metadata.timeline.timelineStartSeconds) - metadata.timeline.timelineStartSeconds).toFixed(3)} с · {displayingBufferedFrame ? "держим последний кадр, следующий в буфере" : playbackController.playback.playing ? "воспроизведение" : "пауза / seek"} @@ -797,24 +806,32 @@ export function M4ReplayThreatVisual({ Spatial evidence {classifiedSpatialLayer ? classifiedSpatialFrame - ? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} cells` + ? replaceClassifiedPointCloud + ? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} cells` + : `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} TGS 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" + ? classifiedSpatialFrame.sampleAvailable === false + ? "LiDAR отсутствует · все ячейки принудительно UNOBSERVED · causal rolling 1 s" + : activeSpatialFrame + ? "map-gravity-local · all eligible points accounted · causal rolling 1 s" + : "TGS рассчитан · linked source cloud недоступен для этого кадра" : 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})`} + {frame + ? frame.worldStateAvailable + ? " · world-state delivered" + : ` · world-state gap (${frame.terminalOutcome})` + : " · world-state frame unavailable"} {accumulatedCameraPoints ? ` · camera points ${accumulatedCameraPoints.sampleCount}/${accumulatedCameraPoints.projectedPointCount} · causal ${accumulatedCameraPoints.windowSeconds.toFixed(1)} с / ${accumulatedCameraPoints.sourceFrameCount} frames` - : pointCloudOverlay + : pointCloudOverlay && frame ? ` · camera points ${frame.cameraProjectedSampleCount}/${frame.cameraProjectedPointCount} exact-current · накопление загружается` : showMediaPoints && cameraPointOverlay.error ? " · накопленное camera cloud недоступно" @@ -935,12 +952,12 @@ export function M4ReplayThreatVisual({
) : null} - {spatialFrame && (!classifiedSpatialLayer || classifiedSpatialFrame) ? ( + {(!classifiedSpatialLayer ? spatialFrame : classifiedSpatialFrame) ? ( ) : null} - {frame && !frame.spatialAvailable ? ( + {classifiedSpatialFrame?.sampleAvailable === false ? ( +
+ Кадр {classifiedSpatialFrame.sourceSequence + 1}: LiDAR отсутствует; все 2 244 TGS-ячейки явно UNOBSERVED. +
+ ) : classifiedSpatialFrame && !activeSpatialFrame ? ( +
+ Кадр {classifiedSpatialFrame.sourceSequence + 1}: TGS costmap показан cell-only; linked source cloud для отрисовки отсутствует. +
+ ) : frame && !frame.spatialAvailable ? (
{spatialFrame ? `На кадре ${frame.sequence + 1} нет body frame; держим spatial evidence кадра ${spatialFrame.sequence + 1}.` diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index 0b491e1..5993956 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -112,6 +112,13 @@ const KNOWN_WORKS: Readonly `${rig(rigLabel)} RIGHT · Camera + gravity-aligned LiDAR`, + experimentId: "m49-tgs-full-shadow", + experimentName: "TRAVEL TGS complete source-paced shadow", + variantName: "M4.9T5 · 4 489 frames · causal rolling 1 s · CPU-only", + }, "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 51842ec..48983aa 100644 --- a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts +++ b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts @@ -26,6 +26,7 @@ function mergeResults( m48s: next.m48s ?? current.m48s, m48t: next.m48t ?? current.m48t, m49Tgs: next.m49Tgs ?? current.m49Tgs, + m49TgsFull: next.m49TgsFull ?? current.m49TgsFull, m4Threat: next.m4Threat ?? current.m4Threat, l3: next.l3 ?? current.l3, l31: next.l31 ?? current.l31, @@ -126,6 +127,7 @@ export function useAdvancedLaboratoryCatalog({ "m48-static-occupancy-qualification", "m48r3-static-occupancy-shadow", "m49-tgs-fail-closed-evidence", + "m49-tgs-full-shadow", ].includes(selectedWorkId) && !indexedResultId ) return; diff --git a/apps/control-station/test/m49TgsFullShadow.test.mjs b/apps/control-station/test/m49TgsFullShadow.test.mjs new file mode 100644 index 0000000..9d55401 --- /dev/null +++ b/apps/control-station/test/m49TgsFullShadow.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let fetchM49TgsFullShadowSpatialChunk; + +const resultId = `m49-tgs-full-shadow-${"a".repeat(64)}`; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ fetchM49TgsFullShadowSpatialChunk } = await server.ssrLoadModule( + "/src/core/laboratory/m49TgsFullShadow.ts", + )); +}); + +after(async () => { + await server?.close(); +}); + +test("M4.9T5 chunk keeps every missing-LiDAR cell explicitly UNOBSERVED", async () => { + let requestedUrl = ""; + const centers = Array.from({ length: 2244 }, (_, index) => [index * 0.45, 0]); + const unobserved = Array.from({ length: 2244 }, () => 0); + const zBounds = Array.from({ length: 2244 }, () => [null, null]); + const metrics = { + eligible_point_count: 0, + ground_point_count: 0, + nonground_point_count: 0, + rejected_point_count: 0, + occupied_cell_count: 0, + }; + const chunk = await fetchM49TgsFullShadowSpatialChunk(resultId, 7, 1, { + fetcher: async (url) => { + requestedUrl = String(url); + return new Response(JSON.stringify({ + schema_version: "missioncore.m49-tgs-full-shadow-spatial-chunk/v1", + result_id: resultId, + start: 7, + count: 1, + coordinate_frame: "map-gravity-local", + costmap: { + cell_size_m: 0.45, + radius_m: 12, + centers_xy_m: centers, + }, + frames: [{ + source_sequence: 7, + source_frame_index: 7, + session_seconds: 36.119857292, + sample_available: false, + states: unobserved, + z_bounds_m: zBounds, + metrics, + }], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + + assert.equal( + requestedUrl, + `/api/v1/laboratory/m49/tgs-full-shadow/${resultId}/spatial/chunk?start=7&count=1`, + ); + assert.equal(chunk.count, 1); + assert.equal(chunk.frames[0].sampleAvailable, false); + assert.equal(chunk.frames[0].costmap.states.length, 2244); + assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0])); +}); + +test("M4.9T5 viewer prefetches 24-frame immutable chunks", async () => { + const source = await readFile( + new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url), + "utf8", + ); + assert.match(source, /const CHUNK_FRAMES = 24/); + assert.match(source, /activeChunkStart \+ CHUNK_FRAMES/); + assert.match(source, /fetchM49TgsFullShadowSpatialChunk/); + assert.match(source, /sampleAvailable: spatial\.sampleAvailable/); +}); diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 60d46b0..9c1d309 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -801,12 +801,13 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(visual, /showLocalSurface/); assert.match( visual, - /pointCloudBodyXyzM=\{classifiedSpatialFrame[\s\S]*\? classifiedPointsBody[\s\S]*: spatialFrame\.pointCloudBodyXyzM\}/, + /pointCloudBodyXyzM=\{classifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: activeSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/, ); assert.match( visual, - /const classifiedSpatialFrame = !displayingBufferedFrame[\s\S]*classifiedSpatialLayer\?\.frame\?\.sourceSequence === frame\?\.sequence[\s\S]*spatialFrame\?\.sequence === frame\?\.sequence/, + /const activeSpatialFrame = spatialFrame\?\.sequence === timelineFrame\.activeSequence[\s\S]*const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence/, ); + assert.match(visual, /все 2 244 TGS-ячейки явно UNOBSERVED/); assert.match( visual, /mapGravityLocalSensorToBodyGround[\s\S]*rotated\[2\] \+ nominalSensorHeightM/, diff --git a/config/laboratories/m49-tgs-full-shadow.json b/config/laboratories/m49-tgs-full-shadow.json new file mode 100644 index 0000000..8dfc1ca --- /dev/null +++ b/config/laboratories/m49-tgs-full-shadow.json @@ -0,0 +1,10 @@ +{ + "schema_version": "missioncore.laboratory-evidence-definition/v1", + "work_id": "m49-tgs-full-shadow", + "evidence": { + "runtime_relative_root": "m49/tgs-full-shadow-results", + "result_id_prefix": "m49-tgs-full-shadow", + "document_name": "manifest.json", + "schema_version": "missioncore.m49-tgs-full-shadow-lab/v1" + } +} diff --git a/config/laboratory-execution.json b/config/laboratory-execution.json index 3f86498..0b3d4ba 100644 --- a/config/laboratory-execution.json +++ b/config/laboratory-execution.json @@ -176,6 +176,20 @@ "run": "missioncore.laboratory-run/v1", "evidence": "missioncore.m49-tgs-fail-closed-result/v1" } + }, + { + "work_id": "m49-tgs-full-shadow", + "lifecycle": "experimental", + "isolation": "bounded-adapter", + "adapter_id": "experimental.m49-tgs-full-shadow/v1", + "input_roles": ["repository_root"], + "contracts": { + "source": "missioncore.m49-tgs-full-shadow-result/v1", + "provider": "missioncore.travel-tgs-ground-segmentation/v1", + "graph": "missioncore.m49-tgs-full-source-paced-shadow/v1", + "run": "missioncore.laboratory-run/v1", + "evidence": "missioncore.m49-tgs-full-shadow-lab/v1" + } } ], "legacy_work_ids": [ diff --git a/config/laboratory-value-review.json b/config/laboratory-value-review.json index 57bf47b..dbbd083 100644 --- a/config/laboratory-value-review.json +++ b/config/laboratory-value-review.json @@ -274,6 +274,13 @@ "signal": "progress", "lifecycle": "current", "visual_evidence": "available" + }, + { + "catalog_id": "m49-tgs-full-shadow", + "evidence_id": "m49-tgs-full-shadow-0faeaaf3aba8dccae974eab51ff9cccf264a13ec285abb1920c1e5a09a7e87bc", + "signal": "progress", + "lifecycle": "current", + "visual_evidence": "available" } ] } diff --git a/src/k1link/laboratory/m49_tgs_full_shadow.py b/src/k1link/laboratory/m49_tgs_full_shadow.py new file mode 100644 index 0000000..aa416bc --- /dev/null +++ b/src/k1link/laboratory/m49_tgs_full_shadow.py @@ -0,0 +1,295 @@ +"""Seal and verify the complete source-paced TRAVEL TGS shadow.""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final + +RESULT_SCHEMA: Final = "missioncore.m49-tgs-full-shadow-lab/v1" +REPORT_SCHEMA: Final = "missioncore.m49-tgs-full-shadow-report/v1" +WORKER_SCHEMA: Final = "missioncore.m49-tgs-full-shadow-result/v1" +PREFIX: Final = "m49-tgs-full-shadow-" +PROFILE_SCHEMA: Final = "missioncore.m49-tgs-full-shadow-profile/v1" +EVIDENCE_FILES: Final = ( + "costmap-cell-centers-xy-m.npy", + "costmap-cell-indices-xy.npy", + "costmap-states.npy", + "costmap-z-bounds-m.npy", + "frames.ndjson", +) +_HASH_CHUNK_BYTES: Final = 1024 * 1024 +_MAX_JSON_BYTES: Final = 4 * 1024 * 1024 + + +class M49TgsFullShadowError(RuntimeError): + """The full TGS shadow is unavailable or violates its immutable contract.""" + + +@dataclass(frozen=True, slots=True) +class M49TgsFullShadowResult: + result_id: str + root: Path + manifest: dict[str, Any] + report: dict[str, Any] + + +def _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() + + +def _canonical_sha256(value: object) -> str: + content = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + return hashlib.sha256(content).hexdigest() + + +def _json(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 M49TgsFullShadowError(f"{label} is unavailable") + value = json.loads(path.read_text(encoding="utf-8-sig")) + if not isinstance(value, dict): + raise M49TgsFullShadowError(f"{label} is invalid") + return value + + +def _artifact(path: Path, role: str, media_type: str) -> dict[str, object]: + return { + "path": path.name, + "role": role, + "media_type": media_type, + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + + +def seal_m49_tgs_full_shadow( + *, + source_root: Path, + destination_root: Path, + profile_path: Path, + linked_visual_result_id: str, + created_at_utc: str | None = None, +) -> M49TgsFullShadowResult: + source = source_root.expanduser().resolve(strict=True) + if source.is_symlink() or not source.is_dir(): + raise M49TgsFullShadowError("Worker evidence root is unavailable") + destination = destination_root.expanduser().absolute() + destination.mkdir(parents=True, exist_ok=True) + if destination.is_symlink(): + raise M49TgsFullShadowError("destination must not be a symlink") + profile = _json(profile_path, "full-shadow profile") + worker = _json(source / "result.json", "Worker result") + summary = _json(source / "worker-summary.json", "Worker summary") + timeline = worker.get("timeline") + if ( + profile.get("schema_version") != PROFILE_SCHEMA + or worker.get("schema_version") != WORKER_SCHEMA + or not isinstance(timeline, dict) + or ( + timeline.get("frame_count") != 4489 + or timeline.get("available_lidar_frame_count") != 3928 + or timeline.get("missing_lidar_frame_count") != 561 + ) + or worker.get("point_accounting", {}).get("unaccounted") != 0 + or summary.get("schema_version") != "missioncore.m49-tgs-full-shadow-worker-summary/v1" + or summary.get("gpu_requested") is not False + or summary.get("aos_used") is not False + or summary.get("all_timeline_frames_accounted") is not True + or summary.get("all_eligible_points_accounted") is not True + or summary.get("canonical_triton_health") != "healthy" + ): + raise M49TgsFullShadowError("Worker full-shadow contract changed") + if ( + not linked_visual_result_id.startswith("m4-threat-replay-") + or len(linked_visual_result_id) != len("m4-threat-replay-") + 64 + ): + raise M49TgsFullShadowError("linked visual result is invalid") + for name in EVIDENCE_FILES: + path = source / name + proof = worker.get("files", {}).get(name, {}) + if ( + path.is_symlink() + or not path.is_file() + or proof.get("bytes") != path.stat().st_size + or proof.get("sha256") != _sha256(path) + ): + raise M49TgsFullShadowError(f"Worker evidence changed: {name}") + identity = { + "schema_version": RESULT_SCHEMA, + "source_pack_sha256": worker["source_pack_sha256"], + "input_manifest_sha256": worker["input_manifest_sha256"], + "config_sha256": worker["config_sha256"], + "linked_visual_result_id": linked_visual_result_id, + "files": {name: worker["files"][name]["sha256"] for name in EVIDENCE_FILES}, + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + "visual_quality_accepted": False, + }, + } + identity_sha256 = _canonical_sha256(identity) + result_id = f"{PREFIX}{identity_sha256}" + target = destination / result_id + if target.exists(): + return read_m49_tgs_full_shadow(target) + created = created_at_utc or datetime.now(tz=UTC).isoformat().replace("+00:00", "Z") + performance_accepted = worker.get("status") == "passed" + report = { + "schema_version": REPORT_SCHEMA, + "result_id": result_id, + "created_at_utc": created, + "source": { + "source_id": "RAVNOVES00", + "source_session_id": "20260720T065719Z_viewer_live", + "source_pack_sha256": worker["source_pack_sha256"], + "linked_visual_result_id": linked_visual_result_id, + }, + "configuration": { + "profile_id": profile["profile_id"], + "config_sha256": worker["config_sha256"], + "coordinate_frame": "map-gravity-local", + "history_seconds": profile["profile"]["history_seconds"], + "cell_size_m": worker["costmap"]["cell_size_m"], + "radius_m": worker["costmap"]["radius_m"], + "state_priority": profile["costmap"]["state_priority"], + }, + "execution": { + "worker": "Worker 006", + "device": "cpu", + "gpu_used": False, + "aos_used": False, + "wrapper_elapsed_seconds": summary["wall_seconds"], + "canonical_triton_id": summary["canonical_triton_id"], + "canonical_triton_health": summary["canonical_triton_health"], + }, + "timeline": worker["timeline"], + "point_accounting": worker["point_accounting"], + "performance": worker["performance"], + "acceptance": { + **worker["acceptance"], + "representation_complete": True, + "visual_quality_accepted": False, + "integrated_graph_performance_accepted": False, + }, + "decision": { + "state": ( + "source-paced-qualified-visual-review-required" + if performance_accepted + else "performance-rejected" + ), + "candidate_retained": performance_accepted, + "next_action": ( + "Review the complete camera-synchronised TGS costmap timeline; " + "then measure the integrated graph regression separately." + ), + }, + "limitations": [ + "The run proves recorded source-paced CPU shadow performance, not live sensor transport.", + "No independent traversability truth or vehicle envelope is present.", + "Missing LiDAR frames are explicit all-cell UNOBSERVED and never inferred free.", + "Camera projection, navigation and actuation remain disabled.", + ], + "authority": { + "mode": "replay-simulated", + "commands_enabled": False, + "realtime_shadow_accepted": performance_accepted, + "integrated_graph_performance_accepted": 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, + "frame_count": 4489, + "state_codes": profile["state_codes"], + }, + } + with tempfile.TemporaryDirectory(prefix="mission-core-m49-tgs-full-", dir=destination) as raw: + staging = Path(raw) / result_id + staging.mkdir() + for name in EVIDENCE_FILES: + shutil.copyfile(source / name, staging / name) + shutil.copyfile(source / "worker-summary.json", staging / "worker-summary.json") + (staging / "report.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + artifacts = [ + _artifact(staging / "report.json", "report", "application/json"), + _artifact(staging / "worker-summary.json", "runtime-summary", "application/json"), + ] + artifacts.extend( + _artifact( + staging / name, + "frame-catalog" if name == "frames.ndjson" else "spatial-evidence", + "application/x-ndjson" if name == "frames.ndjson" else "application/x-npy", + ) + for name in EVIDENCE_FILES + ) + manifest = { + "schema_version": RESULT_SCHEMA, + "result_id": result_id, + "created_at_utc": created, + "identity_sha256": identity_sha256, + "identity": identity, + "artifacts": artifacts, + } + (staging / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + staging.replace(target) + return read_m49_tgs_full_shadow(target) + + +def read_m49_tgs_full_shadow(root: Path) -> M49TgsFullShadowResult: + candidate = root.expanduser().resolve(strict=True) + if candidate.is_symlink() or not candidate.is_dir() or not candidate.name.startswith(PREFIX): + raise M49TgsFullShadowError("full-shadow result root is invalid") + manifest = _json(candidate / "manifest.json", "full-shadow manifest") + report = _json(candidate / "report.json", "full-shadow report") + identity = manifest.get("identity") + if ( + manifest.get("schema_version") != RESULT_SCHEMA + or report.get("schema_version") != REPORT_SCHEMA + or manifest.get("result_id") != candidate.name + or report.get("result_id") != candidate.name + or not isinstance(identity, dict) + or manifest.get("identity_sha256") != _canonical_sha256(identity) + or candidate.name != f"{PREFIX}{manifest['identity_sha256']}" + ): + raise M49TgsFullShadowError("full-shadow identity changed") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list): + raise M49TgsFullShadowError("full-shadow artifact catalog changed") + for artifact in artifacts: + if not isinstance(artifact, dict) or not isinstance(artifact.get("path"), str): + raise M49TgsFullShadowError("full-shadow artifact entry changed") + path = candidate / artifact["path"] + if ( + path.parent != candidate + or path.is_symlink() + or not path.is_file() + or artifact.get("byte_length") != path.stat().st_size + or artifact.get("sha256") != _sha256(path) + ): + raise M49TgsFullShadowError("full-shadow artifact digest changed") + return M49TgsFullShadowResult(candidate.name, candidate, manifest, report) + + +__all__ = [ + "M49TgsFullShadowError", + "M49TgsFullShadowResult", + "PREFIX", + "read_m49_tgs_full_shadow", + "seal_m49_tgs_full_shadow", +] diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index aa76ebb..a81bd86 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -134,6 +134,7 @@ from k1link.web.m48s_fixed_class_detector_lab_api import ( ) 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.m49_tgs_full_shadow_api import build_m49_tgs_full_shadow_router from k1link.web.map_api import ( MapGatewayConfiguration, MapGatewayProxy, @@ -1004,6 +1005,17 @@ app.include_router( ), ) ) +app.include_router( + build_m49_tgs_full_shadow_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "m49" + / "tgs-full-shadow-results" + ), + ) +) app.include_router( build_m48s_fixed_class_detector_lab_router( root_provider=lambda: ( diff --git a/src/k1link/web/m49_tgs_full_shadow_api.py b/src/k1link/web/m49_tgs_full_shadow_api.py new file mode 100644 index 0000000..73a3424 --- /dev/null +++ b/src/k1link/web/m49_tgs_full_shadow_api.py @@ -0,0 +1,314 @@ +"""Read-only API for the sealed complete TGS shadow.""" + +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_full_shadow import ( + M49TgsFullShadowError, + M49TgsFullShadowResult, + PREFIX, + read_m49_tgs_full_shadow, +) + +RootProvider = Callable[[], Path | None] +RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$") +ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/tgs-full-shadow" + + +def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: None) -> APIRouter: + router = APIRouter(prefix=ENDPOINT_ROOT, tags=["laboratory"]) + + def sealed(result_id: str) -> M49TgsFullShadowResult: + root = _configured_root(root_provider) + if root is None or RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="M49 TGS full shadow not found") + candidate = root / result_id + if candidate.is_symlink() or not candidate.is_dir(): + raise HTTPException(status_code=404, detail="M49 TGS full shadow not found") + try: + resolved = candidate.resolve(strict=True) + if resolved.parent != root: + raise ValueError("result escaped configured root") + return _read_cached(str(resolved), _signature(resolved)) + except (M49TgsFullShadowError, OSError, ValueError): + raise HTTPException(status_code=404, detail="M49 TGS full shadow 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 _catalog([], configured=False, invalid_total=0) + results: list[dict[str, object]] = [] + invalid = 0 + for candidate in sorted(root.iterdir()): + if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None: + continue + try: + results.append(_project(_read_cached(str(candidate.resolve()), _signature(candidate)))) + except (M49TgsFullShadowError, OSError, ValueError): + invalid += 1 + results.sort(key=lambda row: (str(row["created_at_utc"]), str(row["result_id"])), reverse=True) + return _catalog(results[:limit], configured=True, invalid_total=invalid) + + @router.get("/{result_id}") + def get_result(result_id: str) -> dict[str, object]: + return _project(sealed(result_id)) + + @router.get("/{result_id}/frames/{source_sequence}/spatial") + def get_spatial(result_id: str, source_sequence: int) -> Response: + if source_sequence < 0 or source_sequence >= 4489: + raise HTTPException(status_code=404, detail="M49 TGS full-shadow frame not found") + result = sealed(result_id) + try: + content = _frame_json_cached( + str(result.root), result_id, source_sequence, _evidence_signature(result.root) + ) + except (OSError, ValueError, KeyError, json.JSONDecodeError): + raise HTTPException(status_code=503, detail="M49 TGS full-shadow 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"}, + ) + + @router.get("/{result_id}/spatial/chunk") + def get_spatial_chunk( + result_id: str, + start: int = Query(ge=0, lt=4489), + count: int = Query(default=24, ge=1, le=24), + ) -> Response: + result = sealed(result_id) + bounded_count = min(count, 4489 - start) + try: + content = _chunk_json_cached( + str(result.root), result_id, start, bounded_count, _evidence_signature(result.root) + ) + except (OSError, ValueError, KeyError, json.JSONDecodeError): + raise HTTPException( + status_code=503, + detail="M49 TGS full-shadow spatial chunk 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_cached(root: str, signature: tuple[int, ...]) -> M49TgsFullShadowResult: + del signature + return read_m49_tgs_full_shadow(Path(root)) + + +@lru_cache(maxsize=4) +def _frames(root: str, signature: tuple[int, int]) -> tuple[dict[str, object], ...]: + del signature + path = Path(root) / "frames.ndjson" + rows = tuple(json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()) + if len(rows) != 4489: + raise ValueError("full-shadow frame catalog changed") + return rows + + +@lru_cache(maxsize=96) +def _frame_json_cached( + root: str, result_id: str, source_sequence: int, signature: tuple[int, ...] +) -> bytes: + root_path = Path(root) + frame_signature = (signature[-2], signature[-1]) + frame = _frames(root, frame_signature)[source_sequence] + centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False) + states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False) + z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False) + states = states_all[source_sequence] + z_bounds = z_all[source_sequence] + if ( + centers.shape != (2244, 2) + or states_all.shape != (4489, 2244) + or z_all.shape != (4489, 2244, 2) + or not np.isfinite(centers).all() + or not np.isin(states, np.asarray([0, 1, 2, 3], dtype=np.uint8)).all() + ): + raise ValueError("full-shadow spatial shape changed") + payload = { + "schema_version": "missioncore.m49-tgs-full-shadow-spatial/v1", + "result_id": result_id, + "source_sequence": source_sequence, + "source_frame_index": frame["source_frame_index"], + "session_seconds": frame["session_seconds"], + "sample_available": frame["sample_available"], + "coordinate_frame": "map-gravity-local", + "costmap": { + "cell_size_m": 0.45, + "radius_m": 12.0, + "centers_xy_m": centers.astype(float).tolist(), + "states": 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 + ], + }, + "metrics": copy.deepcopy(frame), + "state_codes": {"UNOBSERVED": 0, "GROUND_SUPPORT": 1, "NONGROUND_OCCUPIED": 2, "UNKNOWN_REJECTED": 3}, + "aos_used": False, + "gpu_used": False, + "authority": {"navigation_or_safety_accepted": False, "visual_quality_accepted": False}, + "access": "read-only", + } + return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +@lru_cache(maxsize=16) +def _chunk_json_cached( + root: str, + result_id: str, + start: int, + count: int, + signature: tuple[int, ...], +) -> bytes: + root_path = Path(root) + frame_signature = (signature[-2], signature[-1]) + frames = _frames(root, frame_signature) + centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False) + states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False) + z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False) + if ( + centers.shape != (2244, 2) + or states_all.shape != (4489, 2244) + or z_all.shape != (4489, 2244, 2) + or not np.isfinite(centers).all() + ): + raise ValueError("full-shadow spatial chunk shape changed") + rows: list[dict[str, object]] = [] + for source_sequence in range(start, start + count): + frame = frames[source_sequence] + states = states_all[source_sequence] + z_bounds = z_all[source_sequence] + if not np.isin(states, np.asarray([0, 1, 2, 3], dtype=np.uint8)).all(): + raise ValueError("full-shadow spatial state changed") + rows.append( + { + "source_sequence": source_sequence, + "source_frame_index": frame["source_frame_index"], + "session_seconds": frame["session_seconds"], + "sample_available": frame["sample_available"], + "states": 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 + ], + "metrics": copy.deepcopy(frame), + } + ) + payload = { + "schema_version": "missioncore.m49-tgs-full-shadow-spatial-chunk/v1", + "result_id": result_id, + "start": start, + "count": count, + "coordinate_frame": "map-gravity-local", + "costmap": { + "cell_size_m": 0.45, + "radius_m": 12.0, + "centers_xy_m": centers.astype(float).tolist(), + }, + "frames": rows, + "state_codes": { + "UNOBSERVED": 0, + "GROUND_SUPPORT": 1, + "NONGROUND_OCCUPIED": 2, + "UNKNOWN_REJECTED": 3, + }, + "aos_used": False, + "gpu_used": False, + "authority": { + "navigation_or_safety_accepted": False, + "visual_quality_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: M49TgsFullShadowResult) -> dict[str, object]: + return { + **copy.deepcopy(result.report), + "schema_version": "missioncore.m49-tgs-full-shadow-view/v1", + "result_id": result.result_id, + "created_at_utc": result.manifest["created_at_utc"], + "ground_truth": False, + "access": "read-only", + } + + +def _catalog(items: list[dict[str, object]], *, configured: bool, invalid_total: int) -> dict[str, object]: + return { + "schema_version": "missioncore.m49-tgs-full-shadow-catalog/v1", + "configured": configured, + "items": items, + "candidate_total": len(items) + invalid_total, + "invalid_total": invalid_total, + "access": "read-only", + } + + +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(root: Path) -> tuple[int, ...]: + values: list[int] = [] + for name in ("manifest.json", "report.json", "worker-summary.json", *EVIDENCE_FILES): + path = root / name + if path.is_symlink() or not path.is_file(): + raise ValueError("full-shadow artifact unavailable") + stat = path.stat() + values.extend((stat.st_size, stat.st_mtime_ns)) + return tuple(values) + + +def _evidence_signature(root: Path) -> tuple[int, ...]: + return _signature(root) + + +EVIDENCE_FILES: Final = ( + "costmap-cell-centers-xy-m.npy", + "costmap-cell-indices-xy.npy", + "costmap-states.npy", + "costmap-z-bounds-m.npy", + "frames.ndjson", +) + + +__all__ = ["build_m49_tgs_full_shadow_router"] diff --git a/tests/test_laboratory_evidence_registry.py b/tests/test_laboratory_evidence_registry.py index de34304..de5cc21 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) == 41 + assert len(registry.definitions) == 42 assert {item.work_id for item in registry.definitions} >= { "e31-source-binding", "e46j-raw-fisheye-realtime", @@ -145,6 +145,7 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None: "m48s-fixed-class-detector", "m48t-risk-quality-temporal", "m49-tgs-fail-closed-evidence", + "m49-tgs-full-shadow", } 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 6a7751d..0bbbad2 100644 --- a/tests/test_laboratory_execution.py +++ b/tests/test_laboratory_execution.py @@ -102,6 +102,7 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: "m48s-fixed-class-detector", "m48t-risk-quality-temporal", "m49-tgs-fail-closed-evidence", + "m49-tgs-full-shadow", } by_work_id = {row.work_id: row for row in execution.definitions} assert by_work_id["m48-small-static-passage-regression"].evidence_contract == ( @@ -121,6 +122,8 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: 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 by_work_id["m49-tgs-full-shadow"].lifecycle == "experimental" + assert by_work_id["m49-tgs-full-shadow"].isolation == "bounded-adapter" assert all( row.lifecycle == "canonical" for row in execution.definitions @@ -130,6 +133,7 @@ def test_repository_registry_classifies_every_evidence_definition() -> None: "m48s-fixed-class-detector", "m48t-risk-quality-temporal", "m49-tgs-fail-closed-evidence", + "m49-tgs-full-shadow", } ) 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 292ffb4..62d9202 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) == 39 + assert len(registry.entries) == 40 assert {entry.catalog_id for entry in registry.entries} >= { "e28-local-surface", "e46d-temporal-failure-audit", @@ -92,4 +92,5 @@ def test_product_value_review_registry_covers_reviewed_laboratory_families() -> "m48s-fixed-class-detector", "m48t-risk-quality-temporal", "m49-tgs-fail-closed-evidence", + "m49-tgs-full-shadow", } diff --git a/tests/test_m49_tgs_full_shadow.py b/tests/test_m49_tgs_full_shadow.py new file mode 100644 index 0000000..b0baab2 --- /dev/null +++ b/tests/test_m49_tgs_full_shadow.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import numpy as np + +from k1link.web import m49_tgs_full_shadow_api as full_shadow_api + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKER_ROOT = REPOSITORY_ROOT / "experiments" / "perception" / "worker" / "m49_t3_travel" +sys.path.insert(0, str(WORKER_ROOT)) + +EVIDENCE_SPEC = importlib.util.spec_from_file_location( + "m49_tgs_full_shadow_evidence", WORKER_ROOT / "build_tgs_full_shadow_evidence.py" +) +assert EVIDENCE_SPEC and EVIDENCE_SPEC.loader +EVIDENCE = importlib.util.module_from_spec(EVIDENCE_SPEC) +EVIDENCE_SPEC.loader.exec_module(EVIDENCE) + +ARTIFACT_SPEC = importlib.util.spec_from_file_location( + "m49_tgs_full_shadow_artifact", + REPOSITORY_ROOT / "scripts" / "build_m49_tgs_full_shadow_worker_artifact.py", +) +assert ARTIFACT_SPEC and ARTIFACT_SPEC.loader +ARTIFACT = importlib.util.module_from_spec(ARTIFACT_SPEC) +ARTIFACT_SPEC.loader.exec_module(ARTIFACT) + + +def test_full_shadow_exact_multiset_and_costmap_priority() -> None: + native = np.asarray( + [[2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0], [3.0, 0.0, 1.0, 0.0], [4.0, 0.0, 2.0, 0.0]], + dtype=np.float32, + ) + points, states = EVIDENCE.classify_exact_input(native, native[[0]], native[[1, 2]]) + assert sorted(states.tolist()) == [1, 2, 2, 3] + grid = EVIDENCE.costmap_grid(12.0, 0.45) + cell_states, _ = EVIDENCE.rasterize(points, states, grid, 0.45) + assert np.count_nonzero(cell_states == 2) == 2 + + +def test_full_shadow_worker_artifact_is_deterministic_and_cpu_only(tmp_path: Path) -> None: + revision = "a" * 40 + first = ARTIFACT.build("m49-tgs-full-test", tmp_path / "one", revision=revision) + second = ARTIFACT.build("m49-tgs-full-test", tmp_path / "two", revision=revision) + assert first["sha256"] == second["sha256"] + import tarfile + + with tarfile.open(first["artifact"], "r:gz") as archive: + release = archive.extractfile("payload/release.json") + runner = archive.extractfile("payload/Invoke-M49TgsFullShadow.ps1") + assert release is not None and runner is not None + release_text = release.read().decode() + runner_text = runner.read().decode() + assert '"candidate_id": "travel-tgs-full-shadow"' in release_text + assert "--gpus" not in runner_text + assert "gpu_requested = $false" in runner_text + + +def test_full_shadow_chunk_contract_keeps_missing_lidar_unobserved( + monkeypatch, + tmp_path: Path, +) -> None: + frames = tuple( + { + "source_frame_index": index, + "session_seconds": index / 10, + "sample_available": index != 1, + "eligible_point_count": 0 if index == 1 else 1, + "ground_point_count": 0, + "nonground_point_count": 0 if index == 1 else 1, + "rejected_point_count": 0, + "occupied_cell_count": 0 if index == 1 else 1, + } + for index in range(4489) + ) + centers = np.zeros((2244, 2), dtype=np.float32) + states = np.broadcast_to(np.zeros((1, 2244), dtype=np.uint8), (4489, 2244)) + z_bounds = np.broadcast_to( + np.full((1, 2244, 2), np.nan, dtype=np.float32), + (4489, 2244, 2), + ) + + monkeypatch.setattr(full_shadow_api, "_frames", lambda *_args: frames) + monkeypatch.setattr( + full_shadow_api.np, + "load", + lambda path, **_kwargs: ( + centers if "centers" in str(path) else states if "states" in str(path) else z_bounds + ), + ) + content = full_shadow_api._chunk_json_cached.__wrapped__( + str(tmp_path), + "m49-tgs-full-shadow-" + "a" * 64, + 0, + 2, + (0, 0), + ) + payload = json.loads(content) + assert payload["schema_version"] == "missioncore.m49-tgs-full-shadow-spatial-chunk/v1" + assert payload["count"] == 2 + assert len(payload["costmap"]["centers_xy_m"]) == 2244 + assert payload["frames"][1]["sample_available"] is False + assert set(payload["frames"][1]["states"]) == {0}