From 621084fcd6c2d63f5049eeee14b3e02eb938de33 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 27 Jul 2026 16:55:40 +0300 Subject: [PATCH] feat(perception): add E34 temporal occupied layer --- .../src/core/laboratory/advancedResults.ts | 10 +- .../src/core/laboratory/e34TemporalLayer.ts | 480 +++++++ apps/control-station/src/styles.css | 1 + .../src/styles/e34-temporal-layer.css | 169 +++ .../laboratory/AdvancedLaboratoryResult.tsx | 146 +++ .../src/workspaces/laboratory/E34Result.tsx | 236 ++++ .../laboratory/E34TemporalLayerScene.tsx | 363 ++++++ .../laboratory/LaboratoryArchiveWorkspace.tsx | 133 +- .../test/advancedLaboratoryResults.test.mjs | 93 +- .../test/laboratoryProductUi.test.mjs | 25 + docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md | 19 +- ...16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md | 14 +- ...27-e34-short-ttl-occupied-unknown-layer.md | 89 ++ .../perception/LAB_E34_REPORT_2026-07-27.md | 182 +++ .../e34_temporal_occupied_layer_profile.json | 45 + .../run_e34_temporal_occupied_layer.py | 47 + .../compute/e34_temporal_occupied_replay.py | 1099 +++++++++++++++++ src/k1link/compute/temporal_occupied_layer.py | 1026 +++++++++++++++ src/k1link/web/advanced_laboratory_api.py | 158 +++ src/k1link/web/app.py | 7 + tests/test_advanced_laboratory_api.py | 138 ++- tests/test_e34_temporal_occupied_layer.py | 356 ++++++ 22 files changed, 4732 insertions(+), 104 deletions(-) create mode 100644 apps/control-station/src/core/laboratory/e34TemporalLayer.ts create mode 100644 apps/control-station/src/styles/e34-temporal-layer.css create mode 100644 apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/E34Result.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/E34TemporalLayerScene.tsx create mode 100644 docs/adr/0027-e34-short-ttl-occupied-unknown-layer.md create mode 100644 experiments/perception/LAB_E34_REPORT_2026-07-27.md create mode 100644 experiments/perception/e34_temporal_occupied_layer_profile.json create mode 100644 experiments/perception/run_e34_temporal_occupied_layer.py create mode 100644 src/k1link/compute/e34_temporal_occupied_replay.py create mode 100644 src/k1link/compute/temporal_occupied_layer.py create mode 100644 tests/test_e34_temporal_occupied_layer.py diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 501a637..3c264d2 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -93,6 +93,7 @@ export interface AdvancedLaboratoryResults { e31: E31LaboratoryResult | null; e32: E32LaboratoryResult | null; e33: E33LaboratoryResult | null; + e34: E34TemporalLayerResult | null; } export class AdvancedLaboratoryContractError extends Error { @@ -371,10 +372,15 @@ export async function fetchAdvancedLaboratoryResults({ fetcher?: LaboratoryFetch; signal?: AbortSignal; } = {}): Promise { - const [e31, e32, e33] = await Promise.all([ + const [e31, e32, e33, e34] = await Promise.all([ fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal), fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal), fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal), + fetchE34TemporalLayerResult({ fetcher, signal }), ]); - return { e31, e32, e33 }; + return { e31, e32, e33, e34 }; } +import { + fetchE34TemporalLayerResult, + type E34TemporalLayerResult, +} from "./e34TemporalLayer"; diff --git a/apps/control-station/src/core/laboratory/e34TemporalLayer.ts b/apps/control-station/src/core/laboratory/e34TemporalLayer.ts new file mode 100644 index 0000000..3d53958 --- /dev/null +++ b/apps/control-station/src/core/laboratory/e34TemporalLayer.ts @@ -0,0 +1,480 @@ +export type E34TemporalState = "current" | "held" | "expired"; +export type E34OccupancyState = "occupied" | "unknown"; +export type E34OwnerKind = "camera-track" | "geometry-cluster"; +export type E34Point3 = readonly [number, number, number]; + +export interface E34TemporalHistoryPoint { + frameIndex: number; + sessionSeconds: number; + centroidMapXyzM: E34Point3; +} + +export interface E34TemporalComponent { + temporalId: number; + state: E34TemporalState; + occupancyState: E34OccupancyState; + ownerKind: E34OwnerKind; + centroidMapXyzM: E34Point3; + lastObservedAgeSeconds: number; + associationReason: string; + history: readonly E34TemporalHistoryPoint[]; +} + +export interface E34TemporalReviewFrame { + frameIndex: number; + sessionSeconds: number; + sourceAvailable: boolean; + layerState: "current" | "held" | "unknown"; + counts: { + current: number; + held: number; + expired: number; + }; + cellCentersMapXyzM: readonly E34Point3[]; + components: readonly E34TemporalComponent[]; +} + +export interface E34TemporalLayerResult { + resultId: string; + createdAtUtc: string | null; + sourceSessionId: string; + status: "accepted-bounded-occupied-unknown-temporal-layer"; + e32ResultId: string; + e33ResultId: string; + profileId: string; + pipelineId: string; + coordinateFrame: "map"; + configuration: { + voxelSizeM: number; + occupiedTtlSeconds: number; + maximumActiveComponents: number; + maximumCellsPerComponent: number; + geometryMaximumGapSeconds: number; + geometryMaximumCentroidDistanceM: number; + }; + metrics: { + sourceFrames: number; + processedFrames: number; + framesWithCurrentLayer: number; + heldOnlyFrames: number; + unknownEmptyFrames: number; + createdComponents: number; + cameraCreatedComponents: number; + geometryCreatedComponents: number; + geometryReassociatedComponents: number; + exactCameraAssociations: number; + geometrySpatialReassociations: number; + heldPublications: number; + expiredComponents: number; + peakActiveComponents: number; + maximumObservedSpanSeconds: number; + continuityFraction: number; + geometryOnlyReassociationFraction: number; + e32CurrentPointRows: number; + e34ConsumedCurrentPointRows: number; + currentCellRows: number; + heldCellRows: number; + storedCellRows: number; + freeCellRows: number; + peakCellsPerComponent: number; + maximumHeldAgeSeconds: number; + maximumExpiryDelaySeconds: number; + maximumReplayMaterializationDelaySeconds: number; + mapFrameJumpCandidates: number; + frameProcessingP95Ms: number; + buildElapsedMs: number; + }; + reviewFrames: readonly E34TemporalReviewFrame[]; + access: "read-only"; +} + +type LaboratoryFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +class E34TemporalLayerContractError extends Error { + constructor(message: string) { + super(message); + this.name = "E34TemporalLayerContractError"; + } +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new E34TemporalLayerContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function list(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new E34TemporalLayerContractError(`${label}: ожидался массив.`); + } + return value; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new E34TemporalLayerContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function optionalString(value: unknown, label: string): string | null { + return value === null ? null : stringValue(value, label); +} + +function finiteNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new E34TemporalLayerContractError(`${label}: ожидалось число.`); + } + return value; +} + +function numberValue(value: unknown, label: string): number { + const parsed = finiteNumber(value, label); + if (parsed < 0) { + throw new E34TemporalLayerContractError(`${label}: число отрицательно.`); + } + return parsed; +} + +function integerValue(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isSafeInteger(parsed)) { + throw new E34TemporalLayerContractError(`${label}: ожидалось целое число.`); + } + return parsed; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new E34TemporalLayerContractError(`${label}: ожидался boolean.`); + } + return value; +} + +function exactString( + value: unknown, + expected: T, + label: string, +): T { + if (value !== expected) { + throw new E34TemporalLayerContractError(`${label}: неверное значение.`); + } + return expected; +} + +function oneOf( + value: unknown, + expected: readonly T[], + label: string, +): T { + if (typeof value !== "string" || !expected.includes(value as T)) { + throw new E34TemporalLayerContractError(`${label}: неверное значение.`); + } + return value as T; +} + +function contentId(value: unknown, prefix: string, label: string): string { + const parsed = stringValue(value, label); + if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) { + throw new E34TemporalLayerContractError(`${label}: неверный content id.`); + } + return parsed; +} + +function point(value: unknown, label: string): E34Point3 { + const coordinates = list(value, label); + if (coordinates.length !== 3) { + throw new E34TemporalLayerContractError(`${label}: ожидались XYZ.`); + } + return [ + finiteNumber(coordinates[0], `${label}[0]`), + finiteNumber(coordinates[1], `${label}[1]`), + finiteNumber(coordinates[2], `${label}[2]`), + ]; +} + +function parseHistory(value: unknown, label: string): E34TemporalHistoryPoint { + const item = record(value, label); + return { + frameIndex: integerValue(item.frame_index, `${label}.frame_index`), + sessionSeconds: numberValue( + item.session_seconds, + `${label}.session_seconds`, + ), + centroidMapXyzM: point( + item.centroid_map_xyz_m, + `${label}.centroid_map_xyz_m`, + ), + }; +} + +function parseComponent(value: unknown, label: string): E34TemporalComponent { + const item = record(value, label); + return { + temporalId: integerValue(item.temporal_id, `${label}.temporal_id`), + state: oneOf( + item.state, + ["current", "held", "expired"] as const, + `${label}.state`, + ), + occupancyState: oneOf( + item.occupancy_state, + ["occupied", "unknown"] as const, + `${label}.occupancy_state`, + ), + ownerKind: oneOf( + item.owner_kind, + ["camera-track", "geometry-cluster"] as const, + `${label}.owner_kind`, + ), + centroidMapXyzM: point( + item.centroid_map_xyz_m, + `${label}.centroid_map_xyz_m`, + ), + lastObservedAgeSeconds: numberValue( + item.last_observed_age_seconds, + `${label}.last_observed_age_seconds`, + ), + associationReason: stringValue( + item.association_reason, + `${label}.association_reason`, + ), + history: list(item.history_tail, `${label}.history_tail`).map( + (entry, index) => parseHistory(entry, `${label}.history_tail[${index}]`), + ), + }; +} + +function parseReviewFrame( + value: unknown, + label: string, +): E34TemporalReviewFrame { + const item = record(value, label); + const counts = record(item.counts, `${label}.counts`); + return { + frameIndex: integerValue(item.frame_index, `${label}.frame_index`), + sessionSeconds: numberValue( + item.session_seconds, + `${label}.session_seconds`, + ), + sourceAvailable: booleanValue( + item.source_available, + `${label}.source_available`, + ), + layerState: oneOf( + item.layer_state, + ["current", "held", "unknown"] as const, + `${label}.layer_state`, + ), + counts: { + current: integerValue(counts.current, `${label}.counts.current`), + held: integerValue(counts.held, `${label}.counts.held`), + expired: integerValue(counts.expired, `${label}.counts.expired`), + }, + cellCentersMapXyzM: list( + item.cell_centers_map_xyz_m, + `${label}.cell_centers_map_xyz_m`, + ).map((entry, index) => point( + entry, + `${label}.cell_centers_map_xyz_m[${index}]`, + )), + components: list(item.components, `${label}.components`).map( + (entry, index) => parseComponent( + entry, + `${label}.components[${index}]`, + ), + ), + }; +} + +function diagnosticAuthority(value: unknown, label: string): void { + const authority = record(value, label); + if ( + authority.commands_enabled !== false + || authority.navigation_or_safety_accepted !== false + ) { + throw new E34TemporalLayerContractError( + `${label}: запрещённые полномочия.`, + ); + } +} + +function metricRecord( + value: Record, +): E34TemporalLayerResult["metrics"] { + const metric = (key: string) => numberValue( + value[key], + `E34.metrics.${key}`, + ); + const integer = (key: string) => integerValue( + value[key], + `E34.metrics.${key}`, + ); + return { + sourceFrames: integer("source_frames"), + processedFrames: integer("processed_frames"), + framesWithCurrentLayer: integer("frames_with_current_layer"), + heldOnlyFrames: integer("held_only_frames"), + unknownEmptyFrames: integer("unknown_empty_frames"), + createdComponents: integer("created_components"), + cameraCreatedComponents: integer("camera_created_components"), + geometryCreatedComponents: integer("geometry_created_components"), + geometryReassociatedComponents: integer("geometry_reassociated_components"), + exactCameraAssociations: integer("exact_camera_associations"), + geometrySpatialReassociations: integer("geometry_spatial_reassociations"), + heldPublications: integer("held_publications"), + expiredComponents: integer("expired_components"), + peakActiveComponents: integer("peak_active_components"), + maximumObservedSpanSeconds: metric("maximum_observed_span_seconds"), + continuityFraction: metric("continuity_fraction"), + geometryOnlyReassociationFraction: metric( + "geometry_only_reassociation_fraction", + ), + e32CurrentPointRows: integer("e32_current_point_rows"), + e34ConsumedCurrentPointRows: integer("e34_consumed_current_point_rows"), + currentCellRows: integer("current_cell_rows"), + heldCellRows: integer("held_cell_rows"), + storedCellRows: integer("stored_cell_rows"), + freeCellRows: integer("free_cell_rows"), + peakCellsPerComponent: integer("peak_cells_per_component"), + maximumHeldAgeSeconds: metric("maximum_held_age_seconds"), + maximumExpiryDelaySeconds: metric("maximum_expiry_delay_seconds"), + maximumReplayMaterializationDelaySeconds: metric( + "maximum_replay_materialization_delay_seconds", + ), + mapFrameJumpCandidates: integer("map_frame_jump_candidates"), + frameProcessingP95Ms: metric("frame_processing_p95_ms"), + buildElapsedMs: metric("build_elapsed_ms"), + }; +} + +function parseResult(value: unknown): E34TemporalLayerResult { + const item = record(value, "E34"); + const configuration = record(item.configuration, "E34.configuration"); + const review = record(item.review, "E34.review"); + const acceptance = record(item.acceptance, "E34.acceptance"); + diagnosticAuthority(item.authority, "E34.authority"); + if (acceptance.accepted !== true) { + throw new E34TemporalLayerContractError("E34.acceptance: результат отклонён."); + } + exactString( + review.schema_version, + "missioncore.e34-temporal-review-timeline/v1", + "E34.review.schema_version", + ); + return { + resultId: contentId( + item.result_id, + "e34-temporal-occupied", + "E34.result_id", + ), + createdAtUtc: optionalString(item.created_at_utc, "E34.created_at_utc"), + sourceSessionId: stringValue( + item.source_session_id, + "E34.source_session_id", + ), + status: exactString( + item.status, + "accepted-bounded-occupied-unknown-temporal-layer", + "E34.status", + ), + e32ResultId: contentId( + item.e32_result_id, + "e32-track-geometry", + "E34.e32_result_id", + ), + e33ResultId: contentId( + item.e33_result_id, + "e33-worker-shadow", + "E34.e33_result_id", + ), + profileId: stringValue(item.profile_id, "E34.profile_id"), + pipelineId: stringValue(item.pipeline_id, "E34.pipeline_id"), + coordinateFrame: exactString( + item.coordinate_frame, + "map", + "E34.coordinate_frame", + ), + configuration: { + voxelSizeM: numberValue( + configuration.voxel_size_m, + "E34.configuration.voxel_size_m", + ), + occupiedTtlSeconds: numberValue( + configuration.occupied_ttl_seconds, + "E34.configuration.occupied_ttl_seconds", + ), + maximumActiveComponents: integerValue( + configuration.maximum_active_components, + "E34.configuration.maximum_active_components", + ), + maximumCellsPerComponent: integerValue( + configuration.maximum_cells_per_component, + "E34.configuration.maximum_cells_per_component", + ), + geometryMaximumGapSeconds: numberValue( + configuration.geometry_maximum_gap_seconds, + "E34.configuration.geometry_maximum_gap_seconds", + ), + geometryMaximumCentroidDistanceM: numberValue( + configuration.geometry_maximum_centroid_distance_m, + "E34.configuration.geometry_maximum_centroid_distance_m", + ), + }, + metrics: metricRecord(record(item.metrics, "E34.metrics")), + reviewFrames: list(review.frames, "E34.review.frames").map( + (entry, index) => parseReviewFrame( + entry, + `E34.review.frames[${index}]`, + ), + ), + access: exactString(item.access, "read-only", "E34.access"), + }; +} + +export async function fetchE34TemporalLayerResult({ + fetcher = fetch, + signal, +}: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; +} = {}): Promise { + const response = await fetcher( + "/api/v1/laboratory/e34/results?limit=1", + { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }, + ); + if (!response.ok) { + throw new E34TemporalLayerContractError( + `Каталог LAB E34 недоступен: HTTP ${response.status}.`, + ); + } + const catalog = record(await response.json(), "Каталог LAB E34"); + exactString( + catalog.schema_version, + "missioncore.laboratory-advanced-catalog/v1", + "Каталог LAB E34.schema_version", + ); + booleanValue(catalog.configured, "Каталог LAB E34.configured"); + integerValue(catalog.candidate_total, "Каталог LAB E34.candidate_total"); + integerValue(catalog.invalid_total, "Каталог LAB E34.invalid_total"); + exactString( + catalog.access, + "read-only", + "Каталог LAB E34.access", + ); + const items = list(catalog.items, "Каталог LAB E34.items"); + if (items.length > 1) { + throw new E34TemporalLayerContractError( + "Каталог LAB E34: нарушен limit=1.", + ); + } + return items.length ? parseResult(items[0]) : null; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 053c73e..ffcf1e9 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -3,6 +3,7 @@ @import "./styles/workspaces.css"; @import "./styles/laboratory.css"; @import "./styles/laboratory-reporting.css"; +@import "./styles/e34-temporal-layer.css"; @import "./styles/e30-human-review.css"; @import "./styles/spatial.css"; @import "./styles/device.css"; diff --git a/apps/control-station/src/styles/e34-temporal-layer.css b/apps/control-station/src/styles/e34-temporal-layer.css new file mode 100644 index 0000000..ad07385 --- /dev/null +++ b/apps/control-station/src/styles/e34-temporal-layer.css @@ -0,0 +1,169 @@ +.e34-temporal-evidence, +.e34-temporal-scene { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.e34-temporal-scene { + position: relative; + overflow: hidden; + background: var(--nodedc-canvas); +} + +.e34-temporal-scene__viewport { + position: absolute; + inset: 0; +} + +.e34-temporal-scene__viewport canvas { + display: block; + width: 100%; + height: 100%; + cursor: grab; + touch-action: none; +} + +.e34-temporal-scene__viewport canvas:active { + cursor: grabbing; +} + +.e34-temporal-scene__toolbar { + position: absolute; + z-index: 3; + top: 0.6rem; + left: 0.6rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.e34-temporal-scene__toolbar .nodedc-button { + background: var(--nodedc-floating-surface); + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e34-temporal-scene__gestures, +.e34-temporal-scene__legend, +.e34-temporal-evidence__telemetry { + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-floating-surface); + color: var(--nodedc-text-secondary); + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e34-temporal-scene__gestures { + display: flex; + gap: 0.55rem; + padding: 0.43rem 0.55rem; +} + +.e34-temporal-scene__gestures span, +.e34-temporal-scene__legend span, +.e34-temporal-evidence__telemetry dt, +.e34-temporal-evidence__telemetry dd { + font-size: 0.5rem; +} + +.e34-temporal-scene__legend { + position: absolute; + right: 0.6rem; + bottom: 0.6rem; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + padding: 0.42rem 0.55rem; +} + +.e34-temporal-scene__legend span { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.e34-temporal-scene__legend span::before { + width: 0.38rem; + height: 0.38rem; + border-radius: 50%; + background: var(--nodedc-text-muted); + content: ""; +} + +.e34-temporal-scene__legend span[data-state="current"]::before { + background: rgb(var(--nodedc-accent-rgb)); +} + +.e34-temporal-scene__legend span[data-state="held"]::before { + background: rgb(var(--nodedc-warning-rgb)); +} + +.e34-temporal-scene__legend span[data-state="expired"]::before { + background: rgb(var(--nodedc-danger-rgb)); +} + +.e34-temporal-evidence__telemetry { + position: absolute; + z-index: 3; + left: 0.6rem; + bottom: 0.6rem; + display: grid; + width: min(13rem, calc(100% - 1.2rem)); + gap: 0.35rem; + margin: 0; + padding: 0.55rem 0.65rem; + pointer-events: none; +} + +.e34-temporal-evidence__telemetry > div { + display: grid; + gap: 0.12rem; +} + +.e34-temporal-evidence__telemetry dt, +.e34-temporal-evidence__telemetry dd { + margin: 0; +} + +.e34-temporal-evidence__telemetry dt { + color: var(--nodedc-text-muted); +} + +.e34-temporal-evidence__telemetry dd { + overflow: hidden; + color: var(--nodedc-text-primary); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.e34-temporal-evidence .laboratory-evidence-viewer__controls + .nodedc-select-anchor { + width: clamp(12rem, 24vw, 20rem); +} + +.e34-temporal-scene__error { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--nodedc-text-muted); + font-size: 0.62rem; +} + +@media (max-width: 900px) { + .e34-temporal-scene__gestures { + display: none; + } + + .e34-temporal-scene__legend { + left: 0.6rem; + right: auto; + bottom: 5.7rem; + } + + .e34-temporal-evidence .laboratory-evidence-viewer__controls + .nodedc-select-anchor { + width: 10rem; + } +} diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx new file mode 100644 index 0000000..6e6f85c --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -0,0 +1,146 @@ +import type { ComponentType } from "react"; + +import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation"; +import type { AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults"; +import type { ObservationSessionSummary } from "../../core/observation/sessionArchive"; +import type { WorkspaceRendererProps } from "../contracts"; +import { E31Result } from "./E31Result"; +import { E32Result } from "./E32Result"; +import { E33Result } from "./E33Result"; +import { E34Result } from "./E34Result"; +import { RecordedReplayEvidence } from "./RecordedReplayEvidence"; + +export type AdvancedLaboratoryWorkId = + | "e31-source-binding" + | "e32-track-geometry" + | "e33-worker-shadow" + | "e34-temporal-layer"; + +type LaboratoryWorkspaceProps = WorkspaceRendererProps & { + SpatialView: ComponentType; +}; + +export function isAdvancedLaboratoryWorkId( + value: string, +): value is AdvancedLaboratoryWorkId { + return ( + value === "e31-source-binding" + || value === "e32-track-geometry" + || value === "e33-worker-shadow" + || value === "e34-temporal-layer" + ); +} + +export function advancedLaboratoryWorkOptions( + results: AdvancedLaboratoryResults, + sourceSessions: ReadonlyMap, +): readonly LaboratoryOption[] { + const options: LaboratoryOption[] = []; + if (results.e31 && sourceSessions.has(results.e31.sourceSessionId)) { + options.push({ + id: "e31-source-binding", + label: "LAB E31 · source binding", + }); + } + if (results.e32 && sourceSessions.has(results.e32.sourceSessionId)) { + options.push({ + id: "e32-track-geometry", + label: "LAB E32 · TrackGeometry v1", + }); + } + if (results.e33 && sourceSessions.has(results.e33.sourceSessionId)) { + options.push({ + id: "e33-worker-shadow", + label: "LAB E33 · worker shadow 1×", + }); + } + if (results.e34) { + options.push({ + id: "e34-temporal-layer", + label: "LAB E34 · temporal occupied/unknown", + }); + } + return options; +} + +export function advancedLaboratorySourceSession( + workId: AdvancedLaboratoryWorkId, + results: AdvancedLaboratoryResults, + sourceSessions: ReadonlyMap, +): ObservationSessionSummary | null { + const sourceSessionId = workId === "e31-source-binding" + ? results.e31?.sourceSessionId + : workId === "e32-track-geometry" + ? results.e32?.sourceSessionId + : workId === "e33-worker-shadow" + ? results.e33?.sourceSessionId + : null; + return sourceSessionId ? sourceSessions.get(sourceSessionId) ?? null : null; +} + +export function AdvancedLaboratoryResult({ + props, + rigLabel, + workId, + results, + sourceSessions, + replayingSessionId, + failedSessionId, + replayError, +}: { + props: LaboratoryWorkspaceProps; + rigLabel: string; + workId: AdvancedLaboratoryWorkId; + results: AdvancedLaboratoryResults; + sourceSessions: ReadonlyMap; + replayingSessionId: string | null; + failedSessionId: string | null; + replayError: string | null; +}) { + if (workId === "e34-temporal-layer" && results.e34) { + return ; + } + + const sourceSession = advancedLaboratorySourceSession( + workId, + results, + sourceSessions, + ); + if (!sourceSession) return null; + const evidence = ( + + ); + if (workId === "e31-source-binding" && results.e31) { + return ( + + ); + } + if (workId === "e32-track-geometry" && results.e32) { + return ( + + ); + } + if (workId === "e33-worker-shadow" && results.e33) { + return ( + + ); + } + return null; +} diff --git a/apps/control-station/src/workspaces/laboratory/E34Result.tsx b/apps/control-station/src/workspaces/laboratory/E34Result.tsx new file mode 100644 index 0000000..1d1643f --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E34Result.tsx @@ -0,0 +1,236 @@ +import { useMemo, useState } from "react"; +import { Select } from "@nodedc/ui-react"; + +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import type { + E34TemporalLayerResult, + E34TemporalReviewFrame, +} from "../../core/laboratory/e34TemporalLayer"; +import { formatNumber } from "../../presentation"; +import { + E34TemporalLayerScene, + type E34TemporalViewMode, +} from "./E34TemporalLayerScene"; + +function percent(value: number): string { + return `${(value * 100).toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })}%`; +} + +function defaultFrame( + frames: readonly E34TemporalReviewFrame[], +): E34TemporalReviewFrame | null { + return frames.find((frame) => ( + frame.counts.current > 0 && frame.counts.held > 0 + )) ?? frames.find((frame) => frame.counts.expired > 0) ?? frames[0] ?? null; +} + +function frameLabel(frame: E34TemporalReviewFrame): string { + const state = frame.counts.expired + ? `истекло ${frame.counts.expired}` + : frame.counts.held + ? `удерживается ${frame.counts.held}` + : `наблюдается ${frame.counts.current}`; + return `Кадр ${formatNumber(frame.frameIndex, 0)} · ${state}`; +} + +function E34Evidence({ + result, +}: { + result: E34TemporalLayerResult; +}) { + const initial = useMemo( + () => defaultFrame(result.reviewFrames), + [result.reviewFrames], + ); + const [frameIndex, setFrameIndex] = useState(initial?.frameIndex ?? 0); + const [mode, setMode] = useState("3d"); + const [expanded, setExpanded] = useState(false); + const frame = result.reviewFrames.find( + (item) => item.frameIndex === frameIndex, + ) ?? initial; + + if (!frame) { + return ( +
+ Контрольные состояния временного слоя не опубликованы. +
+ ); + } + + return ( +
+ ({ + value: String(item.frameIndex), + label: frameLabel(item), + }))} + variant="split" + menuWidth="anchor" + onChange={(value) => setFrameIndex(Number(value))} + /> + )} + overlay={( +
+
+
Кадр / время
+
+ {formatNumber(frame.frameIndex, 0)} + {" · "} + {frame.sessionSeconds.toLocaleString("ru-RU", { + maximumFractionDigits: 3, + })} + {" с"} +
+
+
+
Вход
+
{frame.sourceAvailable ? "Есть текущие точки" : "Текущих точек нет"}
+
+
+
Состояние слоя
+
+ {frame.counts.current} current · {frame.counts.held} held · {frame.counts.expired} expired +
+
+
+ )} + > + +
+
+ ); +} + +export function E34Result({ + rigLabel, + result, +}: { + rigLabel: string; + result: E34TemporalLayerResult; +}) { + const metrics = result.metrics; + const config = result.configuration; + return ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/E34TemporalLayerScene.tsx b/apps/control-station/src/workspaces/laboratory/E34TemporalLayerScene.tsx new file mode 100644 index 0000000..4649cbe --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E34TemporalLayerScene.tsx @@ -0,0 +1,363 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button, Icon } from "@nodedc/ui-react"; +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; + +import type { + E34Point3, + E34TemporalComponent, + E34TemporalReviewFrame, +} from "../../core/laboratory/e34TemporalLayer"; + +export type E34TemporalViewMode = "3d" | "plan"; + +function tokenColor( + host: HTMLElement, + token: string, + fallback: readonly [number, number, number], +): THREE.Color { + const value = getComputedStyle(host).getPropertyValue(token).trim(); + if (value.startsWith("#")) { + return new THREE.Color(value); + } + const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number); + const [red, green, blue] = channels?.length === 3 + ? channels + : fallback; + return new THREE.Color(red / 255, green / 255, blue / 255); +} + +function disposeRenderable(object: THREE.Object3D): void { + const renderable = object as THREE.Object3D & { + geometry?: THREE.BufferGeometry; + material?: THREE.Material | THREE.Material[]; + }; + renderable.geometry?.dispose(); + const materials = Array.isArray(renderable.material) + ? renderable.material + : renderable.material + ? [renderable.material] + : []; + materials.forEach((material) => material.dispose()); +} + +function scenePoint( + point: E34Point3, + origin: E34Point3, +): readonly [number, number, number] { + return [ + point[0] - origin[0], + point[2] - origin[2], + -(point[1] - origin[1]), + ]; +} + +function positions( + points: readonly E34Point3[], + origin: E34Point3, +): Float32Array { + const result = new Float32Array(points.length * 3); + points.forEach((point, index) => { + const [x, y, z] = scenePoint(point, origin); + const offset = index * 3; + result[offset] = x; + result[offset + 1] = y; + result[offset + 2] = z; + }); + return result; +} + +function median(values: readonly number[]): number { + if (!values.length) return 0; + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)] ?? 0; +} + +function frameOrigin(frame: E34TemporalReviewFrame): E34Point3 { + const anchors = frame.components.length + ? frame.components.map((component) => component.centroidMapXyzM) + : frame.cellCentersMapXyzM; + return [ + median(anchors.map((point) => point[0])), + median(anchors.map((point) => point[1])), + median(anchors.map((point) => point[2])), + ]; +} + +function componentColor( + host: HTMLElement, + component: E34TemporalComponent, +): THREE.Color { + if (component.state === "expired") { + return tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]); + } + if (component.state === "held") { + return tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]); + } + return tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]); +} + +function fitRadius(frame: E34TemporalReviewFrame, origin: E34Point3): number { + const points = [ + ...frame.cellCentersMapXyzM, + ...frame.components.map((component) => component.centroidMapXyzM), + ]; + if (!points.length) return 4; + const distances = points + .map((point) => { + const [x, y, z] = scenePoint(point, origin); + return Math.hypot(x, y, z); + }) + .sort((left, right) => left - right); + const p95 = distances[Math.floor((distances.length - 1) * 0.95)] ?? 4; + return THREE.MathUtils.clamp(p95, 3, 32); +} + +export function E34TemporalLayerScene({ + frame, + mode, +}: { + frame: E34TemporalReviewFrame; + mode: E34TemporalViewMode; +}) { + const hostRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const contentRef = useRef(null); + const viewRadiusRef = useRef(5); + const [renderError, setRenderError] = useState(null); + const origin = useMemo(() => frameOrigin(frame), [frame]); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + powerPreference: "high-performance", + }); + } catch { + setRenderError("Браузер не смог создать 3D-сцену временного слоя."); + return; + } + + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setClearColor( + tokenColor(host, "--nodedc-canvas", [5, 5, 6]), + 1, + ); + renderer.domElement.setAttribute( + "aria-label", + "Интерактивная 3D-сцена временного occupied/unknown слоя E34", + ); + renderer.domElement.setAttribute("role", "img"); + host.prepend(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 500); + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.enablePan = true; + controls.enableZoom = true; + controls.screenSpacePanning = true; + controls.minDistance = 0.5; + controls.maxDistance = 160; + controls.target.set(0, 0, 0); + + const content = new THREE.Group(); + scene.add(content); + sceneRef.current = scene; + cameraRef.current = camera; + controlsRef.current = controls; + contentRef.current = content; + + const resize = () => { + const width = Math.max(host.clientWidth, 1); + const height = Math.max(host.clientHeight, 1); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.setSize(width, height, false); + }; + const observer = new ResizeObserver(resize); + observer.observe(host); + resize(); + + let animationFrame = 0; + const render = () => { + animationFrame = window.requestAnimationFrame(render); + controls.update(); + renderer.render(scene, camera); + }; + render(); + + return () => { + window.cancelAnimationFrame(animationFrame); + observer.disconnect(); + controls.dispose(); + scene.traverse(disposeRenderable); + renderer.dispose(); + renderer.domElement.remove(); + sceneRef.current = null; + cameraRef.current = null; + controlsRef.current = null; + contentRef.current = null; + }; + }, []); + + useEffect(() => { + const host = hostRef.current; + const content = contentRef.current; + if (!host || !content) return; + while (content.children.length) { + const child = content.children[0]; + if (!child) continue; + content.remove(child); + child.traverse(disposeRenderable); + } + + const contextGeometry = new THREE.BufferGeometry(); + contextGeometry.setAttribute( + "position", + new THREE.BufferAttribute( + positions(frame.cellCentersMapXyzM, origin), + 3, + ), + ); + const context = new THREE.Points( + contextGeometry, + new THREE.PointsMaterial({ + color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]), + size: 2.4, + sizeAttenuation: false, + transparent: true, + opacity: 0.32, + depthWrite: false, + }), + ); + content.add(context); + + for (const component of frame.components) { + const color = componentColor(host, component); + const markerGeometry = new THREE.BufferGeometry(); + markerGeometry.setAttribute( + "position", + new THREE.BufferAttribute( + positions([component.centroidMapXyzM], origin), + 3, + ), + ); + const marker = new THREE.Points( + markerGeometry, + new THREE.PointsMaterial({ + color, + size: component.state === "expired" ? 8 : 6, + sizeAttenuation: false, + transparent: true, + opacity: component.state === "expired" ? 0.72 : 1, + depthWrite: false, + }), + ); + content.add(marker); + + if (component.history.length > 1) { + const trailGeometry = new THREE.BufferGeometry(); + trailGeometry.setAttribute( + "position", + new THREE.BufferAttribute( + positions( + component.history.map((item) => item.centroidMapXyzM), + origin, + ), + 3, + ), + ); + content.add(new THREE.Line( + trailGeometry, + new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: component.state === "expired" ? 0.28 : 0.54, + }), + )); + } + } + + const radius = fitRadius(frame, origin); + viewRadiusRef.current = radius; + const grid = new THREE.GridHelper( + radius * 2.4, + 20, + tokenColor(host, "--nodedc-text-muted", [96, 99, 106]), + tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]), + ); + const materials = Array.isArray(grid.material) + ? grid.material + : [grid.material]; + materials.forEach((material) => { + material.transparent = true; + material.opacity = 0.15; + material.depthWrite = false; + }); + content.add(grid); + }, [frame, origin]); + + const resetView = () => { + const camera = cameraRef.current; + const controls = controlsRef.current; + if (!camera || !controls) return; + const radius = viewRadiusRef.current; + controls.target.set(0, 0, 0); + if (mode === "plan") { + camera.position.set(0, radius * 2.8, 0.001); + camera.up.set(0, 0, -1); + } else { + camera.position.set(radius * 1.35, radius * 0.9, radius * 1.35); + camera.up.set(0, 1, 0); + } + camera.near = Math.max(radius / 2_000, 0.005); + camera.far = Math.max(radius * 20, 120); + camera.updateProjectionMatrix(); + controls.maxDistance = Math.max(radius * 8, 40); + controls.update(); + }; + + useEffect(resetView, [frame, mode]); + + return ( +
+
+ {renderError ? ( +

{renderError}

+ ) : null} +
+
+ +
+ ЛКМ · вращение + Колесо · масштаб + ПКМ · панорама +
+
+
+ + Ячейки · {frame.cellCentersMapXyzM.length} + + Наблюдается · {frame.counts.current} + Удерживается · {frame.counts.held} + Истекло · {frame.counts.expired} +
+
+ ); +} diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index 2c4dfb2..9223c2b 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -29,9 +29,7 @@ import { } from "../../core/laboratory/e30Review"; import { fetchAdvancedLaboratoryResults, - type E31LaboratoryResult, - type E32LaboratoryResult, - type E33LaboratoryResult, + type AdvancedLaboratoryResults, } from "../../core/laboratory/advancedResults"; import { fetchLidarLocalSurfaces, @@ -41,14 +39,17 @@ import { formatNumber } from "../../presentation"; import { E30ReviewWorkspace } from "../E30ReviewWorkspace"; import { LidarQualityWorkspace } from "../LidarQualityWorkspace"; import type { WorkspaceRendererProps } from "../contracts"; -import { E31Result } from "./E31Result"; -import { E32Result } from "./E32Result"; -import { E33Result } from "./E33Result"; +import { + AdvancedLaboratoryResult, + advancedLaboratorySourceSession, + advancedLaboratoryWorkOptions, + isAdvancedLaboratoryWorkId, + type AdvancedLaboratoryWorkId, +} from "./AdvancedLaboratoryResult"; import { e28LaboratoryBrief, e29LaboratoryBrief, e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF, } from "./laboratoryArchiveBriefs"; -import { RecordedReplayEvidence } from "./RecordedReplayEvidence"; type LaboratoryWorkspaceProps = WorkspaceRendererProps & { SpatialView: ComponentType; }; @@ -58,11 +59,16 @@ type LaboratoryWorkId = | "e28-local-surface" | "e29-camera-geometry" | "e30-evidence-review" - | "e31-source-binding" - | "e32-track-geometry" - | "e33-worker-shadow" + | AdvancedLaboratoryWorkId | `session:${string}`; +const EMPTY_ADVANCED_RESULTS: AdvancedLaboratoryResults = { + e31: null, + e32: null, + e33: null, + e34: null, +}; + function digestFromContentId(value: string | null | undefined): string | null { const digest = value?.split("-").at(-1) ?? ""; return /^[a-f0-9]{64}$/.test(digest) ? digest : null; @@ -554,9 +560,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { const [e28Model, setE28Model] = useState(null); const [e29Result, setE29Result] = useState(null); const [e30Result, setE30Result] = useState(null); - const [e31Result, setE31Result] = useState(null); - const [e32Result, setE32Result] = useState(null); - const [e33Result, setE33Result] = useState(null); + const [advancedResults, setAdvancedResults] = useState( + EMPTY_ADVANCED_RESULTS, + ); const [evidenceLoading, setEvidenceLoading] = useState(true); const [evidenceError, setEvidenceError] = useState(null); const sessions = useObservationSessions({ @@ -598,14 +604,12 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { setE28Model(nextE28); setE29Result(nextE29); setE30Result(nextE30); - setE31Result(nextAdvanced?.e31 ?? null); - setE32Result(nextAdvanced?.e32 ?? null); - setE33Result(nextAdvanced?.e33 ?? null); + setAdvancedResults(nextAdvanced ?? EMPTY_ADVANCED_RESULTS); const failures = [ e28.status === "rejected" ? "E28" : null, e29.status === "rejected" ? "E29" : null, e30.status === "rejected" ? "E30" : null, - advanced.status === "rejected" ? "E31–E33" : null, + advanced.status === "rejected" ? "E31–E34" : null, ].filter(Boolean); setEvidenceError( failures.length @@ -651,32 +655,16 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { label: "LAB E30 · evidence review A2", }); } - if (e31Result && sourceSessions.has(e31Result.sourceSessionId)) { - items.push({ - id: "e31-source-binding", - label: "LAB E31 · source binding", - }); - } - if (e32Result && sourceSessions.has(e32Result.sourceSessionId)) { - items.push({ - id: "e32-track-geometry", - label: "LAB E32 · TrackGeometry v1", - }); - } - if (e33Result && sourceSessions.has(e33Result.sourceSessionId)) { - items.push({ - id: "e33-worker-shadow", - label: "LAB E33 · worker shadow 1×", - }); - } + items.push(...advancedLaboratoryWorkOptions( + advancedResults, + sourceSessions, + )); return items; }, [ + advancedResults, e28Model, e29Result, e30Result, - e31Result, - e32Result, - e33Result, sourceSessions, ]); const profiles = useMemo(() => { @@ -714,15 +702,6 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { const e30SourceSession = e30Result ? sourceSessions.get(e30Result.sourceSessionId) ?? null : null; - const e31SourceSession = e31Result - ? sourceSessions.get(e31Result.sourceSessionId) ?? null - : null; - const e32SourceSession = e32Result - ? sourceSessions.get(e32Result.sourceSessionId) ?? null - : null; - const e33SourceSession = e33Result - ? sourceSessions.get(e33Result.sourceSessionId) ?? null - : null; useEffect(() => { if ( @@ -786,13 +765,13 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { void sessions.replay(e30SourceSession.id); return; } - const advancedSession = next === "e31-source-binding" - ? e31SourceSession - : next === "e32-track-geometry" - ? e32SourceSession - : next === "e33-worker-shadow" - ? e33SourceSession - : null; + const advancedSession = isAdvancedLaboratoryWorkId(next) + ? advancedLaboratorySourceSession( + next, + advancedResults, + sourceSessions, + ) + : null; if (advancedSession) { void sessions.replay(advancedSession.id); } @@ -940,44 +919,16 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { result={e30Result} sourceSession={e30SourceSession} /> - ) : workId === "e31-source-binding" && e31Result && e31SourceSession ? ( - - )} - /> - ) : workId === "e32-track-geometry" && e32Result && e32SourceSession ? ( - - )} - /> - ) : workId === "e33-worker-shadow" && e33Result && e33SourceSession ? ( - - )} + workId={workId} + results={advancedResults} + sourceSessions={sourceSessions} + replayingSessionId={sessions.replayingSessionId} + failedSessionId={sessions.failedSessionId} + replayError={sessions.error} /> ) : selectedSession ? ( { server = await createServer({ appType: "custom", @@ -139,9 +222,9 @@ after(async () => { await server?.close(); }); -test("decodes E31, E32 and E33 from separate read-only catalogs", async () => { +test("decodes E31–E34 from separate read-only catalogs", async () => { const requests = []; - const items = [e31(), e32(), e33()]; + const items = [e31(), e32(), e33(), e34()]; const decoded = await fetchAdvancedLaboratoryResults({ fetcher: async (input, init) => { requests.push({ input: String(input), method: init?.method }); @@ -156,10 +239,13 @@ test("decodes E31, E32 and E33 from separate read-only catalogs", async () => { assert.equal(decoded.e33.metrics.deliveredFrames, 4489); assert.equal(decoded.e33.metrics.sourceAvailableFrames, 3928); assert.equal(decoded.e33.metrics.resultAgeMaxMs, 13.638); + assert.equal(decoded.e34.metrics.processedFrames, 4489); + assert.equal(decoded.e34.reviewFrames[0].components[0].state, "expired"); assert.deepEqual(requests, [ { input: "/api/v1/laboratory/e31/results?limit=1", method: "GET" }, { input: "/api/v1/laboratory/e32/results?limit=1", method: "GET" }, { input: "/api/v1/laboratory/e33/results?limit=1", method: "GET" }, + { input: "/api/v1/laboratory/e34/results?limit=1", method: "GET" }, ]); }); @@ -172,7 +258,8 @@ test("rejects authority escalation in an accepted-looking result", async () => { () => fetchAdvancedLaboratoryResults({ fetcher: async (input) => new Response(JSON.stringify(catalog( String(input).includes("/e31/") ? forged - : String(input).includes("/e32/") ? e32() : e33(), + : String(input).includes("/e32/") ? e32() + : String(input).includes("/e33/") ? e33() : e34(), )), { status: 200 }), }), AdvancedLaboratoryContractError, diff --git a/apps/control-station/test/laboratoryProductUi.test.mjs b/apps/control-station/test/laboratoryProductUi.test.mjs index 3b8120c..67ea21e 100644 --- a/apps/control-station/test/laboratoryProductUi.test.mjs +++ b/apps/control-station/test/laboratoryProductUi.test.mjs @@ -22,6 +22,14 @@ const e33ResultUrl = new URL( "../src/workspaces/laboratory/E33Result.tsx", import.meta.url, ); +const e34ResultUrl = new URL( + "../src/workspaces/laboratory/E34Result.tsx", + import.meta.url, +); +const advancedLaboratoryResultUrl = new URL( + "../src/workspaces/laboratory/AdvancedLaboratoryResult.tsx", + import.meta.url, +); const workspacesUrl = new URL( "../src/workspaces/Workspaces.tsx", import.meta.url, @@ -136,6 +144,23 @@ test("E33 explains the experiment, its method and its retained limits", async () assert.doesNotMatch(e33Source, /name: result\.e32ResultId/); }); +test("E34 keeps temporal evidence inside the canonical LAB and viewer contracts", async () => { + const [e34Source, advancedSource] = await Promise.all([ + readFile(e34ResultUrl, "utf8"), + readFile(advancedLaboratoryResultUrl, "utf8"), + ]); + + assert.match(e34Source, / { const workspacesSource = await readFile(workspacesUrl, "utf8"); diff --git a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md index 10be97e..1bb9f4b 100644 --- a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md +++ b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md @@ -681,10 +681,23 @@ workloads and are evidence of node visibility, not E33 process consumption. E33 qualifies downstream publication against the immutable E10→E32 chain; it does not rerun or independently requalify upstream inference. -Execution was strictly sequential through E33: E30 determined what E31 was +E34 accepted immutable temporal-layer result +`e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73`. +It consumes all `2,119,302` accepted E32 point rows, keeps current occupied +cells separate from held/expired unknown state and preserves the persistent +reconstruction. Peak state is 37 components and 324 cells per component; +component continuity is `82.15%`, geometry-only re-association is `81.78%`, +logical TTL emission delay is zero and coherent global map-frame jump count is +zero. The earlier rejected immutable run exposed and retained a `0.333 s` +source gap plus an incoherent nearest-neighbor jump detector; the fixed +implementation uses an independent TTL deadline and coherent translation +support without changing the frozen profile. + +Execution was strictly sequential through E34: E30 determined what E31 was allowed to change; E31 determined the E32 profile; E32 determined the E33 -runtime input. Exact accounting is now closed, so E34 and E35 are the active -critical path. +runtime input; E32/E33 then bound the E34 layer. Exact accounting and the +bounded nominal temporal layer are now closed, so E35 is the active critical +path. E36 is the first generalization gate. A separate product decision follows: either keep the result as operator/shadow evidence, or start L5 occupied-space integration. No LAB in this cycle can enable navigation, commands or safety diff --git a/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md b/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md index bdd9bb3..81b6559 100644 --- a/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md +++ b/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md @@ -192,7 +192,17 @@ p95 was `2.668 ms`, process RSS peaked at `92.34 MiB`, both queues remained bounded at depth two and the exact physical worker/container identity is retained. This qualifies the final TrackGeometry publication stage against the immutable upstream E10→E32 chain; it is not a new model-inference benchmark. -A8/E34+E35 is now the critical path. +A8/E34 is complete in immutable result +`e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73`. +It consumes all `2,119,302` accepted E32 point rows across all 4,489 frames, +keeps hit-only occupied evidence separate from persistent reconstruction, +publishes held/expired state as unknown, peaks at 37 active components and 324 +cells per component, and emits no free cells. Geometry-only re-association is +`81.78%`; overall component continuity is `82.15%`. Expiration uses an +independent `0.75 s` deadline and records the `0.283 s` worst source-gap +materialization delay separately. The first immutable run remains a rejected +artifact documenting the frame-clock and incoherent-jump implementation +failures. E35 is now the critical path. - [x] Reproduce all 4,489 immutable E29 frames with the exact frozen profile before applying E31/E30 changes. @@ -212,7 +222,7 @@ A8/E34+E35 is now the critical path. and publish per-frame health/timing plus resource telemetry. - [x] Bind the accepted result to physical worker `DESKTOP-OPJ8J04` and the pinned container image; independently verify all result artifacts. -- [ ] Build E34 as a separate short-TTL occupied/unknown temporal layer over +- [x] Build E34 as a separate short-TTL occupied/unknown temporal layer over accepted E32/E33 evidence. - [ ] Run E35 deterministic degradation/recovery variants without changing the immutable source or accepted E32/E33 results. diff --git a/docs/adr/0027-e34-short-ttl-occupied-unknown-layer.md b/docs/adr/0027-e34-short-ttl-occupied-unknown-layer.md new file mode 100644 index 0000000..5531743 --- /dev/null +++ b/docs/adr/0027-e34-short-ttl-occupied-unknown-layer.md @@ -0,0 +1,89 @@ +# ADR 0027 — E34 short-TTL occupied/unknown temporal layer + +Date: 2026-07-27 + +Status: accepted for source-scoped diagnostic/shadow use + +## Context + +E32 published exclusive, source-indexed `PointSlab` ownership over all 4,489 +immutable RAVNOVES00 frames. E33 proved that the exact E32 publication stream +can be delivered at recorded `1.0×` pace through bounded worker channels with +closed accounting. Neither result is a local temporal model: geometry-only +component keys are frame-local, current support disappears immediately when a +source is unavailable, and there is no explicit expiration event. + +The K1 stream is already registered mapped evidence. It does not retain the +beam origin and firing time required for honest ray-cleared free-space +mapping. The accepted persistent reconstruction must also remain a separate, +immutable derivative. + +## Predeclared decision + +1. E34 consumes only the accepted content-addressed E32 result under the + accepted E33 timing/accounting envelope. It never rewrites E32, E33, raw + evidence, the mapped stream or persistent reconstruction. +2. The output is a separate `lidar-local-map/v1` diagnostic derivative in the + E32 `map` frame. It publishes only hit-backed `occupied` and conservatively + aged `unknown` state. It never publishes `free`. +3. Current E32 point owners become `current/occupied` temporal components. + Camera track identity is retained only as source provenance. Geometry-only + components receive new temporal IDs and may be spatially re-associated + without inventing a semantic class. +4. A component without current accepted points becomes `held/unknown` for at + most `0.75 s`. Its last hit-backed cells remain explicitly stale evidence, + not current measurement and not free-space proof. +5. After the TTL, the component emits one `expired/unknown` tombstone and is + removed from the active occupied layer. Expired cells are never published + as occupied. +6. Geometry-only re-association is deterministic, one-to-one and bounded to a + `0.35 s` gap and `0.9 m` centroid distance. It additionally requires either + a `0.05` neighbor-expanded voxel overlap or a centroid distance no greater + than one `0.45 m` voxel. +7. The layer uses hit-only `0.45 m` voxels, at most `4,096` cells per component + and at most `256` active components. Crossing a bound fails closed; cells + are not silently truncated and components are not silently evicted. +8. A map-frame jump candidate is diagnostic only. It requires at least four + adjacent matched components within `0.25 s`, median displacement of at + least `1.5 m` and 25th-percentile displacement of at least `0.9 m`. +9. Acceptance requires complete 4,489-frame accounting, exact upstream + artifact identity, bounded state, no free-space publication, no component + held past TTL, expiry no later than one `0.25 s` observation interval after + the theoretical deadline and zero global map-frame jump candidates. +10. Continuity, re-association, geometry-only persistence and ghost lifetime + are measured and reported. They are not converted into detector accuracy + or ground truth and are not retuned after inspecting this run. +11. Dynamic class, traversability, planner input, commands, navigation and + safety authority remain unavailable. + +## Consequences + +- The E34 result can prove a bounded temporal contract without claiming + free-space quality or changing persistent mapping. +- Camera-only, conflict, unknown and source-unavailable inputs can preserve + uncertainty but cannot create new occupied cells without prior accepted + hit-backed provenance. +- A rejected run remains immutable evidence. Any later profile change requires + a new ADR decision, profile identity and LAB result rather than overwriting + E34. + +## Execution outcome + +The first immutable implementation result +`e34-temporal-occupied-1bab293c1867100e172923b189f8273c47eacca3dd179fd54573200906affd7c` +was rejected without changing the profile. It exposed two implementation-level +architecture errors: + +- scalar nearest-neighbor displacement was not sufficient to call a jump + global; the corrected detector requires one coherent translation within the + already fixed voxel residual; +- frame-driven expiry could not meet the TTL across a recorded `0.333 s` + source gap; expiration now uses an independent logical layer deadline and + retains the later replay materialization time separately. + +The accepted result is +`e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73`. +It processes all 4,489 frames and all 2,119,302 E32 point rows, peaks at 37 +active components and 324 cells per component, emits zero free cells, meets +the `0.75 s` TTL exactly and reports zero coherent global map-frame jump +candidates. A8 now continues with E35 deterministic degradation/recovery. diff --git a/experiments/perception/LAB_E34_REPORT_2026-07-27.md b/experiments/perception/LAB_E34_REPORT_2026-07-27.md new file mode 100644 index 0000000..cedc13e --- /dev/null +++ b/experiments/perception/LAB_E34_REPORT_2026-07-27.md @@ -0,0 +1,182 @@ +# LAB E34 — bounded short-TTL occupied/unknown temporal layer + +Date: 2026-07-27 +Status: accepted for source-scoped diagnostic/shadow use; dynamic class, free +space, planner, navigation, safety and command authority are unavailable +Immutable result: +`e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73` + +## Objective and architecture stage + +E34 closes the occupied-layer half of A8. It asks whether accepted E32 +hit-backed geometry can form a bounded local temporal model without changing +raw/map evidence or polluting persistent reconstruction. + +The result is a separate `lidar-local-map/short-ttl-hit-only/v1` derivative. It makes +`current`, `held` and `expired` states explicit, spatially re-associates +frame-local geometry-only components, preserves camera identity only as source +provenance and never converts missing mapped points into free space. + +## Immutable inputs and predeclared profile + +- E32: + `e32-track-geometry-a14ca0e7fb3850ca0dfa3c41634e1b490a2d58ab74d101afc6d6921fbdb0e6fd`. +- Accepted E33 timing/accounting envelope: + `e33-worker-shadow-05cc0bb264410fd49536df90e94067ac39731aff0322a8873700d40008a8bb3a`. +- Source: RAVNOVES00 / `20260720T065719Z_viewer_live`. +- Frames: `4,489`. +- Timeline: `35.421857292–484.044857292 s`. +- Coordinate frame: accepted E32 `map`. +- Profile: + `missioncore.e34-temporal-occupied-profile/v1`, + `e34-short-ttl-hit-only-occupied-unknown/v1`. + +The profile and ADR 0027 were written before the first full replay: + +- hit-only voxel size `0.45 m`; +- occupied TTL `0.75 s`; +- maximum `256` active components; +- maximum `4,096` cells per component; +- geometry-only re-association gap `0.35 s`; +- centroid gate `0.9 m`; +- neighbor-expanded voxel overlap minimum `0.05`; +- no free space, dynamic class or persistent-map mutation; +- navigation, safety and command authority false. + +No threshold or bound was changed after inspecting either run. + +## Method and algorithms + +For every E32 frame the E34 layer: + +1. reconstructs the exact validated `TrackGeometryFrame` and memory-mapped + `PointSlab`; +2. consumes only geometries whose metric basis is `current-points`; +3. voxelizes their exclusive map-frame point rows at `0.45 m`; +4. preserves an exact camera owner across frames without transferring semantic + ownership to E34; +5. re-associates geometry-only observations one-to-one using age, centroid and + neighbor-expanded voxel overlap; +6. publishes current hit-backed components as `current/occupied`; +7. publishes missing but not expired prior evidence as `held/unknown`; +8. emits an `expired/unknown` tombstone on the independent layer deadline and + removes the cells from active occupancy; +9. checks a global map jump only when at least four components support one + coherent translation; scalar nearest-neighbor distance alone is + insufficient; +10. records compact cell rows, complete per-frame state, track-history tails, + expiration events, review samples, metrics, acceptance and artifact + digests. + +Bounds fail closed. Components and cells are never silently truncated or +evicted. Unknown camera-only/conflict/held observations without current E32 +points cannot create occupied cells. + +## Failed first run and architecture corrections + +The first immutable result +`e34-temporal-occupied-1bab293c1867100e172923b189f8273c47eacca3dd179fd54573200906affd7c` +was rejected by two predeclared gates: + +- seven scalar nearest-neighbor map-jump candidates; +- maximum frame-driven expiry delay `0.283 s`, above the `0.25 s` gate. + +The result was not overwritten and the profile was not relaxed. + +Inspection showed that the jump implementation did not yet implement the +word “global”: the flagged component displacement directions were +incoherent, and frames 1762/3190 contained an exact camera anchor moving only +`0.18–0.24 m`. The implementation was corrected to require a single coherent +translation with residual no larger than the already fixed `0.45 m` voxel. + +The expiry failure exposed a real source gap: frame 1732 at +`208.513857292 s` is followed by frame 1733 at `208.846857292 s`. A +frame-driven state machine cannot emit a `0.75 s` TTL event during that +`0.333 s` gap. Expiration was therefore moved to an independent logical layer +deadline. The exact event time is retained separately from the later replay +materialization time. This is an architecture correction, not a larger TTL. + +## Accepted result + +Result: +`e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73`. + +| Measure | Result | Gate | +| --- | ---: | ---: | +| processed frames | 4,489 / 4,489 | complete | +| E32 point rows consumed | 2,119,302 / 2,119,302 | exact | +| peak active components | 37 | ≤ 256 | +| peak cells/component | 324 | ≤ 4,096 | +| current / held cell rows | 834,478 / 531,581 | hit-backed / stale-unknown | +| free cell rows | 0 | 0 | +| component continuity | 82.15% | descriptive, not GT | +| geometry-only re-association | 81.78% | descriptive, not GT | +| components with geometry re-association | 2,366 | descriptive | +| maximum observed component span | 13.394 s | descriptive | +| maximum held age | 0.750 s | ≤ 0.750 s | +| logical expiry delay | 0.000 s | ≤ 0.250 s | +| maximum replay materialization delay | 0.283 s | retained source-gap evidence | +| coherent global map-jump candidates | 0 | 0 | +| frame processing p95 | 1.483 ms | diagnostic runtime | + +The layer created 4,912 temporal components: 1,027 camera-provenance and 3,885 +geometry-only. It recorded 5,168 exact camera associations, 17,433 spatial +geometry re-associations, 39,664 held publications and 4,895 expiration +events. The peak active state remained 37 components. + +All predeclared acceptance requirements pass. Raw/map evidence, E32/E33 and +persistent reconstruction retain their exact artifact identities. + +## Product materialization + +The accepted result is exposed through the path-free read-only endpoint +`GET /api/v1/laboratory/e34/results?limit=1` and through a separate +`LAB E34 · temporal occupied/unknown` entry in the laboratory contour. + +The UI uses the fixed `missioncore.laboratory-report/v1` anatomy: + +- compact human-readable task, method, principal result and limitation; +- a domain 3D viewer with the admitted 3D/plan and fullscreen controls; +- 27 bounded review checkpoints for current, held and expired states; +- explicit current/held/expired counts, source availability and session time; +- a fixed result block with proved, not proved and decision statements. + +The central laboratory workspace did not gain another experiment-specific +layout branch. E31–E34 discovery and rendering are isolated in the advanced +laboratory feature registry; the E34 visualizer is a domain renderer inside +the existing laboratory evidence surface. No new generic Design Guideline +component or visual language was introduced. + +## Interpretation and limitations + +E34 proves: + +- bounded current/held/expired occupied/unknown state for this source; +- lossless consumption of accepted E32 point rows; +- deterministic geometry-only temporal identity; +- independent TTL expiry; +- no hidden free-space claim or persistent-map write; +- enough compact evidence to inspect history and expiration. + +E34 does not prove: + +- detector accuracy or human ground truth; +- a dynamic/static class; +- ray-cleared free space, traversability, TSDF or ESDF; +- second-source generalization; +- correct behavior under injected source loss, delay, drops or offset; +- planner, navigation, safety or command authority. + +The `0.283 s` replay materialization gap is deliberately retained. E35 must +exercise source-independent deadlines and recovery under deterministic loss, +staleness, delay, bounded drops and timing offsets. + +## Decision and next stage + +E34 is accepted as a source-scoped diagnostic/shadow temporal occupied/unknown +layer. A8 remains open only for E35. + +The next critical-path work is E35 deterministic degradation and recovery +using immutable derived replay variants. Missing evidence must become +camera-only, geometry-only, stale or unknown; it must never become guessed +class, false free space or hidden success. diff --git a/experiments/perception/e34_temporal_occupied_layer_profile.json b/experiments/perception/e34_temporal_occupied_layer_profile.json new file mode 100644 index 0000000..82d6bc4 --- /dev/null +++ b/experiments/perception/e34_temporal_occupied_layer_profile.json @@ -0,0 +1,45 @@ +{ + "schema_version": "missioncore.e34-temporal-occupied-profile/v1", + "profile_id": "e34-short-ttl-hit-only-occupied-unknown/v1", + "expected_e32_result_id": "e32-track-geometry-a14ca0e7fb3850ca0dfa3c41634e1b490a2d58ab74d101afc6d6921fbdb0e6fd", + "expected_e33_result_id": "e33-worker-shadow-05cc0bb264410fd49536df90e94067ac39731aff0322a8873700d40008a8bb3a", + "layer": { + "coordinate_frame": "map", + "voxel_size_m": 0.45, + "occupied_ttl_seconds": 0.75, + "maximum_active_components": 256, + "maximum_cells_per_component": 4096 + }, + "association": { + "geometry_maximum_gap_seconds": 0.35, + "geometry_maximum_centroid_distance_m": 0.9, + "geometry_minimum_voxel_overlap_fraction": 0.05, + "geometry_neighbor_radius_cells": 1 + }, + "map_frame_jump": { + "maximum_adjacent_gap_seconds": 0.25, + "minimum_matched_components": 4, + "minimum_median_displacement_m": 1.5, + "minimum_p25_displacement_m": 0.9 + }, + "acceptance": { + "maximum_expiry_delay_seconds": 0.25, + "maximum_map_frame_jump_candidates": 0, + "require_complete_frame_accounting": true, + "require_exact_upstream_artifact_identity": true, + "require_no_free_space_publication": true, + "require_bounded_state": true + }, + "policy": { + "hit_only_occupied": true, + "absence_of_points_means_free": false, + "expired_components_remain_occupied": false, + "dynamic_class_available": false, + "free_space_available": false, + "persistent_reconstruction_mutation_allowed": false + }, + "authority": { + "commands_enabled": false, + "navigation_or_safety_accepted": false + } +} diff --git a/experiments/perception/run_e34_temporal_occupied_layer.py b/experiments/perception/run_e34_temporal_occupied_layer.py new file mode 100644 index 0000000..2704efd --- /dev/null +++ b/experiments/perception/run_e34_temporal_occupied_layer.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Build the immutable E34 short-TTL occupied/unknown temporal layer.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from k1link.compute.e34_temporal_occupied_replay import ( + build_e34_temporal_occupied_replay, +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--e32-result", type=Path, required=True) + parser.add_argument("--e33-result", type=Path, required=True) + parser.add_argument("--profile", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + args = parser.parse_args() + result = build_e34_temporal_occupied_replay( + e32_result_root=args.e32_result, + e33_result_root=args.e33_result, + profile_path=args.profile, + output_root=args.output_root, + ) + print( + json.dumps( + { + "result_id": result.result_id, + "result_root": str(result.result_root), + "accepted": result.accepted, + "metrics": result.report["metrics"], + "rejection_reasons": result.report["acceptance"][ + "rejection_reasons" + ], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 0 if result.accepted else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/k1link/compute/e34_temporal_occupied_replay.py b/src/k1link/compute/e34_temporal_occupied_replay.py new file mode 100644 index 0000000..8bbedb3 --- /dev/null +++ b/src/k1link/compute/e34_temporal_occupied_replay.py @@ -0,0 +1,1099 @@ +"""Immutable E34 replay into a bounded short-TTL occupied/unknown layer.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import math +import os +import re +import shutil +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final + +import numpy as np + +from k1link.artifacts import utc_now_iso + +from .e32_track_geometry_replay import ( + E32TrackGeometryReplay, + read_e32_track_geometry_replay, +) +from .e32_track_geometry_storage import ( + E32TrackGeometryStorageError, + frame_from_record, + load_point_storage, + record, +) +from .e33_worker_shadow import ( + E33WorkerShadowResult, + read_e33_worker_shadow_result, +) +from .temporal_occupied_layer import ( + TEMPORAL_COMPONENT_SCHEMA, + TEMPORAL_FRAME_SCHEMA, + TemporalLayerConfig, + TemporalOccupiedLayer, + TemporalOccupiedLayerError, +) +from .track_geometry import TrackGeometrySourceBinding + +E34_PROFILE_SCHEMA: Final = "missioncore.e34-temporal-occupied-profile/v1" +E34_RESULT_SCHEMA: Final = "missioncore.e34-temporal-occupied-result/v1" +E34_REPORT_SCHEMA: Final = "missioncore.e34-temporal-occupied-report/v1" +E34_REVIEW_SCHEMA: Final = "missioncore.e34-temporal-review-timeline/v1" +E34_CELL_STORAGE_SCHEMA: Final = "missioncore.e34-temporal-cell-storage/v1" + +E34_FRAMES_NAME: Final = "temporal-occupied-frames.jsonl" +E34_CELLS_NAME: Final = "temporal-cell-coordinates-map-i32.npy" +E34_REVIEW_NAME: Final = "review-timeline.json" +E34_REPORT_NAME: Final = "run-report.json" +E34_MANIFEST_NAME: Final = "manifest.json" + +_E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$") +_E33_RESULT_ID = re.compile(r"^e33-worker-shadow-[a-f0-9]{64}$") +_E34_RESULT_ID = re.compile(r"^e34-temporal-occupied-[a-f0-9]{64}$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") + + +class E34TemporalOccupiedReplayError(RuntimeError): + """The E34 profile, input, replay or immutable result is invalid.""" + + +@dataclass(frozen=True, slots=True) +class E34TemporalOccupiedReplay: + """One fully validated immutable E34 result.""" + + result_root: Path + result_id: str + manifest: dict[str, Any] + report: dict[str, Any] + review: dict[str, Any] + + @property + def accepted(self) -> bool: + return bool(self.report["acceptance"]["accepted"]) + + +def build_e34_temporal_occupied_replay( + *, + e32_result_root: Path, + e33_result_root: Path, + profile_path: Path, + output_root: Path, +) -> E34TemporalOccupiedReplay: + """Build or verify one full E34 replay without mutating accepted inputs.""" + + e32 = read_e32_track_geometry_replay(e32_result_root) + e33 = read_e33_worker_shadow_result(e33_result_root) + profile, config = read_e34_temporal_profile(profile_path) + _validate_upstream(e32=e32, e33=e33, profile=profile) + e32_artifacts = _verified_artifacts( + e32.result_root, + e32.manifest.get("artifacts"), + role_key="role", + ) + upstream_before = { + "e32": _artifact_identity(e32_artifacts), + "e33": _artifact_identity( + _verified_artifacts( + e33.result_root, + e33.result.get("artifacts"), + role_key="kind", + ) + ), + } + e32_identity = _object(e32.manifest.get("identity"), "E32 identity") + binding = TrackGeometrySourceBinding.from_dict( + e32_identity.get("track_geometry_binding") + ) + if binding.coordinate_frame != "map": + raise E34TemporalOccupiedReplayError( + "E34 requires the accepted E32 map frame" + ) + identity = { + "schema_version": E34_RESULT_SCHEMA, + "pipeline": "lidar-local-map/short-ttl-hit-only/v1", + "profile": profile, + "profile_sha256": _sha256(profile_path.resolve(strict=True)), + "e32_result_id": e32.result_id, + "e32_identity_sha256": e32.manifest["identity_sha256"], + "e33_result_id": e33.result_id, + "e33_identity_sha256": e33.result["identity_sha256"], + "source_session_id": binding.source_session_id, + "coordinate_frame": binding.coordinate_frame, + "frame_count": e32_identity["frame_count"], + "timeline_start_seconds": e32_identity["timeline_start_seconds"], + "timeline_end_seconds": e32_identity["timeline_end_seconds"], + "upstream_artifacts": upstream_before, + "producer": { + "e34_replay_sha256": _sha256(Path(__file__)), + "temporal_layer_sha256": _sha256( + Path(__file__).with_name("temporal_occupied_layer.py") + ), + }, + "policy": copy.deepcopy(profile["policy"]), + "authority": _authority(), + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + result_id = f"e34-temporal-occupied-{identity_sha256}" + destination = output_root.expanduser().absolute() + destination.mkdir(mode=0o700, parents=True, exist_ok=True) + result_root = destination / result_id + if result_root.exists(): + return read_e34_temporal_occupied_replay(result_root) + staging = destination / f".{result_id}.{os.getpid()}.incomplete" + staging.mkdir(mode=0o700, exist_ok=False) + try: + report, review = _run_replay( + staging=staging, + result_id=result_id, + identity=identity, + e32=e32, + e32_artifacts=e32_artifacts, + config=config, + ) + upstream_after = { + "e32": _artifact_identity(e32_artifacts), + "e33": _artifact_identity( + _verified_artifacts( + e33.result_root, + e33.result.get("artifacts"), + role_key="kind", + ) + ), + } + upstream_unchanged = upstream_after == upstream_before + report = _finalize_report( + report, + profile=profile, + upstream_unchanged=upstream_unchanged, + ) + _write_json(staging / E34_REVIEW_NAME, review) + _write_json(staging / E34_REPORT_NAME, report) + artifacts = [ + _artifact( + staging / E34_FRAMES_NAME, + "temporal-occupied-frames", + "application/x-ndjson", + TEMPORAL_FRAME_SCHEMA, + ), + _artifact( + staging / E34_CELLS_NAME, + "temporal-cell-coordinates-map-i32", + "application/x-npy", + E34_CELL_STORAGE_SCHEMA, + ), + _artifact( + staging / E34_REVIEW_NAME, + "review-timeline", + "application/json", + E34_REVIEW_SCHEMA, + ), + _artifact( + staging / E34_REPORT_NAME, + "run-report", + "application/json", + E34_REPORT_SCHEMA, + ), + ] + manifest = { + "schema_version": E34_RESULT_SCHEMA, + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": report["created_at_utc"], + "acceptance_state": ( + "accepted-bounded-occupied-unknown-temporal-layer" + if report["acceptance"]["accepted"] + else "rejected-fail-closed" + ), + "publication_scope": "diagnostic-shadow-only", + "ground_truth": False, + "artifacts": artifacts, + "authority": _authority(), + } + _write_json(staging / E34_MANIFEST_NAME, manifest) + os.replace(staging, result_root) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + return read_e34_temporal_occupied_replay(result_root) + + +def read_e34_temporal_occupied_replay( + root: Path, +) -> E34TemporalOccupiedReplay: + """Read and strictly validate one immutable E34 result.""" + + resolved = root.expanduser().resolve(strict=True) + if not resolved.is_dir() or _E34_RESULT_ID.fullmatch(resolved.name) is None: + raise E34TemporalOccupiedReplayError("E34 result root is invalid") + manifest = _read_json(resolved / E34_MANIFEST_NAME) + identity = _object(manifest.get("identity"), "E34 identity") + identity_sha256 = manifest.get("identity_sha256") + if ( + manifest.get("schema_version") != E34_RESULT_SCHEMA + or manifest.get("result_id") != resolved.name + or not isinstance(identity_sha256, str) + or _SHA256.fullmatch(identity_sha256) is None + or resolved.name != f"e34-temporal-occupied-{identity_sha256}" + or hashlib.sha256(_canonical_json(identity)).hexdigest() + != identity_sha256 + or manifest.get("publication_scope") != "diagnostic-shadow-only" + or manifest.get("ground_truth") is not False + or manifest.get("authority") != _authority() + ): + raise E34TemporalOccupiedReplayError( + "E34 result identity is inconsistent" + ) + artifacts = _verified_artifacts( + resolved, + manifest.get("artifacts"), + role_key="kind", + ) + report = _read_json(artifacts["run-report"]) + review = _read_json(artifacts["review-timeline"]) + accepted = _boolean( + _object(report.get("acceptance"), "E34 acceptance").get("accepted"), + "E34 accepted", + ) + if ( + report.get("schema_version") != E34_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or report.get("identity") != identity + or review.get("schema_version") != E34_REVIEW_SCHEMA + or review.get("result_id") != resolved.name + or manifest.get("acceptance_state") + != ( + "accepted-bounded-occupied-unknown-temporal-layer" + if accepted + else "rejected-fail-closed" + ) + ): + raise E34TemporalOccupiedReplayError( + "E34 report or review identity is inconsistent" + ) + frame_count = _positive_int(identity.get("frame_count"), "E34 frame count") + try: + cells = np.load( + artifacts["temporal-cell-coordinates-map-i32"], + allow_pickle=False, + mmap_mode="r", + ) + except (OSError, ValueError) as exc: + raise E34TemporalOccupiedReplayError( + "E34 cell storage is unreadable" + ) from exc + if ( + cells.dtype != np.dtype("= frame_count: + raise E34TemporalOccupiedReplayError( + "E34 frame stream has extra rows" + ) + value = _json_line(line, "E34 frame") + if ( + value.get("schema_version") != TEMPORAL_FRAME_SCHEMA + or value.get("frame_index") != expected_frame_index + or value.get("authority") != _authority() + or _object(value.get("policy"), "E34 frame policy").get( + "free_space_available" + ) + is not False + ): + raise E34TemporalOccupiedReplayError( + "E34 frame contract changed" + ) + active_ids: set[int] = set() + for key, expected_state, expected_occupancy in ( + ("current", "current", "occupied"), + ("held", "held", "unknown"), + ): + for component in _array(value.get(key), f"E34 {key}"): + document = _object(component, "E34 temporal component") + temporal_id = _positive_int( + document.get("temporal_id"), + "E34 temporal component ID", + ) + start = _nonnegative_int( + document.get("cell_row_start"), + "E34 cell row start", + ) + count = _positive_int( + document.get("cell_row_count"), + "E34 cell row count", + ) + if ( + document.get("schema_version") + != TEMPORAL_COMPONENT_SCHEMA + or document.get("state") != expected_state + or document.get("occupancy_state") + != expected_occupancy + or document.get("authority") != _authority() + or temporal_id in active_ids + or start != row_cursor + or start + count > cells.shape[0] + ): + raise E34TemporalOccupiedReplayError( + "E34 active component contract changed" + ) + active_ids.add(temporal_id) + row_cursor += count + for component in _array(value.get("expired"), "E34 expired"): + document = _object(component, "E34 expired component") + temporal_id = _positive_int( + document.get("temporal_id"), + "E34 expired component ID", + ) + if ( + document.get("schema_version") + != TEMPORAL_COMPONENT_SCHEMA + or document.get("state") != "expired" + or document.get("occupancy_state") != "unknown" + or document.get("cell_row_count") != 0 + or temporal_id in active_ids + or document.get("authority") != _authority() + ): + raise E34TemporalOccupiedReplayError( + "E34 expiry contract changed" + ) + observed_frames += 1 + if observed_frames != frame_count or row_cursor != cells.shape[0]: + raise E34TemporalOccupiedReplayError( + "E34 frame or cell accounting is incomplete" + ) + return E34TemporalOccupiedReplay( + result_root=resolved, + result_id=resolved.name, + manifest=manifest, + report=report, + review=review, + ) + + +def read_e34_temporal_profile( + path: Path, +) -> tuple[dict[str, Any], TemporalLayerConfig]: + """Read the predeclared E34 profile and construct its bounded config.""" + + profile = _read_json(path.resolve(strict=True)) + if ( + set(profile) + != { + "schema_version", + "profile_id", + "expected_e32_result_id", + "expected_e33_result_id", + "layer", + "association", + "map_frame_jump", + "acceptance", + "policy", + "authority", + } + or profile.get("schema_version") != E34_PROFILE_SCHEMA + or not isinstance(profile.get("profile_id"), str) + or not isinstance(profile.get("expected_e32_result_id"), str) + or _E32_RESULT_ID.fullmatch(profile["expected_e32_result_id"]) is None + or not isinstance(profile.get("expected_e33_result_id"), str) + or _E33_RESULT_ID.fullmatch(profile["expected_e33_result_id"]) is None + or profile.get("authority") != _authority() + ): + raise E34TemporalOccupiedReplayError("E34 profile identity is invalid") + layer = _object(profile.get("layer"), "E34 layer profile") + association = _object(profile.get("association"), "E34 association") + jump = _object(profile.get("map_frame_jump"), "E34 map-frame jump") + acceptance = _object(profile.get("acceptance"), "E34 acceptance") + policy = _object(profile.get("policy"), "E34 policy") + if ( + layer.get("coordinate_frame") != "map" + or policy + != { + "hit_only_occupied": True, + "absence_of_points_means_free": False, + "expired_components_remain_occupied": False, + "dynamic_class_available": False, + "free_space_available": False, + "persistent_reconstruction_mutation_allowed": False, + } + or acceptance.get("require_complete_frame_accounting") is not True + or acceptance.get("require_exact_upstream_artifact_identity") is not True + or acceptance.get("require_no_free_space_publication") is not True + or acceptance.get("require_bounded_state") is not True + ): + raise E34TemporalOccupiedReplayError( + "E34 conservative policy changed" + ) + try: + config = TemporalLayerConfig( + voxel_size_m=_positive_float( + layer.get("voxel_size_m"), + "E34 voxel size", + ), + occupied_ttl_seconds=_positive_float( + layer.get("occupied_ttl_seconds"), + "E34 occupied TTL", + ), + maximum_active_components=_positive_int( + layer.get("maximum_active_components"), + "E34 active-component bound", + ), + maximum_cells_per_component=_positive_int( + layer.get("maximum_cells_per_component"), + "E34 component cell bound", + ), + geometry_maximum_gap_seconds=_positive_float( + association.get("geometry_maximum_gap_seconds"), + "E34 geometry gap", + ), + geometry_maximum_centroid_distance_m=_positive_float( + association.get("geometry_maximum_centroid_distance_m"), + "E34 geometry distance", + ), + geometry_minimum_voxel_overlap_fraction=_positive_float( + association.get("geometry_minimum_voxel_overlap_fraction"), + "E34 geometry overlap", + ), + geometry_neighbor_radius_cells=_positive_int( + association.get("geometry_neighbor_radius_cells"), + "E34 geometry neighbor radius", + ), + jump_maximum_adjacent_gap_seconds=_positive_float( + jump.get("maximum_adjacent_gap_seconds"), + "E34 jump frame gap", + ), + jump_minimum_matched_components=_positive_int( + jump.get("minimum_matched_components"), + "E34 jump support", + ), + jump_minimum_median_displacement_m=_positive_float( + jump.get("minimum_median_displacement_m"), + "E34 jump median", + ), + jump_minimum_p25_displacement_m=_positive_float( + jump.get("minimum_p25_displacement_m"), + "E34 jump p25", + ), + ) + except TemporalOccupiedLayerError as exc: + raise E34TemporalOccupiedReplayError(str(exc)) from exc + _positive_float( + acceptance.get("maximum_expiry_delay_seconds"), + "E34 maximum expiry delay", + ) + _nonnegative_int( + acceptance.get("maximum_map_frame_jump_candidates"), + "E34 maximum map-frame jump candidates", + ) + return profile, config + + +def _run_replay( + *, + staging: Path, + result_id: str, + identity: dict[str, Any], + e32: E32TrackGeometryReplay, + e32_artifacts: dict[str, Path], + config: TemporalLayerConfig, +) -> tuple[dict[str, Any], dict[str, Any]]: + e32_identity = _object(e32.manifest.get("identity"), "E32 identity") + frame_count = _positive_int(e32_identity.get("frame_count"), "E32 frame count") + try: + offsets, source_indices, points, owner_indices = load_point_storage( + e32_artifacts, + frame_count=frame_count, + ) + except E32TrackGeometryStorageError as exc: + raise E34TemporalOccupiedReplayError(str(exc)) from exc + binding = TrackGeometrySourceBinding.from_dict( + e32_identity.get("track_geometry_binding") + ) + layer = TemporalOccupiedLayer(config) + cell_chunks: list[np.ndarray[Any, np.dtype[np.int32]]] = [] + processing_ms: list[float] = [] + checkpoint_indices = { + int(round(value)) + for value in np.linspace(0, frame_count - 1, num=9) + } + checkpoints: list[dict[str, Any]] = [] + jump_reviews: list[dict[str, Any]] = [] + event_candidates: list[tuple[float, int, dict[str, Any]]] = [] + global_cell_offset = 0 + started = time.perf_counter() + observed_frames = 0 + with ( + e32_artifacts["track-geometry-frames"].open( + "r", + encoding="utf-8", + ) as source, + (staging / E34_FRAMES_NAME).open( + "x", + encoding="utf-8", + newline="\n", + ) as target, + ): + for frame_index, line in enumerate(source): + if frame_index >= frame_count: + raise E34TemporalOccupiedReplayError( + "E32 frame stream has extra rows" + ) + frame_started = time.perf_counter() + value = record(line, expected_frame_index=frame_index) + try: + frame = frame_from_record( + record_value=value, + binding=binding, + frame_offsets=offsets, + source_indices=source_indices, + points=points, + owner_indices=owner_indices, + ) + projection = layer.update(frame) + except ( + E32TrackGeometryStorageError, + TemporalOccupiedLayerError, + ) as exc: + raise E34TemporalOccupiedReplayError(str(exc)) from exc + document = projection.document + for key in ("current", "held"): + for component in document[key]: + component["cell_row_start"] = ( + int(component["cell_row_start"]) + global_cell_offset + ) + document["cell_storage"] = { + "schema_version": E34_CELL_STORAGE_SCHEMA, + "coordinate_frame": "map", + "voxel_size_m": config.voxel_size_m, + "frame_row_start": global_cell_offset, + "frame_row_count": int(projection.cell_rows.shape[0]), + } + _write_jsonl(target, document) + cells = np.asarray(projection.cell_rows, dtype=" 0.0: + event_candidates.append((event_score, frame_index, review_frame)) + event_candidates.sort(key=lambda item: (-item[0], item[1])) + del event_candidates[18:] + processing_ms.append((time.perf_counter() - frame_started) * 1_000.0) + observed_frames += 1 + if observed_frames != frame_count: + raise E34TemporalOccupiedReplayError("E32 frame stream is incomplete") + all_cells = ( + np.concatenate(cell_chunks, axis=0).astype(" dict[str, Any]: + result = copy.deepcopy(report) + metrics = _object(result.get("metrics"), "E34 metrics") + frames = _object(metrics.get("frames"), "E34 frame metrics") + components = _object(metrics.get("components"), "E34 component metrics") + occupancy = _object(metrics.get("occupancy"), "E34 occupancy metrics") + aging = _object(metrics.get("aging"), "E34 aging metrics") + map_frame = _object(metrics.get("map_frame"), "E34 map-frame metrics") + layer = _object(profile.get("layer"), "E34 layer profile") + acceptance = _object(profile.get("acceptance"), "E34 acceptance profile") + requirements = { + "complete_frame_accounting": ( + frames.get("source") == frames.get("processed") + ), + "exact_e32_point_consumption": ( + occupancy.get("e32_current_point_rows") + == occupancy.get("e34_consumed_current_point_rows") + ), + "active_component_bound": ( + components.get("peak_active") + <= layer.get("maximum_active_components") + ), + "component_cell_bound": ( + occupancy.get("peak_cells_per_component") + <= layer.get("maximum_cells_per_component") + ), + "held_age_within_ttl": ( + aging.get("maximum_held_age_seconds") + <= float(layer["occupied_ttl_seconds"]) + 1e-9 + ), + "expiry_delay_within_gate": ( + aging.get("maximum_expiry_delay_seconds") + <= float(acceptance["maximum_expiry_delay_seconds"]) + 1e-9 + ), + "map_frame_jump_candidates_within_gate": ( + map_frame.get("jump_candidates") + <= acceptance.get("maximum_map_frame_jump_candidates") + ), + "upstream_artifacts_unchanged": upstream_unchanged, + "no_free_space_publication": occupancy.get("free_cell_rows") == 0, + "dynamic_class_unavailable": True, + "persistent_reconstruction_unmodified": True, + "navigation_or_safety_authority_false": True, + } + failed = [name for name, passed in requirements.items() if not passed] + result["acceptance"] = { + "accepted": not failed, + "requirements": requirements, + "rejection_reasons": failed, + "scope": "bounded-short-ttl-occupied-unknown-diagnostic-layer", + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + result["status"] = ( + "accepted-bounded-occupied-unknown-temporal-layer" + if not failed + else "rejected-fail-closed" + ) + result["decision"]["next_gate"] = ( + "A8/E35 deterministic degradation and recovery" + if not failed + else "E34 rejected; inspect immutable evidence before a new profile" + ) + return result + + +def _validate_upstream( + *, + e32: E32TrackGeometryReplay, + e33: E33WorkerShadowResult, + profile: dict[str, Any], +) -> None: + e33_identity = _object(e33.result.get("identity"), "E33 identity") + if ( + e32.report.get("status") + != "accepted-diagnostic-track-geometry-replay" + or not e33.accepted + or profile.get("expected_e32_result_id") != e32.result_id + or profile.get("expected_e33_result_id") != e33.result_id + or e33_identity.get("e32_result_id") != e32.result_id + ): + raise E34TemporalOccupiedReplayError( + "E34 profile does not bind accepted E32/E33 evidence" + ) + + +def _review_projection( + frame: dict[str, Any], + cells: np.ndarray[Any, np.dtype[np.int32]], + *, + voxel_size_m: float, +) -> dict[str, Any]: + if cells.shape[0] > 512: + indices = np.linspace(0, cells.shape[0] - 1, num=512).astype(np.int64) + sample = cells[indices] + else: + sample = cells + centers = (sample.astype(np.float64) + 0.5) * voxel_size_m + components = [] + for key in ("current", "held", "expired"): + for component in frame[key]: + components.append( + { + "temporal_id": component["temporal_id"], + "state": component["state"], + "occupancy_state": component["occupancy_state"], + "owner_kind": component["owner_kind"], + "last_observed_age_seconds": component[ + "last_observed_age_seconds" + ], + "association_reason": component["association_reason"], + "centroid_map_xyz_m": component[ + "centroid_map_xyz_m" + ], + "history_tail": component["history_tail"], + } + ) + return { + "frame_index": frame["frame_index"], + "source_frame_index": frame["source_frame_index"], + "session_seconds": frame["session_seconds"], + "source_available": frame["source_available"], + "layer_state": frame["layer_state"], + "counts": { + "current": len(frame["current"]), + "held": len(frame["held"]), + "expired": len(frame["expired"]), + }, + "map_frame_jump": copy.deepcopy(frame["map_frame_jump"]), + "components": components, + "cell_centers_map_xyz_m": centers.tolist(), + } + + +def _verified_artifacts( + root: Path, + raw: object, + *, + role_key: str, +) -> dict[str, Path]: + records = _array(raw, "artifact descriptors") + result: dict[str, Path] = {} + for value in records: + item = _object(value, "artifact descriptor") + role = item.get(role_key) + relative = item.get("path") + if ( + not isinstance(role, str) + or not role + or role in result + or not isinstance(relative, str) + or not relative + or Path(relative).is_absolute() + or ".." in Path(relative).parts + ): + raise E34TemporalOccupiedReplayError( + "artifact descriptor is invalid" + ) + path = root / relative + if ( + not path.is_file() + or path.is_symlink() + or item.get("byte_length") != path.stat().st_size + or item.get("sha256") != _sha256(path) + ): + raise E34TemporalOccupiedReplayError( + "upstream or E34 artifact identity changed" + ) + result[role] = path + if not result: + raise E34TemporalOccupiedReplayError("artifact set is empty") + return result + + +def _artifact_identity(artifacts: dict[str, Path]) -> dict[str, Any]: + return { + role: { + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + for role, path in sorted(artifacts.items()) + } + + +def _artifact( + path: Path, + kind: str, + media_type: str, + schema_version: str, +) -> dict[str, Any]: + return { + "kind": kind, + "path": path.name, + "media_type": media_type, + "schema_version": schema_version, + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + + +def _distribution(values: list[float]) -> dict[str, float | int | None]: + if not values: + return { + "sample_count": 0, + "minimum": None, + "p50": None, + "p95": None, + "maximum": None, + "mean": None, + } + array = np.asarray(values, dtype=np.float64) + return { + "sample_count": len(values), + "minimum": float(np.min(array)), + "p50": float(np.percentile(array, 50)), + "p95": float(np.percentile(array, 95)), + "maximum": float(np.max(array)), + "mean": float(np.mean(array)), + } + + +def _authority() -> dict[str, bool]: + return { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + + +def _positive_float(value: object, label: str) -> float: + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + or float(value) <= 0.0 + ): + raise E34TemporalOccupiedReplayError(f"{label} is invalid") + return float(value) + + +def _positive_int(value: object, label: str) -> int: + result = _nonnegative_int(value, label) + if result == 0: + raise E34TemporalOccupiedReplayError(f"{label} is invalid") + return result + + +def _nonnegative_int(value: object, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise E34TemporalOccupiedReplayError(f"{label} is invalid") + return value + + +def _boolean(value: object, label: str) -> bool: + if not isinstance(value, bool): + raise E34TemporalOccupiedReplayError(f"{label} is invalid") + return value + + +def _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict) or any( + not isinstance(key, str) for key in value + ): + raise E34TemporalOccupiedReplayError(f"{label} must be an object") + return value + + +def _array(value: object, label: str) -> list[Any]: + if not isinstance(value, list): + raise E34TemporalOccupiedReplayError(f"{label} must be an array") + return value + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise E34TemporalOccupiedReplayError( + f"JSON artifact is invalid: {path.name}" + ) from exc + return _object(value, path.name) + + +def _json_line(line: str, label: str) -> dict[str, Any]: + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + raise E34TemporalOccupiedReplayError(f"{label} JSON is invalid") from exc + return _object(value, label) + + +def _write_json(path: Path, value: object) -> None: + with path.open("x", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + + +def _write_jsonl(stream: Any, value: object) -> None: + stream.write( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + ) + stream.write("\n") diff --git a/src/k1link/compute/temporal_occupied_layer.py b/src/k1link/compute/temporal_occupied_layer.py new file mode 100644 index 0000000..79e3180 --- /dev/null +++ b/src/k1link/compute/temporal_occupied_layer.py @@ -0,0 +1,1026 @@ +"""Bounded hit-only temporal occupied/unknown layer over TrackGeometry v1. + +This module owns the streaming state machine only. It does not read or write +experiment artifacts and it cannot infer free space, a dynamic class, planner +input or runtime authority. +""" + +from __future__ import annotations + +import math +from collections import Counter, deque +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Final + +import numpy as np +import numpy.typing as npt + +from .track_geometry import ( + TrackGeometryCurrentness, + TrackGeometryFrame, + TrackGeometryMetricBasis, + TrackGeometryOwnerKind, +) + +TEMPORAL_COMPONENT_SCHEMA: Final = "missioncore.temporal-occupied-component/v1" +TEMPORAL_FRAME_SCHEMA: Final = "missioncore.temporal-occupied-frame/v1" + +Int32Array = npt.NDArray[np.int32] + + +class TemporalOccupiedLayerError(RuntimeError): + """The E34 temporal state or input violates the predeclared contract.""" + + +class TemporalComponentState(StrEnum): + CURRENT = "current" + HELD = "held" + EXPIRED = "expired" + + +class TemporalOccupancyState(StrEnum): + OCCUPIED = "occupied" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class TemporalLayerConfig: + voxel_size_m: float + occupied_ttl_seconds: float + maximum_active_components: int + maximum_cells_per_component: int + geometry_maximum_gap_seconds: float + geometry_maximum_centroid_distance_m: float + geometry_minimum_voxel_overlap_fraction: float + geometry_neighbor_radius_cells: int + jump_maximum_adjacent_gap_seconds: float + jump_minimum_matched_components: int + jump_minimum_median_displacement_m: float + jump_minimum_p25_displacement_m: float + + def __post_init__(self) -> None: + numeric = ( + self.voxel_size_m, + self.occupied_ttl_seconds, + self.geometry_maximum_gap_seconds, + self.geometry_maximum_centroid_distance_m, + self.geometry_minimum_voxel_overlap_fraction, + self.jump_maximum_adjacent_gap_seconds, + self.jump_minimum_median_displacement_m, + self.jump_minimum_p25_displacement_m, + ) + if any( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + or float(value) <= 0.0 + for value in numeric + ): + raise TemporalOccupiedLayerError("temporal layer numeric bound is invalid") + if not 0.0 < self.geometry_minimum_voxel_overlap_fraction <= 1.0: + raise TemporalOccupiedLayerError("temporal overlap fraction is invalid") + integers = ( + self.maximum_active_components, + self.maximum_cells_per_component, + self.geometry_neighbor_radius_cells, + self.jump_minimum_matched_components, + ) + if any( + not isinstance(value, int) + or isinstance(value, bool) + or value < 1 + for value in integers + ): + raise TemporalOccupiedLayerError("temporal layer integer bound is invalid") + + +@dataclass(frozen=True, slots=True) +class _Observation: + source_owner_key: str + owner_kind: TrackGeometryOwnerKind + semantic_track_id: int | None + semantic_label: str | None + point_count: int + cells: frozenset[tuple[int, int, int]] + centroid_map_xyz_m: tuple[float, float, float] + cell_bounds: tuple[ + tuple[int, int, int], + tuple[int, int, int], + ] + + +@dataclass(slots=True) +class _TemporalComponent: + temporal_id: int + owner_kind: TrackGeometryOwnerKind + source_owner_keys: set[str] + semantic_track_ids: set[int] + semantic_labels: set[str] + first_observed_frame_index: int + first_observed_seconds: float + last_observed_frame_index: int + last_observed_seconds: float + last_source_owner_key: str + point_count: int + cells: frozenset[tuple[int, int, int]] + centroid_map_xyz_m: tuple[float, float, float] + cell_bounds: tuple[ + tuple[int, int, int], + tuple[int, int, int], + ] + observation_count: int = 1 + exact_association_count: int = 0 + spatial_reassociation_count: int = 0 + history: deque[tuple[int, float, tuple[float, float, float]]] = field( + default_factory=lambda: deque(maxlen=8) + ) + + +@dataclass(frozen=True, slots=True) +class TemporalFrameProjection: + """One frame projection and its compact active-cell rows.""" + + document: dict[str, Any] + cell_rows: Int32Array + + +class TemporalOccupiedLayer: + """Deterministic bounded current/held/expired state machine.""" + + def __init__(self, config: TemporalLayerConfig) -> None: + self.config = config + self._components: dict[int, _TemporalComponent] = {} + self._camera_owner_to_component: dict[str, int] = {} + self._next_temporal_id = 1 + self._previous_frame_index: int | None = None + self._previous_seconds: float | None = None + self._previous_observations: tuple[_Observation, ...] = () + self.peak_active_components = 0 + self.peak_cells_per_component = 0 + self.created_components = 0 + self.camera_components_created = 0 + self.geometry_components_created = 0 + self.geometry_components_reassociated: set[int] = set() + self.exact_associations = 0 + self.spatial_reassociations = 0 + self.held_publications = 0 + self.expired_components = 0 + self.unknown_input_observations = 0 + self.map_frame_jump_candidates = 0 + self.maximum_held_age_seconds = 0.0 + self.maximum_expiry_delay_seconds = 0.0 + self.maximum_expiry_materialization_delay_seconds = 0.0 + self.current_point_rows = 0 + self.current_cell_rows = 0 + self.held_cell_rows = 0 + self.maximum_component_observations = 0 + self.maximum_component_observed_span_seconds = 0.0 + self.frame_state_counts: Counter[str] = Counter() + + @property + def active_component_count(self) -> int: + return len(self._components) + + def update(self, frame: TrackGeometryFrame) -> TemporalFrameProjection: + self._validate_frame_order(frame) + expired = self._expire_on_independent_deadline(frame.session_seconds) + observations, unknown_count = self._observations(frame) + self.unknown_input_observations += unknown_count + jump = self._map_frame_jump(frame.session_seconds, observations) + if jump["candidate"]: + self.map_frame_jump_candidates += 1 + + matched: set[int] = set() + assignment_reason: dict[int, str] = {} + observation_assignments: dict[int, int] = {} + + for observation_index, observation in enumerate(observations): + if observation.owner_kind is not TrackGeometryOwnerKind.CAMERA_TRACK: + continue + temporal_id = self._camera_owner_to_component.get( + observation.source_owner_key + ) + if temporal_id is None or temporal_id not in self._components: + continue + observation_assignments[observation_index] = temporal_id + matched.add(temporal_id) + assignment_reason[temporal_id] = "camera-source-identity" + + geometry_pairs: list[tuple[float, int, int]] = [] + for observation_index, observation in enumerate(observations): + if ( + observation_index in observation_assignments + or observation.owner_kind + is not TrackGeometryOwnerKind.GEOMETRY_CLUSTER + ): + continue + for component in self._components.values(): + if ( + component.temporal_id in matched + or component.owner_kind + is not TrackGeometryOwnerKind.GEOMETRY_CLUSTER + ): + continue + age = frame.session_seconds - component.last_observed_seconds + if not 0.0 < age <= self.config.geometry_maximum_gap_seconds: + continue + distance = _distance( + observation.centroid_map_xyz_m, + component.centroid_map_xyz_m, + ) + if distance > self.config.geometry_maximum_centroid_distance_m: + continue + overlaps = ( + distance <= self.config.voxel_size_m + or _overlap_at_least( + observation.cells, + observation.cell_bounds, + component.cells, + component.cell_bounds, + minimum_fraction=( + self.config.geometry_minimum_voxel_overlap_fraction + ), + neighbor_radius=( + self.config.geometry_neighbor_radius_cells + ), + ) + ) + if overlaps: + geometry_pairs.append( + (distance, component.temporal_id, observation_index) + ) + assigned_geometry_observations: set[int] = set() + for _, temporal_id, observation_index in sorted(geometry_pairs): + if ( + temporal_id in matched + or observation_index in assigned_geometry_observations + ): + continue + matched.add(temporal_id) + assigned_geometry_observations.add(observation_index) + observation_assignments[observation_index] = temporal_id + assignment_reason[temporal_id] = "geometry-spatial-reassociation" + + current: list[dict[str, Any]] = [] + frame_spatial_reassociations = 0 + frame_exact_associations = 0 + for observation_index, observation in enumerate(observations): + temporal_id = observation_assignments.get(observation_index) + if temporal_id is None: + component = self._create_component(frame, observation) + temporal_id = component.temporal_id + matched.add(temporal_id) + assignment_reason[temporal_id] = "new-hit-backed-component" + else: + component = self._components[temporal_id] + reason = assignment_reason[temporal_id] + self._observe_component(component, frame, observation) + if reason == "camera-source-identity": + component.exact_association_count += 1 + self.exact_associations += 1 + frame_exact_associations += 1 + else: + component.spatial_reassociation_count += 1 + self.geometry_components_reassociated.add( + component.temporal_id + ) + self.spatial_reassociations += 1 + frame_spatial_reassociations += 1 + current.append( + self._component_document( + component, + state=TemporalComponentState.CURRENT, + occupancy=TemporalOccupancyState.OCCUPIED, + now_seconds=frame.session_seconds, + association_reason=assignment_reason[temporal_id], + cell_row_start=0, + ) + ) + + held: list[dict[str, Any]] = [] + for temporal_id, component in sorted(self._components.items()): + if temporal_id in matched: + continue + age = frame.session_seconds - component.last_observed_seconds + if age > self.config.occupied_ttl_seconds: + raise TemporalOccupiedLayerError( + "expired temporal component survived its independent deadline" + ) + self.maximum_held_age_seconds = max( + self.maximum_held_age_seconds, + age, + ) + self.held_publications += 1 + held.append( + self._component_document( + component, + state=TemporalComponentState.HELD, + occupancy=TemporalOccupancyState.UNKNOWN, + now_seconds=frame.session_seconds, + association_reason="ttl-hold-last-hit", + cell_row_start=0, + ) + ) + + if len(self._components) > self.config.maximum_active_components: + raise TemporalOccupiedLayerError( + "temporal active-component bound exceeded" + ) + self.peak_active_components = max( + self.peak_active_components, + len(self._components), + ) + + ordered_current = sorted(current, key=lambda item: int(item["temporal_id"])) + ordered_held = sorted(held, key=lambda item: int(item["temporal_id"])) + cell_chunks: list[Int32Array] = [] + row_offset = 0 + for item in (*ordered_current, *ordered_held): + component = self._components[int(item["temporal_id"])] + cells = np.asarray(sorted(component.cells), dtype=" dict[str, Any]: + return { + "active_components": len(self._components), + "peak_active_components": self.peak_active_components, + "peak_cells_per_component": self.peak_cells_per_component, + "created_components": self.created_components, + "camera_components_created": self.camera_components_created, + "geometry_components_created": self.geometry_components_created, + "geometry_components_reassociated": len( + self.geometry_components_reassociated + ), + "exact_associations": self.exact_associations, + "spatial_reassociations": self.spatial_reassociations, + "held_publications": self.held_publications, + "expired_components": self.expired_components, + "unknown_input_observations": self.unknown_input_observations, + "map_frame_jump_candidates": self.map_frame_jump_candidates, + "maximum_held_age_seconds": self.maximum_held_age_seconds, + "maximum_expiry_delay_seconds": self.maximum_expiry_delay_seconds, + "maximum_expiry_materialization_delay_seconds": ( + self.maximum_expiry_materialization_delay_seconds + ), + "current_point_rows": self.current_point_rows, + "current_cell_rows": self.current_cell_rows, + "held_cell_rows": self.held_cell_rows, + "maximum_component_observations": ( + self.maximum_component_observations + ), + "maximum_component_observed_span_seconds": ( + self.maximum_component_observed_span_seconds + ), + "frame_state_counts": dict(sorted(self.frame_state_counts.items())), + } + + def _validate_frame_order(self, frame: TrackGeometryFrame) -> None: + if self._previous_frame_index is None: + if frame.frame_index != 0: + raise TemporalOccupiedLayerError( + "temporal replay must begin at frame zero" + ) + return + if ( + frame.frame_index != self._previous_frame_index + 1 + or self._previous_seconds is None + or frame.session_seconds <= self._previous_seconds + ): + raise TemporalOccupiedLayerError( + "temporal replay frame order changed" + ) + + def _expire_on_independent_deadline( + self, + materialized_at_seconds: float, + ) -> list[dict[str, Any]]: + expired: list[dict[str, Any]] = [] + expired_ids = [ + temporal_id + for temporal_id, component in sorted(self._components.items()) + if materialized_at_seconds - component.last_observed_seconds + > self.config.occupied_ttl_seconds + ] + for temporal_id in expired_ids: + component = self._components.pop(temporal_id) + deadline = ( + component.last_observed_seconds + + self.config.occupied_ttl_seconds + ) + materialization_delay = materialized_at_seconds - deadline + self.maximum_expiry_materialization_delay_seconds = max( + self.maximum_expiry_materialization_delay_seconds, + materialization_delay, + ) + self.expired_components += 1 + document = self._component_document( + component, + state=TemporalComponentState.EXPIRED, + occupancy=TemporalOccupancyState.UNKNOWN, + now_seconds=deadline, + association_reason="independent-ttl-deadline", + cell_row_start=0, + ) + document["expiration_event"] = { + "deadline_seconds": deadline, + "emitted_at_seconds": deadline, + "emission_delay_seconds": 0.0, + "replay_materialized_at_seconds": materialized_at_seconds, + "replay_materialization_delay_seconds": materialization_delay, + "clock": "independent-layer-deadline", + } + expired.append(document) + for owner_key in component.source_owner_keys: + if self._camera_owner_to_component.get(owner_key) == temporal_id: + self._camera_owner_to_component.pop(owner_key, None) + return expired + + def _observations( + self, + frame: TrackGeometryFrame, + ) -> tuple[tuple[_Observation, ...], int]: + observations: list[_Observation] = [] + unknown_count = 0 + for geometry in frame.geometries: + if ( + geometry.currentness is not TrackGeometryCurrentness.CURRENT + or geometry.metric_basis + is not TrackGeometryMetricBasis.CURRENT_POINTS + ): + unknown_count += 1 + continue + try: + owner_index = frame.point_slab.owner_keys.index( + geometry.owner_key + ) + except ValueError as exc: + raise TemporalOccupiedLayerError( + "current geometry lost PointSlab ownership" + ) from exc + mask = frame.point_slab.owner_indices == owner_index + points = np.asarray( + frame.point_slab.points_xyz_m[mask], + dtype=np.float64, + ) + if points.size == 0: + raise TemporalOccupiedLayerError( + "current geometry has no accepted hit-backed points" + ) + cell_array = np.floor( + points / self.config.voxel_size_m + ).astype(" self.config.maximum_cells_per_component: + raise TemporalOccupiedLayerError( + "temporal component cell bound exceeded" + ) + self.peak_cells_per_component = max( + self.peak_cells_per_component, + int(unique_cells.shape[0]), + ) + self.current_point_rows += int(points.shape[0]) + cells = frozenset( + (int(row[0]), int(row[1]), int(row[2])) + for row in unique_cells + ) + centroid = np.median(points, axis=0) + minimum = np.min(unique_cells, axis=0) + maximum = np.max(unique_cells, axis=0) + observations.append( + _Observation( + source_owner_key=geometry.owner_key, + owner_kind=geometry.owner_kind, + semantic_track_id=geometry.semantic_track_id, + semantic_label=geometry.semantic_label, + point_count=int(points.shape[0]), + cells=cells, + centroid_map_xyz_m=( + float(centroid[0]), + float(centroid[1]), + float(centroid[2]), + ), + cell_bounds=( + (int(minimum[0]), int(minimum[1]), int(minimum[2])), + (int(maximum[0]), int(maximum[1]), int(maximum[2])), + ), + ) + ) + return tuple(observations), unknown_count + + def _create_component( + self, + frame: TrackGeometryFrame, + observation: _Observation, + ) -> _TemporalComponent: + temporal_id = self._next_temporal_id + self._next_temporal_id += 1 + component = _TemporalComponent( + temporal_id=temporal_id, + owner_kind=observation.owner_kind, + source_owner_keys={observation.source_owner_key}, + semantic_track_ids=( + set() + if observation.semantic_track_id is None + else {observation.semantic_track_id} + ), + semantic_labels=( + set() + if observation.semantic_label is None + else {observation.semantic_label} + ), + first_observed_frame_index=frame.frame_index, + first_observed_seconds=frame.session_seconds, + last_observed_frame_index=frame.frame_index, + last_observed_seconds=frame.session_seconds, + last_source_owner_key=observation.source_owner_key, + point_count=observation.point_count, + cells=observation.cells, + centroid_map_xyz_m=observation.centroid_map_xyz_m, + cell_bounds=observation.cell_bounds, + ) + component.history.append( + ( + frame.frame_index, + frame.session_seconds, + observation.centroid_map_xyz_m, + ) + ) + self._components[temporal_id] = component + if observation.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK: + self._camera_owner_to_component[ + observation.source_owner_key + ] = temporal_id + self.camera_components_created += 1 + else: + self.geometry_components_created += 1 + self.created_components += 1 + self.maximum_component_observations = max( + self.maximum_component_observations, + component.observation_count, + ) + return component + + def _observe_component( + self, + component: _TemporalComponent, + frame: TrackGeometryFrame, + observation: _Observation, + ) -> None: + if component.owner_kind is not observation.owner_kind: + raise TemporalOccupiedLayerError( + "temporal association changed owner kind" + ) + component.source_owner_keys.add(observation.source_owner_key) + if observation.semantic_track_id is not None: + component.semantic_track_ids.add(observation.semantic_track_id) + if observation.semantic_label is not None: + component.semantic_labels.add(observation.semantic_label) + component.last_observed_frame_index = frame.frame_index + component.last_observed_seconds = frame.session_seconds + component.last_source_owner_key = observation.source_owner_key + component.point_count = observation.point_count + component.cells = observation.cells + component.centroid_map_xyz_m = observation.centroid_map_xyz_m + component.cell_bounds = observation.cell_bounds + component.observation_count += 1 + component.history.append( + ( + frame.frame_index, + frame.session_seconds, + observation.centroid_map_xyz_m, + ) + ) + self.maximum_component_observations = max( + self.maximum_component_observations, + component.observation_count, + ) + self.maximum_component_observed_span_seconds = max( + self.maximum_component_observed_span_seconds, + frame.session_seconds - component.first_observed_seconds, + ) + + def _component_document( + self, + component: _TemporalComponent, + *, + state: TemporalComponentState, + occupancy: TemporalOccupancyState, + now_seconds: float, + association_reason: str, + cell_row_start: int, + ) -> dict[str, Any]: + age = now_seconds - component.last_observed_seconds + active_cells = state is not TemporalComponentState.EXPIRED + return { + "schema_version": TEMPORAL_COMPONENT_SCHEMA, + "temporal_id": component.temporal_id, + "state": state.value, + "occupancy_state": occupancy.value, + "owner_kind": component.owner_kind.value, + "source_owner_key": component.last_source_owner_key, + "source_owner_alias_count": len(component.source_owner_keys), + "semantic_provenance": { + "owner": ( + "camera" + if component.owner_kind + is TrackGeometryOwnerKind.CAMERA_TRACK + else None + ), + "track_ids": sorted(component.semantic_track_ids), + "labels": sorted(component.semantic_labels), + "class_inferred_by_e34": False, + }, + "first_observed_frame_index": component.first_observed_frame_index, + "last_observed_frame_index": component.last_observed_frame_index, + "last_observed_age_seconds": age, + "observation_count": component.observation_count, + "association_reason": association_reason, + "exact_association_count": component.exact_association_count, + "spatial_reassociation_count": ( + component.spatial_reassociation_count + ), + "last_current_point_count": component.point_count, + "centroid_map_xyz_m": list(component.centroid_map_xyz_m), + "cell_row_start": cell_row_start, + "cell_row_count": len(component.cells) if active_cells else 0, + "history_tail": [ + { + "frame_index": frame_index, + "session_seconds": seconds, + "centroid_map_xyz_m": list(centroid), + } + for frame_index, seconds, centroid in component.history + ], + "policy": { + "hit_backed_origin": True, + "held_is_unknown": state is TemporalComponentState.HELD, + "expired_cells_published": False, + "absence_of_points_means_free": False, + }, + "authority": _authority(), + } + + def _map_frame_jump( + self, + now_seconds: float, + observations: tuple[_Observation, ...], + ) -> dict[str, Any]: + if ( + self._previous_seconds is None + or not self._previous_observations + or not observations + ): + return _jump_document(()) + gap = now_seconds - self._previous_seconds + if not 0.0 < gap <= self.config.jump_maximum_adjacent_gap_seconds: + return _jump_document(()) + displacement_vectors: list[np.ndarray[Any, np.dtype[np.float64]]] = [] + used_previous: set[int] = set() + used_current: set[int] = set() + previous_camera = { + item.source_owner_key: index + for index, item in enumerate(self._previous_observations) + if item.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK + } + for current_index, current in enumerate(observations): + if current.owner_kind is not TrackGeometryOwnerKind.CAMERA_TRACK: + continue + previous_index = previous_camera.get(current.source_owner_key) + if previous_index is None: + continue + used_previous.add(previous_index) + used_current.add(current_index) + displacement_vectors.append( + _vector( + self._previous_observations[ + previous_index + ].centroid_map_xyz_m, + current.centroid_map_xyz_m, + ) + ) + candidates: list[tuple[float, int, int]] = [] + for previous_index, previous in enumerate(self._previous_observations): + if ( + previous_index in used_previous + or previous.owner_kind + is not TrackGeometryOwnerKind.GEOMETRY_CLUSTER + ): + continue + for current_index, current in enumerate(observations): + if ( + current_index in used_current + or current.owner_kind + is not TrackGeometryOwnerKind.GEOMETRY_CLUSTER + ): + continue + candidates.append( + ( + _distance( + previous.centroid_map_xyz_m, + current.centroid_map_xyz_m, + ), + previous_index, + current_index, + ) + ) + for _, previous_index, current_index in sorted(candidates): + if previous_index in used_previous or current_index in used_current: + continue + used_previous.add(previous_index) + used_current.add(current_index) + displacement_vectors.append( + _vector( + self._previous_observations[ + previous_index + ].centroid_map_xyz_m, + observations[current_index].centroid_map_xyz_m, + ) + ) + if not displacement_vectors: + return _jump_document(()) + displacement_array = np.asarray(displacement_vectors, dtype=np.float64) + magnitudes = np.linalg.norm(displacement_array, axis=1) + if ( + len(magnitudes) < self.config.jump_minimum_matched_components + or float(np.percentile(magnitudes, 50)) + < self.config.jump_minimum_median_displacement_m + or float(np.percentile(magnitudes, 25)) + < self.config.jump_minimum_p25_displacement_m + ): + return _jump_document(tuple(float(value) for value in magnitudes)) + coherent_vectors = self._coherent_global_translation(observations) + if not coherent_vectors: + document = _jump_document( + tuple(float(value) for value in magnitudes) + ) + document["reason"] = "no-coherent-global-translation" + return document + coherent_array = np.asarray(coherent_vectors, dtype=np.float64) + coherent_magnitudes = np.linalg.norm(coherent_array, axis=1) + translation = np.median(coherent_array, axis=0) + residuals = np.linalg.norm(coherent_array - translation, axis=1) + median = float(np.percentile(coherent_magnitudes, 50)) + p25 = float(np.percentile(coherent_magnitudes, 25)) + candidate = ( + len(coherent_vectors) + >= self.config.jump_minimum_matched_components + and median >= self.config.jump_minimum_median_displacement_m + and p25 >= self.config.jump_minimum_p25_displacement_m + ) + return { + "candidate": candidate, + "matched_component_count": len(coherent_vectors), + "displacement_m": { + "p25": p25, + "median": median, + "maximum": float(np.max(coherent_magnitudes)), + }, + "translation_map_xyz_m": translation.tolist(), + "maximum_translation_residual_m": float(np.max(residuals)), + "reason": ( + "coherent-global-translation" + if candidate + else "coherent-translation-below-gate" + ), + "classification": "diagnostic-candidate-only", + } + + def _coherent_global_translation( + self, + current: tuple[_Observation, ...], + ) -> tuple[np.ndarray[Any, np.dtype[np.float64]], ...]: + previous = self._previous_observations + exact_pairs: list[ + tuple[int, int, np.ndarray[Any, np.dtype[np.float64]]] + ] = [] + previous_camera = { + item.source_owner_key: index + for index, item in enumerate(previous) + if item.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK + } + for current_index, item in enumerate(current): + if item.owner_kind is not TrackGeometryOwnerKind.CAMERA_TRACK: + continue + previous_index = previous_camera.get(item.source_owner_key) + if previous_index is not None: + exact_pairs.append( + ( + previous_index, + current_index, + _vector( + previous[previous_index].centroid_map_xyz_m, + item.centroid_map_xyz_m, + ), + ) + ) + geometry_pairs = [ + ( + previous_index, + current_index, + _vector( + previous_item.centroid_map_xyz_m, + current_item.centroid_map_xyz_m, + ), + ) + for previous_index, previous_item in enumerate(previous) + if previous_item.owner_kind + is TrackGeometryOwnerKind.GEOMETRY_CLUSTER + for current_index, current_item in enumerate(current) + if current_item.owner_kind + is TrackGeometryOwnerKind.GEOMETRY_CLUSTER + ] + hypotheses = [item[2] for item in (*exact_pairs, *geometry_pairs)] + best: tuple[ + int, + float, + tuple[np.ndarray[Any, np.dtype[np.float64]], ...], + ] | None = None + residual_gate = self.config.voxel_size_m + for hypothesis in hypotheses: + exact_residuals = [ + float(np.linalg.norm(vector - hypothesis)) + for _, _, vector in exact_pairs + ] + if exact_residuals and max(exact_residuals) > residual_gate: + continue + selected = [item[2] for item in exact_pairs] + used_previous = {item[0] for item in exact_pairs} + used_current = {item[1] for item in exact_pairs} + candidates = sorted( + ( + float(np.linalg.norm(vector - hypothesis)), + previous_index, + current_index, + vector, + ) + for previous_index, current_index, vector in geometry_pairs + if float(np.linalg.norm(vector - hypothesis)) <= residual_gate + ) + residual_total = sum(exact_residuals) + for residual, previous_index, current_index, vector in candidates: + if ( + previous_index in used_previous + or current_index in used_current + ): + continue + used_previous.add(previous_index) + used_current.add(current_index) + selected.append(vector) + residual_total += residual + candidate = ( + len(selected), + residual_total, + tuple(selected), + ) + if ( + best is None + or candidate[0] > best[0] + or ( + candidate[0] == best[0] + and candidate[1] < best[1] + ) + ): + best = candidate + if best is None or best[0] < self.config.jump_minimum_matched_components: + return () + return best[2] + + +def _jump_document(displacements: tuple[float, ...]) -> dict[str, Any]: + return { + "candidate": False, + "matched_component_count": len(displacements), + "displacement_m": { + "p25": None, + "median": None, + "maximum": None, + }, + "classification": "diagnostic-candidate-only", + } + + +def _distance( + left: tuple[float, float, float], + right: tuple[float, float, float], +) -> float: + return math.sqrt(sum((a - b) ** 2 for a, b in zip(left, right, strict=True))) + + +def _vector( + left: tuple[float, float, float], + right: tuple[float, float, float], +) -> np.ndarray[Any, np.dtype[np.float64]]: + return np.asarray(right, dtype=np.float64) - np.asarray( + left, + dtype=np.float64, + ) + + +def _overlap_at_least( + left: frozenset[tuple[int, int, int]], + left_bounds: tuple[tuple[int, int, int], tuple[int, int, int]], + right: frozenset[tuple[int, int, int]], + right_bounds: tuple[tuple[int, int, int], tuple[int, int, int]], + *, + minimum_fraction: float, + neighbor_radius: int, +) -> bool: + for axis in range(3): + if ( + left_bounds[1][axis] + neighbor_radius < right_bounds[0][axis] + or right_bounds[1][axis] + neighbor_radius < left_bounds[0][axis] + ): + return False + smaller, larger = (left, right) if len(left) <= len(right) else (right, left) + required = max(1, math.ceil(len(smaller) * minimum_fraction)) + matched = 0 + for x, y, z in smaller: + found = False + for dx in range(-neighbor_radius, neighbor_radius + 1): + for dy in range(-neighbor_radius, neighbor_radius + 1): + for dz in range(-neighbor_radius, neighbor_radius + 1): + if (x + dx, y + dy, z + dz) in larger: + found = True + break + if found: + break + if found: + break + if found: + matched += 1 + if matched >= required: + return True + return False + + +def _authority() -> dict[str, bool]: + return { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } diff --git a/src/k1link/web/advanced_laboratory_api.py b/src/k1link/web/advanced_laboratory_api.py index 7d5b0d2..e1ee7c5 100644 --- a/src/k1link/web/advanced_laboratory_api.py +++ b/src/k1link/web/advanced_laboratory_api.py @@ -24,6 +24,11 @@ from k1link.compute.e33_worker_shadow import ( E33WorkerShadowResult, read_e33_worker_shadow_result, ) +from k1link.compute.e34_temporal_occupied_replay import ( + E34TemporalOccupiedReplay, + E34TemporalOccupiedReplayError, + read_e34_temporal_occupied_replay, +) LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = ( "missioncore.laboratory-advanced-catalog/v1" @@ -32,6 +37,7 @@ LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = ( _E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$") _E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$") _E33_RESULT_ID = re.compile(r"^e33-worker-shadow-[a-f0-9]{64}$") +_E34_RESULT_ID = re.compile(r"^e34-temporal-occupied-[a-f0-9]{64}$") RootProvider = Callable[[], Path | None] @@ -73,6 +79,15 @@ def _read_e33_cached( return read_e33_worker_shadow_result(Path(root_text)) +@lru_cache(maxsize=16) +def _read_e34_cached( + root_text: str, + signature: tuple[int, ...], +) -> E34TemporalOccupiedReplay: + del signature + return read_e34_temporal_occupied_replay(Path(root_text)) + + def _configured_root(provider: RootProvider) -> Path | None: value = provider() if value is None: @@ -294,6 +309,113 @@ def _project_e33( } +def _project_e34(result: E34TemporalOccupiedReplay) -> dict[str, object]: + identity = _object(result.manifest.get("identity"), "E34 identity") + profile = _object(identity.get("profile"), "E34 profile") + layer = _object(profile.get("layer"), "E34 layer") + association = _object(profile.get("association"), "E34 association") + metrics = _object(result.report.get("metrics"), "E34 metrics") + frames = _object(metrics.get("frames"), "E34 frames") + components = _object(metrics.get("components"), "E34 components") + occupancy = _object(metrics.get("occupancy"), "E34 occupancy") + aging = _object(metrics.get("aging"), "E34 aging") + map_frame = _object(metrics.get("map_frame"), "E34 map frame") + runtime = _object(metrics.get("runtime"), "E34 runtime") + frame_processing = _object( + runtime.get("frame_processing_ms"), + "E34 frame processing", + ) + acceptance = _object(result.report.get("acceptance"), "E34 acceptance") + if acceptance.get("accepted") is not True: + raise ValueError("E34 result is not accepted") + return { + "result_id": result.result_id, + "created_at_utc": result.manifest.get("created_at_utc"), + "source_session_id": identity.get("source_session_id"), + "status": result.manifest.get("acceptance_state"), + "e32_result_id": identity.get("e32_result_id"), + "e33_result_id": identity.get("e33_result_id"), + "profile_id": profile.get("profile_id"), + "pipeline_id": identity.get("pipeline"), + "coordinate_frame": identity.get("coordinate_frame"), + "configuration": { + "voxel_size_m": layer.get("voxel_size_m"), + "occupied_ttl_seconds": layer.get("occupied_ttl_seconds"), + "maximum_active_components": layer.get( + "maximum_active_components" + ), + "maximum_cells_per_component": layer.get( + "maximum_cells_per_component" + ), + "geometry_maximum_gap_seconds": association.get( + "geometry_maximum_gap_seconds" + ), + "geometry_maximum_centroid_distance_m": association.get( + "geometry_maximum_centroid_distance_m" + ), + }, + "metrics": { + "source_frames": frames.get("source"), + "processed_frames": frames.get("processed"), + "frames_with_current_layer": frames.get("with_current_layer"), + "held_only_frames": frames.get("held_only"), + "unknown_empty_frames": frames.get("unknown_empty"), + "created_components": components.get("created"), + "camera_created_components": components.get("camera_created"), + "geometry_created_components": components.get("geometry_created"), + "geometry_reassociated_components": components.get( + "geometry_reassociated_components" + ), + "exact_camera_associations": components.get( + "exact_camera_associations" + ), + "geometry_spatial_reassociations": components.get( + "geometry_spatial_reassociations" + ), + "held_publications": components.get("held_publications"), + "expired_components": components.get("expired"), + "peak_active_components": components.get("peak_active"), + "maximum_observed_span_seconds": components.get( + "maximum_observed_span_seconds" + ), + "continuity_fraction": components.get("continuity_fraction"), + "geometry_only_reassociation_fraction": components.get( + "geometry_only_reassociation_fraction" + ), + "e32_current_point_rows": occupancy.get( + "e32_current_point_rows" + ), + "e34_consumed_current_point_rows": occupancy.get( + "e34_consumed_current_point_rows" + ), + "current_cell_rows": occupancy.get("current_cell_rows"), + "held_cell_rows": occupancy.get("held_cell_rows"), + "stored_cell_rows": occupancy.get("stored_cell_rows"), + "free_cell_rows": occupancy.get("free_cell_rows"), + "peak_cells_per_component": occupancy.get( + "peak_cells_per_component" + ), + "maximum_held_age_seconds": aging.get( + "maximum_held_age_seconds" + ), + "maximum_expiry_delay_seconds": aging.get( + "maximum_expiry_delay_seconds" + ), + "maximum_replay_materialization_delay_seconds": aging.get( + "maximum_replay_materialization_delay_seconds" + ), + "map_frame_jump_candidates": map_frame.get("jump_candidates"), + "frame_processing_p95_ms": frame_processing.get("p95"), + "build_elapsed_ms": runtime.get("build_elapsed_ms"), + }, + "review": copy.deepcopy(result.review), + "acceptance": copy.deepcopy(acceptance), + "decision": copy.deepcopy(result.report.get("decision")), + "authority": copy.deepcopy(result.report.get("authority")), + "access": "read-only", + } + + def _empty_catalog(configured: bool) -> dict[str, object]: return { "schema_version": LABORATORY_ADVANCED_CATALOG_SCHEMA, @@ -310,6 +432,7 @@ def build_advanced_laboratory_router( e31_root_provider: RootProvider = lambda: None, e32_root_provider: RootProvider = lambda: None, e33_root_provider: RootProvider = lambda: None, + e34_root_provider: RootProvider = lambda: None, ) -> APIRouter: router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) @@ -434,4 +557,39 @@ def build_advanced_laboratory_router( "invalid_total": invalid_total, } + @router.get("/e34/results") + def list_e34_results( + limit: int = Query(default=1, ge=1, le=10), + ) -> dict[str, object]: + root = _configured_root(e34_root_provider) + if root is None: + return _empty_catalog(False) + candidates = _candidates(root, _E34_RESULT_ID) + items: list[dict[str, object]] = [] + invalid_total = 0 + for candidate in candidates: + try: + result = _read_e34_cached( + str(candidate.resolve()), + _result_signature(candidate), + ) + if not result.accepted: + raise ValueError("E34 result is not accepted") + if len(items) < limit: + items.append(_project_e34(result)) + except ( + E34TemporalOccupiedReplayError, + KeyError, + OSError, + TypeError, + ValueError, + ): + invalid_total += 1 + return { + **_empty_catalog(True), + "items": items, + "candidate_total": len(candidates), + "invalid_total": invalid_total, + } + return router diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 22101a3..9a6fb1b 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -520,6 +520,13 @@ app.include_router( / "e33" / "results" ), + e34_root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "e34" + / "results" + ), ) ) app.include_router( diff --git a/tests/test_advanced_laboratory_api.py b/tests/test_advanced_laboratory_api.py index fd1c768..b3d5f1b 100644 --- a/tests/test_advanced_laboratory_api.py +++ b/tests/test_advanced_laboratory_api.py @@ -1,10 +1,13 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace from fastapi import APIRouter from fastapi.routing import APIRoute +from pytest import MonkeyPatch +import k1link.web.advanced_laboratory_api as advanced_api from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router @@ -23,7 +26,7 @@ def _endpoint(router: APIRouter, path: str) -> object: def test_advanced_catalogs_are_empty_when_not_configured() -> None: router = build_advanced_laboratory_router() - for name in ("e31", "e32", "e33"): + for name in ("e31", "e32", "e33", "e34"): route = _endpoint(router, f"/api/v1/laboratory/{name}/results") catalog = route(limit=1) # type: ignore[operator] assert catalog == { @@ -42,18 +45,21 @@ def test_advanced_catalogs_fail_closed_on_incomplete_results( e31 = tmp_path / "e31" e32 = tmp_path / "e32" e33 = tmp_path / "e33" - for root in (e31, e32, e33): + e34 = tmp_path / "e34" + for root in (e31, e32, e33, e34): root.mkdir() (e31 / f"e31-source-qualification-{'1' * 64}").mkdir() (e32 / f"e32-track-geometry-{'2' * 64}").mkdir() (e33 / f"e33-worker-shadow-{'3' * 64}").mkdir() + (e34 / f"e34-temporal-occupied-{'4' * 64}").mkdir() router = build_advanced_laboratory_router( e31_root_provider=lambda: e31, e32_root_provider=lambda: e32, e33_root_provider=lambda: e33, + e34_root_provider=lambda: e34, ) - for name in ("e31", "e32", "e33"): + for name in ("e31", "e32", "e33", "e34"): route = _endpoint(router, f"/api/v1/laboratory/{name}/results") catalog = route(limit=1) # type: ignore[operator] assert catalog["configured"] is True @@ -77,3 +83,129 @@ def test_advanced_catalog_does_not_follow_a_configured_symlink( catalog = route(limit=1) # type: ignore[operator] assert catalog["configured"] is False + + +def test_e34_catalog_projects_only_accepted_read_only_evidence( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + result_id = f"e34-temporal-occupied-{'4' * 64}" + root = tmp_path / "e34" + candidate = root / result_id + candidate.mkdir(parents=True) + (candidate / "manifest.json").write_text("{}", encoding="utf-8") + authority = { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + result = SimpleNamespace( + result_id=result_id, + accepted=True, + manifest={ + "created_at_utc": "2026-07-27T13:28:30Z", + "acceptance_state": ( + "accepted-bounded-occupied-unknown-temporal-layer" + ), + "identity": { + "source_session_id": "source-session", + "e32_result_id": f"e32-track-geometry-{'2' * 64}", + "e33_result_id": f"e33-worker-shadow-{'3' * 64}", + "pipeline": "lidar-local-map/short-ttl-hit-only/v1", + "coordinate_frame": "map", + "profile": { + "profile_id": "e34-profile/v1", + "layer": { + "voxel_size_m": 0.45, + "occupied_ttl_seconds": 0.75, + "maximum_active_components": 256, + "maximum_cells_per_component": 4096, + }, + "association": { + "geometry_maximum_gap_seconds": 0.35, + "geometry_maximum_centroid_distance_m": 0.9, + }, + }, + }, + }, + report={ + "metrics": { + "frames": { + "source": 4489, + "processed": 4489, + "with_current_layer": 3928, + "held_only": 540, + "unknown_empty": 21, + }, + "components": { + "created": 4912, + "camera_created": 1027, + "geometry_created": 3885, + "geometry_reassociated_components": 2366, + "exact_camera_associations": 5168, + "geometry_spatial_reassociations": 17433, + "held_publications": 39664, + "expired": 4895, + "peak_active": 37, + "maximum_observed_span_seconds": 13.394, + "continuity_fraction": 0.821466, + "geometry_only_reassociation_fraction": 0.81776, + }, + "occupancy": { + "e32_current_point_rows": 2119302, + "e34_consumed_current_point_rows": 2119302, + "current_cell_rows": 834478, + "held_cell_rows": 531581, + "stored_cell_rows": 1366059, + "free_cell_rows": 0, + "peak_cells_per_component": 324, + }, + "aging": { + "maximum_held_age_seconds": 0.75, + "maximum_expiry_delay_seconds": 0.0, + "maximum_replay_materialization_delay_seconds": 0.283, + }, + "map_frame": {"jump_candidates": 0}, + "runtime": { + "build_elapsed_ms": 4317.0, + "frame_processing_ms": {"p95": 1.483}, + }, + }, + "acceptance": {"accepted": True, "requirements": {}}, + "decision": {"next_gate": "A8/E35"}, + "authority": authority, + }, + review={ + "schema_version": ( + "missioncore.e34-temporal-review-timeline/v1" + ), + "result_id": result_id, + "frames": [], + }, + ) + + def fake_read( + root_text: str, + signature: tuple[int, ...], + ) -> SimpleNamespace: + assert root_text == str(candidate.resolve()) + assert signature + return result + + monkeypatch.setattr(advanced_api, "_read_e34_cached", fake_read) + router = build_advanced_laboratory_router( + e34_root_provider=lambda: root, + ) + + route = _endpoint(router, "/api/v1/laboratory/e34/results") + catalog = route(limit=1) # type: ignore[operator] + + assert catalog["candidate_total"] == 1 + assert catalog["invalid_total"] == 0 + item = catalog["items"][0] + assert item["status"] == ( + "accepted-bounded-occupied-unknown-temporal-layer" + ) + assert item["metrics"]["processed_frames"] == 4489 + assert item["metrics"]["free_cell_rows"] == 0 + assert item["authority"] == authority + assert item["access"] == "read-only" diff --git a/tests/test_e34_temporal_occupied_layer.py b/tests/test_e34_temporal_occupied_layer.py new file mode 100644 index 0000000..e006fd6 --- /dev/null +++ b/tests/test_e34_temporal_occupied_layer.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from k1link.compute.temporal_occupied_layer import ( + TemporalLayerConfig, + TemporalOccupiedLayer, + TemporalOccupiedLayerError, +) +from k1link.compute.track_geometry import ( + PointSlab, + TrackGeometry, + TrackGeometryCurrentness, + TrackGeometryEvidenceState, + TrackGeometryFrame, + TrackGeometryMetricBasis, + TrackGeometryOwnerKind, + TrackGeometrySourceBinding, +) + + +def _config(**changes: object) -> TemporalLayerConfig: + values: dict[str, object] = { + "voxel_size_m": 0.45, + "occupied_ttl_seconds": 0.75, + "maximum_active_components": 256, + "maximum_cells_per_component": 4096, + "geometry_maximum_gap_seconds": 0.35, + "geometry_maximum_centroid_distance_m": 0.9, + "geometry_minimum_voxel_overlap_fraction": 0.05, + "geometry_neighbor_radius_cells": 1, + "jump_maximum_adjacent_gap_seconds": 0.25, + "jump_minimum_matched_components": 4, + "jump_minimum_median_displacement_m": 1.5, + "jump_minimum_p25_displacement_m": 0.9, + } + values.update(changes) + return TemporalLayerConfig(**values) # type: ignore[arg-type] + + +def _binding() -> TrackGeometrySourceBinding: + return TrackGeometrySourceBinding( + source_pack_id="e10-lidar-pack-" + "a" * 64, + source_session_id="source-session", + representation_profile_id="representation-profile/v1", + e31_qualification_id="e31-source-qualification-" + "b" * 64, + calibration_sha256="c" * 64, + coordinate_frame="map", + time_basis="source-time", + selected_offset_ms=0, + ) + + +def _frame( + *, + frame_index: int, + seconds: float, + owners: list[ + tuple[ + str, + TrackGeometryOwnerKind, + list[list[float]], + int | None, + ] + ], + source_available: bool = True, + unknown_camera_owner: str | None = None, +) -> TrackGeometryFrame: + owner_keys = tuple(item[0] for item in owners) + source_indices: list[int] = [] + points: list[list[float]] = [] + owner_indices: list[int] = [] + geometries: list[TrackGeometry] = [] + for owner_index, (owner_key, owner_kind, owner_points, track_id) in enumerate( + owners + ): + row_start = len(points) + points.extend(owner_points) + source_indices.extend(range(row_start, row_start + len(owner_points))) + owner_indices.extend([owner_index] * len(owner_points)) + geometries.append( + TrackGeometry( + owner_key=owner_key, + owner_kind=owner_kind, + evidence_state=( + TrackGeometryEvidenceState.AGREE + if owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK + else TrackGeometryEvidenceState.GEOMETRY_ONLY + ), + currentness=TrackGeometryCurrentness.CURRENT, + metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS, + reason_codes=("accepted-hit-backed-support",), + semantic_track_id=track_id, + semantic_label=( + "object" + if owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK + else None + ), + bbox_xyxy=( + (1.0, 1.0, 2.0, 2.0) + if owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK + else None + ), + range_m=1.0, + ) + ) + if unknown_camera_owner is not None: + track_id = int(unknown_camera_owner.split(":")[1]) + geometries.append( + TrackGeometry( + owner_key=unknown_camera_owner, + owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK, + evidence_state=TrackGeometryEvidenceState.CAMERA_ONLY, + currentness=TrackGeometryCurrentness.CURRENT, + metric_basis=TrackGeometryMetricBasis.UNAVAILABLE, + reason_codes=("camera-without-current-points",), + semantic_track_id=track_id, + semantic_label="object", + bbox_xyxy=(1.0, 1.0, 2.0, 2.0), + ) + ) + point_array = ( + np.asarray(points, dtype=" None: + layer = TemporalOccupiedLayer(_config()) + current = layer.update( + _frame( + frame_index=0, + seconds=0.0, + owners=[ + ( + "track:7", + TrackGeometryOwnerKind.CAMERA_TRACK, + [[1.0, 2.0, 0.5], [1.1, 2.0, 0.5]], + 7, + ) + ], + ) + ) + held = layer.update( + _frame( + frame_index=1, + seconds=0.5, + owners=[], + source_available=False, + ) + ) + expired = layer.update( + _frame( + frame_index=2, + seconds=0.9, + owners=[], + source_available=False, + ) + ) + + assert current.document["current"][0]["occupancy_state"] == "occupied" + assert held.document["held"][0]["occupancy_state"] == "unknown" + assert held.document["held"][0]["cell_row_count"] > 0 + assert expired.document["expired"][0]["occupancy_state"] == "unknown" + assert expired.document["expired"][0]["cell_row_count"] == 0 + assert expired.document["expired"][0]["expiration_event"] == { + "deadline_seconds": 0.75, + "emitted_at_seconds": 0.75, + "emission_delay_seconds": 0.0, + "replay_materialized_at_seconds": 0.9, + "replay_materialization_delay_seconds": pytest.approx(0.15), + "clock": "independent-layer-deadline", + } + assert expired.cell_rows.shape == (0, 3) + assert layer.active_component_count == 0 + + +def test_geometry_only_components_reassociate_without_inventing_semantics() -> None: + layer = TemporalOccupiedLayer(_config()) + first = layer.update( + _frame( + frame_index=0, + seconds=0.0, + owners=[ + ( + "geometry:0", + TrackGeometryOwnerKind.GEOMETRY_CLUSTER, + [[1.0, 1.0, 0.5], [1.2, 1.0, 0.5]], + None, + ) + ], + ) + ) + second = layer.update( + _frame( + frame_index=1, + seconds=0.1, + owners=[ + ( + "geometry:9", + TrackGeometryOwnerKind.GEOMETRY_CLUSTER, + [[1.1, 1.0, 0.5], [1.3, 1.0, 0.5]], + None, + ) + ], + ) + ) + + assert first.document["current"][0]["temporal_id"] == second.document[ + "current" + ][0]["temporal_id"] + assert second.document["current"][0]["association_reason"] == ( + "geometry-spatial-reassociation" + ) + assert second.document["current"][0]["semantic_provenance"] == { + "owner": None, + "track_ids": [], + "labels": [], + "class_inferred_by_e34": False, + } + + +def test_unknown_without_current_points_cannot_create_occupied_cells() -> None: + layer = TemporalOccupiedLayer(_config()) + projection = layer.update( + _frame( + frame_index=0, + seconds=0.0, + owners=[], + unknown_camera_owner="track:11", + ) + ) + + assert projection.document["layer_state"] == "unknown" + assert projection.document["current"] == [] + assert projection.document["input"]["unknown_without_current_points"] == 1 + assert projection.cell_rows.shape == (0, 3) + assert layer.active_component_count == 0 + + +def test_temporal_layer_detects_supported_global_map_frame_jump() -> None: + layer = TemporalOccupiedLayer(_config()) + first = [ + ( + f"track:{track_id}", + TrackGeometryOwnerKind.CAMERA_TRACK, + [[float(track_id), 0.0, 0.5]], + track_id, + ) + for track_id in range(1, 5) + ] + shifted = [ + ( + f"track:{track_id}", + TrackGeometryOwnerKind.CAMERA_TRACK, + [[float(track_id) + 2.0, 0.0, 0.5]], + track_id, + ) + for track_id in range(1, 5) + ] + layer.update(_frame(frame_index=0, seconds=0.0, owners=first)) + projection = layer.update( + _frame(frame_index=1, seconds=0.1, owners=shifted) + ) + + assert projection.document["map_frame_jump"]["candidate"] is True + assert projection.document["map_frame_jump"][ + "matched_component_count" + ] == 4 + + +def test_temporal_layer_rejects_incoherent_nearest_neighbor_jump() -> None: + layer = TemporalOccupiedLayer(_config()) + first = [ + ( + f"geometry:{index}", + TrackGeometryOwnerKind.GEOMETRY_CLUSTER, + [[float(index) * 5.0, 0.0, 0.5]], + None, + ) + for index in range(4) + ] + incoherent = [ + ( + f"geometry:{index}", + TrackGeometryOwnerKind.GEOMETRY_CLUSTER, + [[x, y, 0.5]], + None, + ) + for index, (x, y) in enumerate( + ((2.0, 0.0), (8.0, 2.0), (13.0, -3.0), (25.0, 4.0)) + ) + ] + layer.update(_frame(frame_index=0, seconds=0.0, owners=first)) + projection = layer.update( + _frame(frame_index=1, seconds=0.1, owners=incoherent) + ) + + assert projection.document["map_frame_jump"]["candidate"] is False + assert projection.document["map_frame_jump"]["reason"] == ( + "no-coherent-global-translation" + ) + + +def test_temporal_layer_fails_closed_instead_of_truncating_cells() -> None: + layer = TemporalOccupiedLayer(_config(maximum_cells_per_component=1)) + + with pytest.raises( + TemporalOccupiedLayerError, + match="cell bound exceeded", + ): + layer.update( + _frame( + frame_index=0, + seconds=0.0, + owners=[ + ( + "geometry:0", + TrackGeometryOwnerKind.GEOMETRY_CLUSTER, + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + None, + ) + ], + ) + ) + + +def test_temporal_layer_rejects_non_monotonic_replay() -> None: + layer = TemporalOccupiedLayer(_config()) + layer.update(_frame(frame_index=0, seconds=0.0, owners=[])) + + with pytest.raises( + TemporalOccupiedLayerError, + match="frame order changed", + ): + layer.update(_frame(frame_index=2, seconds=0.1, owners=[]))