feat(observatory): ship modular AI inference labs

This commit is contained in:
DCCONSTRUCTIONS
2026-09-04 17:59:05 +03:00
parent eff60e490a
commit cada687173
145 changed files with 17651 additions and 1667 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Синхронизированная запись</title>
<style>
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<div id="viewer"></div>
<script type="module" src="/src/components/rerun/isolatedRerunEntry.ts"></script>
</body>
</html>
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { createIsolatedRerunHost } from "./rerun/isolatedRerunHost";
import { keepRecordedBlueprintSession } from "../core/observation/recordedBlueprintLifecycle";
import {
RECORDED_RERUN_ORBITAL_EYE,
RECORDED_RERUN_PLAN_EYE,
type RecordedRerunCameraEye,
} from "./rerun/recordedRerunCameraJournal";
import type { SceneSettings } from "../sceneSettings";
import {
@@ -138,6 +145,15 @@ export interface RerunViewportProps {
interface RerunBlueprintChannel {
endpointUrl: string;
cameraContract?: string | null;
configureCameraJournal?: (
eye: RecordedRerunCameraEye,
spatialViewportStart: number,
) => void;
getCameraEye?: () => RecordedRerunCameraEye;
setCameraViewportStart?: (spatialViewportStart: number) => void;
getCurrentTimeNs?: () => number | null;
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
channel: {
readonly ready: boolean;
send_rrd: (rrdBytes: Uint8Array) => void;
@@ -166,11 +182,12 @@ interface RecordedRerunIdentity {
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
const LAB_RECORDED_REPLAY_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-replay\.rrd$/;
const PORTABLE_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/portable-results\/m49-tgs-portable-review-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
const PORTABLE_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/portable-results\/(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
const COMPOSITION_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/ai-composition-runs\/ai-composition-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
export function isRecordedRrdSource(path: string): boolean {
return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path)
|| PORTABLE_RECORDED_REPLAY_PATH.test(path);
|| PORTABLE_RECORDED_REPLAY_PATH.test(path) || COMPOSITION_RECORDED_REPLAY_PATH.test(path);
}
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
@@ -267,7 +284,9 @@ export function resolveRecordedBlueprintUrl(
if (explicitSourceUrl !== undefined) {
const explicit = explicitSourceUrl.trim();
if (
!(LAB_RECORDED_REPLAY_PATH.test(normalized) || PORTABLE_RECORDED_REPLAY_PATH.test(normalized))
!(LAB_RECORDED_REPLAY_PATH.test(normalized)
|| PORTABLE_RECORDED_REPLAY_PATH.test(normalized)
|| COMPOSITION_RECORDED_REPLAY_PATH.test(normalized))
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
) return null;
const endpoint = new URL(explicit, `${base.origin}/`);
@@ -393,7 +412,11 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory = false,
semanticLayer,
unifiedPerception,
unifiedCameraShare = 0.46,
planView = false,
cameraEye,
currentTimeNs,
onCameraMaxOrbitalRadius,
perceptionLayers = {
enabled: false,
detections2d: false,
@@ -410,7 +433,11 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory?: boolean;
semanticLayer?: "city" | "vegetation";
unifiedPerception?: boolean;
unifiedCameraShare?: number;
planView?: boolean;
cameraEye?: RecordedRerunCameraEye;
currentTimeNs?: number | null;
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
@@ -426,10 +453,8 @@ export async function fetchRecordedBlueprintRrd(
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
!Number.isFinite(settings.accumulationSeconds) ||
settings.accumulationSeconds < 0 ||
settings.accumulationSeconds > 3600 ||
!Number.isFinite(settings.pointSize) ||
settings.pointSize < 0.1 ||
settings.pointSize > 32 ||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
@@ -437,9 +462,21 @@ export async function fetchRecordedBlueprintRrd(
![0, 1].includes(viewResetGeneration) ||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
typeof resolvedUnifiedPerception !== "boolean" ||
!Number.isFinite(unifiedCameraShare) ||
unifiedCameraShare < 0.1 ||
unifiedCameraShare > 0.9 ||
typeof planView !== "boolean" ||
(cameraEye !== undefined && [
...cameraEye.position,
...cameraEye.lookTarget,
...cameraEye.eyeUp,
].some((value) => !Number.isFinite(value))) ||
(currentTimeNs !== undefined && currentTimeNs !== null && (
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
)) ||
[
perceptionLayers.enabled,
perceptionLayers.cameraImage ?? true,
perceptionLayers.detections2d,
perceptionLayers.segmentation,
perceptionLayers.cuboids3d,
@@ -474,9 +511,17 @@ export async function fetchRecordedBlueprintRrd(
view_reset_generation: viewResetGeneration,
follow_trajectory: followTrajectory,
unified_perception: resolvedUnifiedPerception,
unified_camera_share: unifiedCameraShare,
semantic_layer: semanticLayer ?? null,
plan_view: planView,
eye_position: cameraEye?.position ?? null,
eye_look_target: cameraEye?.lookTarget ?? null,
eye_up: cameraEye?.eyeUp ?? null,
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
current_time_ns: currentTimeNs,
}),
show_detections_2d: perceptionLayers.detections2d,
show_camera_image: perceptionLayers.cameraImage ?? true,
show_segmentation: perceptionLayers.segmentation,
show_cuboids_3d: perceptionLayers.cuboids3d,
...(perceptionLayers.costmap === undefined ? {} : {
@@ -506,9 +551,34 @@ export async function fetchRecordedBlueprintRrd(
) {
throw new Error("Invalid recorded blueprint RRD");
}
const maxOrbitalRadius = Number(
response.headers.get("X-MissionCore-Camera-Max-Orbital-Radius"),
);
if (Number.isFinite(maxOrbitalRadius) && maxOrbitalRadius >= 0.02) {
onCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
return payload;
}
export function recordedCameraJournalContract(
{
activeView,
viewResetGeneration,
planView,
}: {
activeView: RecordedRerunView;
viewResetGeneration: 0 | 1;
planView: boolean;
followTrajectory: boolean;
},
): string {
// Following is a tracking property of the current native eye. It must never
// initialize the browser journal again: doing so replaces the operator's
// current pose with the startup preset immediately before the blueprint is
// sent. Plan/3D and explicit reset are the only preset transitions.
return [activeView, viewResetGeneration, planView].join(":");
}
export async function fetchRecordedPerceptionRrd(
endpointUrl: string,
identity: RecordedRerunIdentity,
@@ -717,6 +787,7 @@ export function RerunViewport({
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
const recordedSemanticLayer = recordedProfile?.semanticLayer;
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
const recordedUnifiedCameraShare = recordedProfile?.unifiedCameraShare ?? 0.46;
const recordedPlanView = recordedProfile?.planView ?? false;
const recordedPerceptionRetryGeneration =
recordedProfile?.perceptionRetryGeneration ?? 0;
@@ -762,9 +833,9 @@ export function RerunViewport({
: sourceUrl
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = sourceUrl
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = recordedBlueprintUrl
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
const presentationStatus = rerunPresentationStatus(
status,
presentationGate,
@@ -836,6 +907,9 @@ export function RerunViewport({
diagnosticLifecycle.verifyBuild();
let disposed = false;
const blueprintOwnerId = crypto.randomUUID().replaceAll("-", "");
blueprintSessionIdRef.current = blueprintOwnerId;
let releaseBlueprintSession = () => {};
let disposeViewer: (() => void) | undefined;
let recordedOpenWatchdog: {
arm: () => void;
@@ -1082,6 +1156,11 @@ export function RerunViewport({
);
}
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
const restoreAfterPageCache = (event: PageTransitionEvent) => {
if (event.persisted) setRetryNonce((nonce) => nonce + 1);
};
window.addEventListener("pagehide", disposeActiveViewerLifecycle);
window.addEventListener("pageshow", restoreAfterPageCache);
host.replaceChildren();
appliedPointColorKeyRef.current = null;
@@ -1089,15 +1168,22 @@ export function RerunViewport({
setRecordingBufferProgress(null);
onStatusChange?.("loading");
void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => {
const isolatedHost = isRecordedSource ? createIsolatedRerunHost(host) : null;
disposeViewer = () => isolatedHost?.dispose();
const nativeViewer = isolatedHost?.ready ?? import("@rerun-io/web-viewer")
.then(({ WebViewer }) => ({ viewer: new WebViewer(), mount: host }));
void nativeViewer
.then(async ({ viewer, mount }) => {
// React StrictMode intentionally disposes the first effect while the
// dynamic import is still pending. Never start that stale viewer (the
// vendor API treats a missing host as document.body).
if (disposed) return;
const viewer = new WebViewer();
if (disposed) {
viewer.stop();
isolatedHost?.dispose();
return;
}
disposeViewer = createReentrantViewerDisposer(() => {
releaseBlueprintSession();
clearRecordingTimers();
clearPlaybackRangeTimer();
playbackTimeUpdates.cancel();
@@ -1155,6 +1241,7 @@ export function RerunViewport({
// A partially initialized WASM handle can already be gone after a
// startup failure.
}
isolatedHost?.dispose();
host.replaceChildren();
});
if (isRecordedSource && recordedArtifact) {
@@ -1191,6 +1278,13 @@ export function RerunViewport({
event.application_id === "nodedc_mission_core_recorded" &&
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id)
) {
releaseBlueprintSession = keepRecordedBlueprintSession({
endpointUrl: recordedBlueprintUrl,
origin: window.location.origin,
applicationId: event.application_id,
recordingId: event.recording_id,
ownerId: blueprintOwnerId,
});
const current = recordedIdentityRef.current;
if (
current?.applicationId !== event.application_id ||
@@ -1500,7 +1594,7 @@ export function RerunViewport({
};
await viewer.start(
rerunViewerInitialSource(resolvedSource),
host,
mount,
viewerOptions,
);
if (disposed) {
@@ -1518,7 +1612,34 @@ export function RerunViewport({
if (recordedBlueprintUrl) {
const channel = viewer.open_channel("missioncore/recorded-blueprint");
blueprintChannel = { endpointUrl: recordedBlueprintUrl, channel };
blueprintChannel = {
endpointUrl: recordedBlueprintUrl,
cameraContract: null,
configureCameraJournal: (eye, spatialViewportStart) => {
if ("configure_camera_journal" in viewer) {
viewer.configure_camera_journal(eye, spatialViewportStart);
}
},
getCameraEye: () => "get_camera_eye" in viewer
? viewer.get_camera_eye()
: RECORDED_RERUN_ORBITAL_EYE,
setCameraViewportStart: (spatialViewportStart) => {
if ("set_camera_viewport_start" in viewer) {
viewer.set_camera_viewport_start(spatialViewportStart);
}
},
getCurrentTimeNs: () => {
const currentIdentity = recordedIdentityRef.current;
if (!currentIdentity) return null;
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
},
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if ("set_camera_max_orbital_radius" in viewer) {
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
}
},
channel,
};
blueprintChannelRef.current = blueprintChannel;
setBlueprintChannelRevision((revision) => revision + 1);
}
@@ -1578,6 +1699,8 @@ export function RerunViewport({
});
return () => {
window.removeEventListener("pagehide", disposeActiveViewerLifecycle);
window.removeEventListener("pageshow", restoreAfterPageCache);
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
activeViewerLifecycleRef.current = null;
}
@@ -1959,6 +2082,22 @@ export function RerunViewport({
!active.channel.ready
) return;
const abort = new AbortController();
const cameraContract = recordedCameraJournalContract(
{
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
planView: recordedPlanView,
followTrajectory: recordedFollowTrajectory,
},
);
if (active.cameraContract !== cameraContract) {
active.configureCameraJournal?.(
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
active.cameraContract = cameraContract;
}
const cameraEye = active.getCameraEye?.();
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
@@ -1969,7 +2108,15 @@ export function RerunViewport({
perceptionLayers: recordedPerceptionLayers,
semanticLayer: recordedSemanticLayer,
unifiedPerception: recordedUnifiedPerception,
unifiedCameraShare: recordedUnifiedCameraShare,
planView: recordedPlanView,
cameraEye,
currentTimeNs: active.getCurrentTimeNs?.(),
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if (blueprintChannelRef.current === active) {
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
},
}).then((payload) => {
if (
abort.signal.aborted ||
@@ -1980,6 +2127,9 @@ export function RerunViewport({
return;
}
active.channel.send_rrd(payload);
active.setCameraViewportStart?.(
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
}).catch(() => {
// The recording remains usable with its embedded default blueprint.
// A later settings change retries through the same small channel.
@@ -1993,8 +2143,10 @@ export function RerunViewport({
recordedFollowTrajectory,
recordedSemanticLayer,
recordedUnifiedPerception,
recordedUnifiedCameraShare,
recordedPlanView,
recordedPerceptionLayers.enabled,
recordedPerceptionLayers.cameraImage,
recordedPerceptionLayers.detections2d,
recordedPerceptionLayers.segmentation,
recordedPerceptionLayers.cuboids3d,
@@ -0,0 +1,33 @@
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveAICompositionReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import type { AICompositionRun } from "../../core/observatory/aiComposition";
function resolveComposition(
runId: string,
launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal },
) {
return resolveAICompositionReplay(runId, launch, options);
}
export function AICompositionReplay({ run }: { run: AICompositionRun }) {
const semantics = run.moduleIds.includes("ddrnet") || run.moduleIds.includes("eomt");
const detections = run.moduleIds.includes("rf-detr")
|| run.moduleIds.includes("object-distance");
const costmap = run.moduleIds.includes("tgs");
return (
<CanonicalResultRerunReplay
key={run.runId}
resultId={run.runId}
sessionId={run.sourceSessionId}
resolveReplay={resolveComposition}
semantics={semantics}
detections={detections}
costmap={costmap}
moduleIds={run.moduleIds}
viewerLayers={run.presentation.viewerLayers}
label={run.configurationLabel}
/>
);
}
@@ -88,6 +88,9 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls,
spatialLayerControls,
spatialLeadingControl,
spatialTrailingControl,
mediaModeControlsVisible = true,
paneToolbarsAlwaysVisible = false,
mediaMultiLayer = false,
mediaContent,
spatialContent,
@@ -116,10 +119,14 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls?: ReactNode;
spatialLayerControls?: ReactNode;
spatialLeadingControl?: ReactNode;
spatialTrailingControl?: ReactNode;
mediaModeControlsVisible?: boolean;
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
paneToolbarsAlwaysVisible?: boolean;
mediaMultiLayer?: boolean;
mediaContent?: ReactNode;
spatialContent?: ReactNode;
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
/** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
unifiedContent?: ReactNode;
emptyMessage: string;
deckOverlays?: ReactNode;
@@ -162,14 +169,14 @@ export function CanonicalRecordedLabReplay<
aria-label={mediaAriaLabel}
hidden={mediaMode === "none"}
>
{splitView ? (
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControls}
{mediaModeControlsVisible ? mediaModeControls : null}
</div>
) : null}
{mediaContent}
@@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay<
data-pane="spatial"
aria-label={spatialAriaLabel}
>
{splitView ? (
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
@@ -190,6 +197,7 @@ export function CanonicalRecordedLabReplay<
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
{spatialTrailingControl}
</div>
</div>
) : null}
@@ -216,7 +224,7 @@ export function CanonicalRecordedLabReplay<
expanded={expanded}
onModeChange={onMediaModeChange}
onExpandedChange={onExpandedChange}
modeControlsVisible={!splitView}
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
actions={actions}
overlay={overlay}
transport={transport}
@@ -241,7 +249,7 @@ export function CanonicalRecordedLabReplay<
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView && !unifiedContent}
resizable={splitView}
separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
@@ -4,14 +4,20 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ActivityIndicator,
Button,
Checker,
ControlRow,
Icon,
SegmentedControl,
IconButton,
Inspector,
InspectorSelectField,
RangeControl,
ToastStack,
Window,
type ToastItem,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
@@ -26,18 +32,24 @@ import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../laboratory/CanonicalRecordedLabReplay";
import {
type CanonicalLabReplayDescriptor,
} from "../../core/laboratory/canonicalLabReplay";
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import { defaultSceneSettings } from "../../sceneSettings";
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
import {
fetchLabViewProfile,
saveLabViewProfile,
} from "../../core/observatory/labViewProfile";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "../../sceneSettings";
type MediaMode = "video" | "camera";
type MediaMode = "camera";
type SpatialMode = "3d" | "plan";
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
type SemanticLayer = "city" | "vegetation";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
@@ -45,7 +57,80 @@ interface CanonicalReplayLaunch {
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
const LAB_SCENE_SETTINGS_STORAGE_PREFIX = "missioncore.observatory.lab-scene-settings.v1:";
const LAB_VIEW_PROFILE_ERROR_TOAST = "observatory-lab-view-profile-error";
const COLOR_OPTIONS: readonly { value: PointColorMode; label: string }[] = [
{ value: "intensity", label: "Интенсивность" },
{ value: "height", label: "Высота Z" },
{ value: "distance", label: "Дистанция" },
{ value: "rgb", label: "RGB" },
{ value: "class", label: "Класс" },
];
const PALETTE_OPTIONS: readonly { value: PointPalette; label: string }[] = [
{ value: "turbo", label: "Turbo" },
{ value: "viridis", label: "Viridis" },
{ value: "plasma", label: "Plasma" },
{ value: "grayscale", label: "Серый" },
];
const LAB_SCENE_DEFAULTS: SceneSettings = {
...defaultSceneSettings,
pointSize: 3.8,
accumulationSeconds: 5,
};
export function normalizeLabSceneSettings(settings: SceneSettings): SceneSettings {
const pointSize = Number.isFinite(settings.pointSize) ? settings.pointSize : LAB_SCENE_DEFAULTS.pointSize;
const accumulationSeconds = Number.isFinite(settings.accumulationSeconds)
? settings.accumulationSeconds : LAB_SCENE_DEFAULTS.accumulationSeconds;
return {
...settings,
pointSize: Math.round(Math.max(0.1, pointSize) * 10) / 10,
accumulationSeconds: Math.round(Math.max(0, accumulationSeconds)),
};
}
function loadLabSceneSettings(resultId: string): SceneSettings {
if (typeof window === "undefined") return LAB_SCENE_DEFAULTS;
try {
const raw = window.localStorage.getItem(`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`);
if (raw === null) return LAB_SCENE_DEFAULTS;
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || Array.isArray(value)) return LAB_SCENE_DEFAULTS;
const row = value as Record<string, unknown>;
const pointSize = row.pointSize;
const accumulationSeconds = row.accumulationSeconds;
const colorMode = row.colorMode;
const palette = row.palette;
const showGrid = row.showGrid;
const showLabels = row.showLabels;
const showCameraFrustums = row.showCameraFrustums;
if (
typeof pointSize !== "number" || !Number.isFinite(pointSize) || pointSize < 0.1
|| typeof accumulationSeconds !== "number" || !Number.isFinite(accumulationSeconds)
|| accumulationSeconds < 0
|| !COLOR_OPTIONS.some((option) => option.value === colorMode)
|| !PALETTE_OPTIONS.some((option) => option.value === palette)
|| typeof showGrid !== "boolean"
|| typeof showLabels !== "boolean"
|| typeof showCameraFrustums !== "boolean"
) return LAB_SCENE_DEFAULTS;
return {
...LAB_SCENE_DEFAULTS,
pointSize,
accumulationSeconds,
colorMode: colorMode as PointColorMode,
palette: palette as PointPalette,
showGrid,
showLabels,
showCameraFrustums,
};
} catch {
return LAB_SCENE_DEFAULTS;
}
}
export function CanonicalResultRerunReplay({
resultId,
@@ -53,15 +138,22 @@ export function CanonicalResultRerunReplay({
initialPlaybackStartSeconds,
resolveReplay,
semantics = false,
detections = false,
costmap = false,
label = "Сохранённый результат · синхронизированный повтор",
moduleIds = [],
viewerLayers = [],
label = "Сохранённый результат",
}: {
resultId: string;
sessionId: string;
initialPlaybackStartSeconds?: number;
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
semantics?: boolean;
detections?: boolean;
costmap?: boolean;
portable?: boolean;
moduleIds?: readonly string[];
viewerLayers?: readonly AIViewerLayer[];
label?: string;
}) {
const {
@@ -75,96 +167,106 @@ export function CanonicalResultRerunReplay({
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "video",
initialMediaMode: "camera",
initialSpatialMode: "3d",
});
const splitView = mediaMode !== null && spatialMode !== null;
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
const [showSemantics, setShowSemantics] = useState(true);
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>(costmap ? "tgs" : "source");
// Give the portable composition its own initial native view identity.
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(costmap ? 1 : 0);
const layerIds = useMemo(
() => new Set(viewerLayers.map((layer) => layer.layerId)),
[viewerLayers],
);
const hasProjectedLayers = viewerLayers.length > 0;
const hasDDRNet = layerIds.has("camera.ddrnet")
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
const hasEoMT = layerIds.has("camera.eomt")
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
const legacyUnclassified = !hasProjectedLayers
&& moduleIds.length === 0
&& !semantics
&& !detections
&& !costmap;
const hasCameraPane = layerIds.has("camera.source")
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
const hasSourcePoints = layerIds.has("spatial.source-points")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasLocalSlam = layerIds.has("spatial.local-slam")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
const splitView = hasCameraPane && hasSpatialPane;
const [showCamera, setShowCamera] = useState(true);
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
const [showEoMT, setShowEoMT] = useState(hasEoMT);
const [showDetections, setShowDetections] = useState(hasDetections);
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
const [showTgs, setShowTgs] = useState(hasTgs);
const [followTarget, setFollowTarget] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0);
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] =
useState<RerunPlaybackController | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const viewerFrameRef = useRef<HTMLDivElement>(null);
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
const sceneDraftRef = useRef(sceneDraft);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
const stopNativeSplitTracking = useCallback(() => {
nativeSplitTrackingCleanupRef.current?.();
nativeSplitTrackingCleanupRef.current = null;
}, []);
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (
!splitView
|| event.button !== 0
|| !(event.target instanceof HTMLCanvasElement)
) return;
const frame = viewerFrameRef.current;
if (!frame) return;
const bounds = frame.getBoundingClientRect();
if (bounds.width <= 0) return;
const dividerX = bounds.left
+ bounds.width * nativeSplitPercentRef.current / 100;
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
stopNativeSplitTracking();
const pointerId = event.pointerId;
const update = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
const currentBounds = frame.getBoundingClientRect();
if (currentBounds.width <= 0) return;
const next = Math.min(90, Math.max(
10,
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
));
nativeSplitPercentRef.current = next;
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
};
const stop = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
stopNativeSplitTracking();
};
window.addEventListener("pointermove", update, true);
window.addEventListener("pointerup", stop, true);
window.addEventListener("pointercancel", stop, true);
nativeSplitTrackingCleanupRef.current = () => {
window.removeEventListener("pointermove", update, true);
window.removeEventListener("pointerup", stop, true);
window.removeEventListener("pointercancel", stop, true);
};
}, [splitView, stopNativeSplitTracking]);
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
useEffect(() => {
if (!splitView) stopNativeSplitTracking();
const launchSha256 = launch?.replay.sha256 ?? null;
if (
trackedLaunchSha256Ref.current !== launchSha256
|| (splitView && !previousSplitViewRef.current)
) {
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
const cameraPanePercent = mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100;
viewerFrameRef.current?.style.setProperty(
"--canonical-rerun-camera-pane",
`${cameraPanePercent}%`,
);
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
useEffect(() => {
if (!splitView) return;
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
return () => window.clearTimeout(timer);
}, [splitPrimarySize, splitView]);
useEffect(() => {
try {
window.localStorage.setItem(
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
JSON.stringify({
pointSize: sceneDraft.pointSize,
accumulationSeconds: sceneDraft.accumulationSeconds,
colorMode: sceneDraft.colorMode,
palette: sceneDraft.palette,
showGrid: sceneDraft.showGrid,
showLabels: sceneDraft.showLabels,
showCameraFrustums: sceneDraft.showCameraFrustums,
}),
);
} catch {
// The visual remains usable when the browser refuses local persistence.
}
}, [resultId, sceneDraft]);
useEffect(() => {
const controller = new AbortController();
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
if (!controller.signal.aborted && settings) {
sceneDraftRef.current = settings;
setSceneDraft(settings);
}
}).catch(() => {
// The per-browser copy remains a usable fallback while the local service recovers.
});
return () => controller.abort();
}, [resultId]);
useEffect(() => {
const controller = new AbortController();
@@ -179,16 +281,12 @@ export function CanonicalResultRerunReplay({
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveReplay(resultId, value, {
signal: controller.signal,
}),
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
})).then((value) => {
if (!controller.signal.aborted) setLaunch(value);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) {
setLaunchError(
caught instanceof Error ? caught.message : "Сохранённая запись недоступна.",
);
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
}
});
return () => controller.abort();
@@ -198,17 +296,15 @@ export function CanonicalResultRerunReplay({
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady
? "ready"
: "loading";
const sceneSettings = useMemo(() => ({
...defaultSceneSettings,
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
showPoints: spatialMode !== null,
showTrajectory: spatialMode !== null,
showGrid: spatialMode !== null,
pointSize: 3.8,
}), [spatialLayer, spatialMode]);
: presentationReady ? "ready" : "loading";
const sceneSettings = useMemo<SceneSettings>(() => ({
...sceneDraft,
projection: spatialMode === "plan" ? "2d" : "3d",
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
showTrajectory: spatialMode !== null && showLocalSlam,
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
const semanticVisible = showDDRNet || showEoMT;
const profile = launch ? recordedSessionRerunProfile({
sourceUrl: launch.replay.sourceUrl,
artifact: {
@@ -222,88 +318,99 @@ export function CanonicalResultRerunReplay({
presentationGate: "ready",
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
// Seek just inside the first portable sample, not onto the exact latest-at
// boundary. This changes only the initial cursor, never source timestamps.
initialPlaybackStartSeconds: initialPlaybackStartSeconds
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
+ (costmap ? 0.000001 : 0)),
view: mediaMode !== null ? "perception" : "spatial",
+ (hasTgs ? 0.000001 : 0)),
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: true,
semanticLayer,
followTrajectory: followTarget,
semanticLayer: showEoMT ? "city" : "vegetation",
unifiedPerception: splitView,
unifiedCameraShare: blueprintSplitPrimarySize / 100,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: mediaMode !== null,
detections2d: semantics && mediaMode === "video",
segmentation: semantics && mediaMode === "video" && showSemantics,
enabled: true,
cameraImage: showCamera,
detections2d: hasDetections && showDetections,
segmentation: semantics && semanticVisible,
cuboids3d: false,
...(costmap ? { costmap: spatialMode !== null && spatialLayer === "tgs" } : {}),
costmap: hasTgs && showTgs,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: mediaMode !== null,
lockPerceptionCameraInteraction: false,
}) : null;
const mediaLayerControls = semantics ? (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Слои камеры и видео"
>
<Button
size="dense"
shape="pill"
variant={showSemantics ? "primary" : "secondary"}
aria-pressed={showSemantics}
onClick={() => setShowSemantics((visible) => !visible)}
>
СЕМАНТИКА
</Button>
<SegmentedControl
value={semanticLayer}
items={[
{ value: "city", label: "ГОРОД · EoMT" },
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
]}
label="Источник семантики"
size="dense"
onChange={(value) => {
setSemanticLayer(value);
setShowSemantics(true);
}}
/>
</div>
) : undefined;
const spatialLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Пространственные слои результата"
>
<SegmentedControl
value={spatialLayer}
items={[
{ value: "source", label: "ИСХ. ТОЧКИ" },
{ value: "local", label: "ЛОК. SLAM" },
{ value: "tgs", label: "TGS", disabled: !costmap },
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
]}
label="Пространственные слои"
size="dense"
onChange={setSpatialLayer}
/>
const toggle = (caption: string, active: boolean, onClick: () => void) => (
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
aria-pressed={active} onClick={onClick}>
{caption}
</Button>
);
const openSlamSettings = useCallback(() => {
if (expanded) onExpandedChange(false);
setSettingsOpen(true);
}, [expanded, onExpandedChange]);
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
const next = { ...sceneDraftRef.current, ...patch };
sceneDraftRef.current = next;
setSceneDraft(next);
}, []);
const closeSlamSettings = useCallback(() => {
const next = normalizeLabSceneSettings(sceneDraftRef.current);
sceneDraftRef.current = next;
setSceneDraft(next);
setSettingsOpen(false);
void saveLabViewProfile(resultId, next).then((settings) => {
sceneDraftRef.current = settings;
setSceneDraft(settings);
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
}).catch(() => {
setToasts((current) => [
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
{
id: LAB_VIEW_PROFILE_ERROR_TOAST,
tone: "error",
title: "Настройки LAB не сохранились",
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
},
]);
});
}, [resultId]);
const mediaLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
{hasDDRNet || hasFallbackSemantics
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
{hasDetections
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
</div>
);
const spatialLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
{hasSourcePoints
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
{hasLocalSlam ? (
<div className="m4-replay-threat-visual__layer-with-settings">
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
<Icon name="settings" size={14} />
</IconButton>
</div>
) : null}
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
</div>
);
const followTargetControl = toggle(
"СЛЕДОВАТЬ",
followTarget,
() => setFollowTarget((value) => !value),
);
const resetSpatialView = (
<Button
size="dense"
variant="ghost"
icon={<Icon name="refresh" size={14} />}
aria-label="Сбросить положение 3D камеры"
title="Сбросить положение 3D камеры"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
>
</Button>
<IconButton label="Сбросить положение видов"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
<Icon name="refresh" size={16} />
</IconButton>
);
const transport = presentationReady && playback && playbackController ? (
@@ -322,65 +429,39 @@ export function CanonicalResultRerunReplay({
showJumpToEnd={false}
/>
) : undefined;
return (
<div
className="canonical-vegetation-rerun-replay"
data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}
>
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}>
<CanonicalRecordedLabReplay
label={label}
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "ВИДЕО" },
{ value: "camera", label: "КАМЕРА" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "ПЛАН" },
]}
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
splitOrientation={splitView ? "vertical" : splitOrientation}
mediaAriaLabel="Камера и рассчитанные слои"
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
spatialTrailingControl={followTargetControl}
mediaModeControlsVisible={false}
paneToolbarsAlwaysVisible
mediaMultiLayer
actions={resetSpatialView}
unifiedContent={profile ? (
<div
ref={viewerFrameRef}
className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}
style={{
"--canonical-rerun-camera-pane": `${
mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100
}%`,
} as CSSProperties}
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
>
<RerunViewport
profile={profile}
sceneSettings={sceneSettings}
onStatusChange={setViewerStatus}
onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController}
/>
<div className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}>
<RerunViewport profile={profile} sceneSettings={sceneSettings}
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController} />
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">
{launchError}
</div>
) : (
<div aria-hidden="true" />
)}
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
) : <div aria-hidden="true" />}
emptyMessage="Слои результата недоступны."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
@@ -392,6 +473,52 @@ export function CanonicalResultRerunReplay({
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
placement="end" size="sm" onClose={closeSlamSettings}>
<Inspector defaultOpen={["points"]} sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: <>
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
exactValueBounds={{ min: 0.1 }}
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
options={[...COLOR_OPTIONS]}
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
options={[...PALETTE_OPTIONS]}
onChange={(palette) => patchSceneDraft({ palette })} />
</>,
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: <RangeControl label="Накопление" min={0} max={500} step={1}
exactValueBounds={{ min: 0 }}
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
},
{
id: "environment",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: <>
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
</>,
},
]} />
</Window>
<ToastStack items={toasts}
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
</div>
);
}
@@ -1,30 +1,51 @@
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
import { GlassSurface } from "@nodedc/ui-react";
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
function resolvePortableReplay(resultId: string, launch: ObservationSessionReplayLaunch,
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
}
export function PortableResultReplay({ review }: { review: ObservatoryPortableResultReview }) {
const tgs = review.resultDocument.schema_version === "missioncore.recorded-tgs-costmap-review/v2";
if (!tgs) return (
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
}
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
}
export function PortableResultReplay({
review,
label,
}: {
review: ObservatoryPortableResultReview;
label?: string;
}) {
const schema = review.resultDocument.schema_version;
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
const module = review.resultDocument.module;
const moduleId = module && typeof module === "object" && "module_id" in module
? module.module_id : undefined;
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|| (schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "ddrnet" || moduleId === "eomt"));
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "rf-detr" || moduleId === "object-distance");
if (!tgs && !semantics && !objects) return (
<GlassSurface className="observatory-replay-state" padding="lg">
<div><StatusBadge tone="success">Проверено</StatusBadge>
<h3>{review.resultKind}</h3><p>Связанных артефактов: {review.artifacts.length}</p>
<details><summary>Документ результата</summary>
<pre>{JSON.stringify(review.resultDocument, null, 2)}</pre></details>
</div>
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
</GlassSurface>
);
return <>
<p>Камера, исходное облако и TGS · общая шкала времени. Зелёный опора,
красный занятое пространство, жёлтый отклонённые точки.
Отсутствие ячеек не означает свободный путь. Сегментация и детекция в этот профиль не входят.</p>
<CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
sessionId={review.sourceSessionId} costmap resolveReplay={resolvePortableReplay} />
</>;
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
detections={objects}
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
label={label}
resolveReplay={tgs ? resolvePortableTgsReplay
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
}
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
FieldFrame,
Select,
StatusBadge,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
aiModuleIdForSetup,
fetchAIModuleCatalog,
fetchAICompositionRuns,
fetchAICompositionJobs,
saveAIComposition,
type AICompositionReceipt,
type AICompositionRun,
type AIGroupId,
type AIModuleCatalog,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
];
export function AIConfigurationWindow({
open, sourceSessionId, sourceLabel, onClose, onCalculated,
}: {
readonly open: boolean;
readonly sourceSessionId: string;
readonly sourceLabel: string;
readonly onClose: () => void;
readonly onCalculated: (receipt: AICompositionReceipt) => void;
}) {
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const request = new AbortController();
setState("loading");
setMessage(null);
void Promise.all([
fetchAIModuleCatalog(request.signal),
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
]).then(([value, jobs, runs]) => {
if (request.signal.aborted) return;
setCatalog(value);
setExistingJobs(jobs);
setExistingRuns(runs);
setSelected({});
setState("ready");
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
setState("error");
});
return () => request.abort();
}, [open, sourceSessionId]);
const existingByModule = useMemo(() => {
const result = new Map<string, ObservatoryRecordedJob>();
for (const job of existingJobs) {
if (job.state === "failed" || job.publication.state === "failed") continue;
const moduleId = aiModuleIdForSetup(job.setupId);
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
}
return result;
}, [existingJobs]);
const selections = useMemo(() => groups.flatMap(({ id }) => {
const moduleId = selected[id];
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
(item) => item.moduleId === moduleId,
);
return module ? [{ group: id, module }] : [];
}), [catalog, selected]);
const selectedModuleIds = useMemo(
() => selections.map(({ module }) => module.moduleId).sort(),
[selections],
);
const matchingRun = useMemo(() => existingRuns.find((run) => (
run.state !== "failed"
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
)), [existingRuns, selectedModuleIds]);
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
const configurationIsRunning = matchingRun?.state === "running";
function selectModule(group: AIGroupId, moduleId: string): void {
if (!catalog) return;
setSelected((current) => {
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
const modules = catalog.groups.flatMap((item) => item.modules);
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
if (moduleId !== "none") {
const module = modules.find((item) => item.moduleId === moduleId);
for (const requirement of module?.requires ?? []) {
if (requirement.startsWith("source.")) continue;
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
continue;
}
const providers = modules.filter((item) => item.provides.includes(requirement));
if (providers.length === 1) {
const providerGroup = catalog.groups.find((item) => item.modules.some(
(candidate) => candidate.moduleId === providers[0]?.moduleId,
))?.group;
if (providerGroup) {
next[providerGroup] = providers[0]?.moduleId;
selectedIds.add(providers[0]?.moduleId ?? "");
}
}
}
} else {
let changed = true;
while (changed) {
changed = false;
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
.flatMap((item) => item.provides));
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
(module) => module.moduleId === item.moduleId,
))?.group;
if (dependentGroup) {
next[dependentGroup] = undefined;
changed = true;
}
}
}
}
}
return next;
});
setMessage(null);
if (state === "error") setState("ready");
}
async function calculate(): Promise<void> {
if (selections.length === 0 || state === "saving") return;
if (configurationAlreadyExists) {
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
return;
}
setState("saving");
setMessage(null);
try {
const receipt = await saveAIComposition(sourceSessionId, selections);
onCalculated(receipt);
onClose();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
setState("error");
}
}
return <Window
open={open}
className="observatory-ai-config-window"
title="Сконфигурировать AI-слой"
subtitle={sourceLabel}
size="lg"
closeOnBackdrop={state !== "saving"}
closeOnEscape={state !== "saving"}
onClose={onClose}
footer={<WindowFooterActions>
{configurationAlreadyExists ? (
<span className="observatory-ai-config__duplicate" role="status">
{configurationIsRunning
? "Текущая конфигурация уже рассчитывается."
: "Текущая конфигурация уже рассчитана."}
</span>
) : null}
<Button
variant="primary"
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
aria-busy={state === "saving"}
onClick={() => void calculate()}
>
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
</Button>
</WindowFooterActions>}
>
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
<p className="observatory-ai-config__lead">
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
</p>
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
{catalog ? groups.map(({ id, label, description }) => {
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
const selectedExisting = selectedModule
? existingByModule.get(selectedModule.moduleId)
: undefined;
const allCalculated = modules.length > 0 && modules.every(
(module) => existingByModule.has(module.moduleId),
);
return <section className="observatory-ai-module-group" key={id}>
<header>
<div>
<strong>{label}</strong>
<span>{description}</span>
</div>
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
{selectedExisting
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
: selectedModule?.label ?? (allCalculated
? "Результат доступен"
: modules.length ? "Не используется" : "Не установлен")}
</StatusBadge>
</header>
<div className="observatory-ai-module-group__body">
<FieldFrame
label={`Модуль: ${label}`}
description={modules.length
? allCalculated
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
: id === "segmentation"
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
: id === "range"
? "Дистанция использует рамки детектора и синхронное облако точек."
: id === "geometry"
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
: "Связи с другими блоками определяются входами выбранного модуля."
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
>
<Select
label={`Выбрать модуль: ${label}`}
value={selected[id] ?? "none"}
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
value: module.moduleId,
label: module.label,
description: existingByModule.has(module.moduleId)
? "Готовый результат будет использован без повторного расчёта"
: module.dockerName,
}))]}
disabled={modules.length === 0 || state === "saving"}
onChange={(value) => selectModule(id, value)}
/>
</FieldFrame>
</div>
</section>;
}) : null}
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
</div>
</Window>;
}
@@ -0,0 +1,10 @@
import { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { createRecordedRerunOwner } from "./recordedRerunOwner";
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
const mount = document.getElementById("viewer");
if (!mount) throw new Error("Recorded viewer mount is missing");
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
@@ -0,0 +1,77 @@
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
export function createIsolatedRerunHost(host: HTMLElement) {
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
frame.title = "Синхронизированная запись";
frame.className = "rerun-viewport__runtime";
frame.src = "/rerun-runtime.html";
let disposed = false;
let releaseFacade: (() => void) | null = null;
let releaseInput: (() => void) | null = null;
let frameWindow: RerunFrameWindow | null = null;
let rejectReady: ((reason: Error) => void) | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
frame!.onload = null;
frame!.onerror = null;
releaseInput?.();
releaseInput = null;
try {
releaseFacade?.();
} catch {
// Realm teardown is required even if a partial native start failed.
} finally {
releaseFacade = null;
frameWindow = null;
frame!.remove();
frame = null;
rejectReady?.(new Error("Recorded viewer was disposed"));
rejectReady = null;
}
};
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
rejectReady = reject;
frame!.onload = () => {
if (disposed) return;
try {
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
const api = frameWindow?.missionCoreRerun;
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
const owner = createRecordedRerunFacade((request, bytes) => {
const current = frameWindow?.missionCoreRerun;
if (!current) throw new Error("Recorded viewer was disposed");
return current.invoke(request, bytes);
});
releaseFacade = owner.dispose;
api.connect(owner.notify);
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
clearTimeout(timer);
rejectReady = null;
frame!.onload = null;
frame!.onerror = null;
resolve({ viewer: owner.facade, mount: host });
} catch (error) {
reject(new Error(String(error)));
dispose();
}
};
frame!.onerror = () => {
reject(new Error("Recorded viewer host failed to load"));
dispose();
};
timer = setTimeout(() => {
reject(new Error("Recorded viewer host timed out"));
dispose();
}, 30_000);
});
host.append(frame);
return { ready, dispose };
}
@@ -0,0 +1,17 @@
/** Relay Escape from the isolated viewer realm to the owning application. */
export function relayIsolatedRerunInput(
host: HTMLElement,
frameWindow: Window | null,
_frame: HTMLIFrameElement | null,
) {
const parent = host.ownerDocument.defaultView;
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
};
frameWindow?.addEventListener("keydown", escape);
return () => {
frameWindow?.removeEventListener("keydown", escape);
frameWindow = null;
};
}
@@ -0,0 +1,244 @@
export type RecordedRerunCameraEye = {
readonly position: readonly [number, number, number];
readonly lookTarget: readonly [number, number, number];
readonly eyeUp: readonly [number, number, number];
};
type Vector3 = [number, number, number];
type DragMode = "rotate" | "pan" | "roll";
const ROTATION_RADIANS_PER_POINT = 0.004;
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
const DOM_WHEEL_DELTA_LINE = 1;
const DOM_WHEEL_DELTA_PAGE = 2;
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
position: [16, -16, 18],
lookTarget: [0, 0, 0],
eyeUp: [0, 0, 1],
};
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
position: [0, 0, 30],
lookTarget: [0, 0, 0],
eyeUp: [0, 1, 0],
};
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a: Vector3, b: Vector3): Vector3 => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const length = (value: Vector3) => Math.hypot(...value);
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
const magnitude = length(value);
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
};
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
const axis = normalize(rawAxis, [0, 0, 1]);
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return add(
add(scale(value, cosine), scale(cross(axis, value), sine)),
scale(axis, dot(axis, value) * (1 - cosine)),
);
};
function cloneEye(value: RecordedRerunCameraEye): {
position: Vector3;
lookTarget: Vector3;
eyeUp: Vector3;
} {
return {
position: vector(value.position),
lookTarget: vector(value.lookTarget),
eyeUp: vector(value.eyeUp),
};
}
/**
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
* renderer. Rerun writes operator navigation into its active blueprint clone;
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
* here lets a later layer blueprint activate with the exact operator pose.
*/
export function createRecordedRerunCameraJournal(
canvas: HTMLCanvasElement,
scope: Window & typeof globalThis,
) {
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
let spatialViewportStart = 0;
let pointerId: number | null = null;
let dragMode: DragMode | null = null;
let previousPointer: readonly [number, number] | null = null;
let latestPointer: readonly [number, number] | null = null;
let maxOrbitalRadius = 1.0e17;
const isSpatialPoint = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
};
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
const usableUp = () => {
const fwd = forward();
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
? [1, 0, 0]
: cross(fwd, [0, 0, 1]);
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
const candidate = normalize(eye.eyeUp, fallback);
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
};
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
const rotate = (deltaX: number, deltaY: number) => {
const radius = orbitRadius();
const up = usableUp();
let fwd = forward();
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
fwd = normalize(
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
fwd,
);
const right = normalize(cross(fwd, up), [1, 0, 0]);
const maxPitch = 0.99 * Math.PI / 2;
const nextPitch = clamp(
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
-maxPitch,
maxPitch,
);
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
};
const pan = (deltaX: number, deltaY: number) => {
const fwd = forward();
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
const speed = 0.001 * orbitRadius();
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
eye.position = add(eye.position, translation);
eye.lookTarget = add(eye.lookTarget, translation);
};
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
const rect = canvas.getBoundingClientRect();
const left = rect.left + rect.width * spatialViewportStart;
const centerX = left + (rect.right - left) / 2;
const centerY = rect.top + rect.height / 2;
const relativeX = event.clientX - centerX;
const relativeY = event.clientY - centerY;
const divisor = relativeX * relativeX + relativeY * relativeY;
if (divisor <= Number.EPSILON) return;
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
};
const pointerDown = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (!isSpatialPoint(event)) return;
pointerId = event.pointerId;
previousPointer = [event.clientX, event.clientY];
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
};
const pointerMove = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
const deltaX = event.clientX - previousPointer[0];
const deltaY = event.clientY - previousPointer[1];
previousPointer = [event.clientX, event.clientY];
if (dragMode === "rotate") rotate(deltaX, deltaY);
else if (dragMode === "pan") pan(deltaX, deltaY);
else roll(event, deltaX, deltaY);
};
const pointerEnd = (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
pointerMove(event);
pointerId = null;
previousPointer = null;
dragMode = null;
};
const wheel = (event: WheelEvent) => {
const rect = canvas.getBoundingClientRect();
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
? event.clientX
: latestPointer?.[0];
if (wheelX === undefined || rect.width <= 0) return;
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
? RERUN_WEB_LINE_SCROLL_POINTS
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
? rect.height
: 1;
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
// eframe negates the DOM delta before Rerun applies
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
// exponent products collapse to the raw accumulated DOM delta below.
const divisor = event.ctrlKey
? RERUN_WEB_PINCH_SCROLL_DIVISOR
: RERUN_ORBITAL_SCROLL_DIVISOR;
const radiusFactor = Math.exp(scrollPoints / divisor);
const offset = subtract(eye.position, eye.lookTarget);
const radius = length(offset);
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
// the scene-derived cap is allowed to stay there and is never snapped in.
const nextRadius = clamp(
radius * radiusFactor,
RERUN_MIN_ORBIT_DISTANCE,
Math.max(radius, maxOrbitalRadius),
);
eye.position = add(
eye.lookTarget,
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
);
};
canvas.addEventListener("pointerdown", pointerDown, true);
scope.addEventListener("pointermove", pointerMove, true);
scope.addEventListener("pointerup", pointerEnd, true);
scope.addEventListener("pointercancel", pointerEnd, true);
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
return {
current(): RecordedRerunCameraEye {
return {
position: [...eye.position],
lookTarget: [...eye.lookTarget],
eyeUp: [...eye.eyeUp],
};
},
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
eye = cloneEye(nextEye);
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
pointerId = null;
dragMode = null;
previousPointer = null;
},
setSpatialViewportStart(nextSpatialViewportStart: number) {
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
},
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
if (
Number.isFinite(nextMaxOrbitalRadius) &&
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
) {
maxOrbitalRadius = nextMaxOrbitalRadius;
}
},
dispose() {
canvas.removeEventListener("pointerdown", pointerDown, true);
scope.removeEventListener("pointermove", pointerMove, true);
scope.removeEventListener("pointerup", pointerEnd, true);
scope.removeEventListener("pointercancel", pointerEnd, true);
scope.removeEventListener("wheel", wheel, true);
},
};
}
@@ -0,0 +1,105 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunInvoke } from "./recordedRerunProtocol";
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
export type RecordedRerunViewer = Pick<WebViewer,
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
"get_playing" | "get_time_range" | "set_active_timeline" |
"set_current_time" | "set_playing"
> & {
open_channel: (name?: string) => Channel;
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
get_camera_eye: () => RecordedRerunCameraEye;
set_camera_viewport_start: (spatialViewportStart: number) => void;
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
};
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
let nextId = 0;
const callbacks = new Map<number, (...args: unknown[]) => void>();
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
if (!invoke) throw new Error("Recorded viewer was disposed");
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
if (response.error) throw new Error(response.error);
return response.result;
};
const dispose = () => {
callbacks.clear();
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
starts.clear();
try { if (invoke) command("stop"); } finally { invoke = null; }
};
const notify = (message: string) => {
if (!invoke) return;
const event = JSON.parse(message);
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
else {
const pending = starts.get(event.id);
starts.delete(event.id);
if (event.type === "started") pending?.resolve();
else pending?.reject(new Error(event.message));
}
};
const facade: RecordedRerunViewer = {
get ready() { return invoke ? command("ready") : false; },
start(source, _parent, options) {
return new Promise<void>((resolve, reject) => {
const id = ++nextId;
starts.set(id, { resolve, reject });
try { command("start", [source, options], id); }
catch (error) { starts.delete(id); reject(error); }
});
},
stop: dispose,
on: ((event: string, callback: (...args: unknown[]) => void) => {
const id = ++nextId;
callbacks.set(id, callback);
try { command("on", [event], id); }
catch (error) { callbacks.delete(id); throw error; }
return () => {
if (callbacks.delete(id) && invoke) command("off", [], id);
};
}) as WebViewer["on"],
open_channel(name) {
const id = ++nextId;
command("channel-open", name === undefined ? [] : [name], id);
let closed = false;
return {
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
close() {
if (closed) return;
closed = true;
if (invoke) command("channel-close", [], id);
},
};
},
open: (...args) => command("open", args),
close: (...args) => { if (invoke) command("close", args); },
override_panel_state: (...args) => command("override_panel_state", args),
get_active_recording_id: () => command("get_active_recording_id"),
get_active_timeline: (...args) => command("get_active_timeline", args),
get_current_time: (...args) => command("get_current_time", args),
get_playing: (...args) => command("get_playing", args),
get_time_range: (...args) => command("get_time_range", args),
set_active_timeline: (...args) => command("set_active_timeline", args),
set_current_time: (...args) => command("set_current_time", args),
set_playing: (...args) => command("set_playing", args),
configure_camera_journal: (eye, spatialViewportStart) => command(
"configure-camera-journal", [eye, spatialViewportStart],
),
get_camera_eye: () => command("get-camera-eye"),
set_camera_viewport_start: (spatialViewportStart) => command(
"set-camera-viewport-start", [spatialViewportStart],
),
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
"set-camera-max-orbital-radius", [maxOrbitalRadius],
),
};
return { facade, dispose, notify };
}
@@ -0,0 +1,115 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
let native: WebViewer | null = null;
let notify: RerunNotify | null = null;
const subscriptions = new Map<number, () => void>();
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
const send = (message: object) => notify?.(JSON.stringify(message));
const stop = () => {
notify = null;
for (const unsubscribe of subscriptions.values()) {
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
}
subscriptions.clear();
for (const channel of channels.values()) {
try { channel.close(); } catch { /* Other channels must still close. */ }
}
channels.clear();
cameraJournal?.dispose();
cameraJournal = null;
const current = native;
native = null;
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
};
const required = () => {
if (!native) throw new Error("Recorded viewer was disposed");
return native;
};
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
const starting = required();
try {
await starting.start(args[0], mount, args[1]);
if (native === starting && starting.canvas) {
cameraJournal?.dispose();
cameraJournal = createRecordedRerunCameraJournal(
starting.canvas,
window as Window & typeof globalThis,
);
}
if (native === starting) send({ type: "started", id });
} catch (error) {
if (native === starting) send({ type: "start-failed", id, message: String(error) });
} finally {
if (native !== starting) {
try { starting.stop(); } catch { /* Late completion after disposal. */ }
}
}
};
return {
version: 2,
connect(callback) {
if (native) throw new Error("Recorded viewer already has an owner");
native = create();
notify = callback;
},
invoke(request, bytes) {
try {
const { method, args = [], id } = JSON.parse(request);
let result: unknown;
switch (method) {
case "stop": stop(); break;
case "start": required(); void start(id, args); break;
case "ready": result = native?.ready ?? false; break;
case "on": {
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
break;
}
case "off":
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
break;
case "channel-open": {
const name = args[0] as string | undefined;
channels.set(id, required().open_channel(name));
break;
}
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
case "channel-send": if (bytes) {
channels.get(id)?.send_rrd(bytes);
} break;
case "channel-close":
try { channels.get(id)?.close(); } finally {
channels.delete(id);
}
break;
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
case "get-camera-eye": result = cameraJournal?.current(); break;
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
case "open": required().open(args[0]); break;
case "close": required().close(args[0]); break;
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
case "get_active_recording_id": result = required().get_active_recording_id(); break;
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
case "get_playing": result = required().get_playing(args[0]); break;
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
case "set_playing": required().set_playing(args[0], args[1]); break;
default: throw new Error("Unsupported recorded viewer command");
}
return JSON.stringify({ result });
} catch (error) {
// A native Error also has a foreign prototype. Return text, never throw
// that object into a parent Promise/callback retained after disposal.
return JSON.stringify({ error: String(error) });
}
},
};
}
@@ -0,0 +1,12 @@
/** Only parent-owned inputs and primitive strings cross the recorded realm.
* An SDK instance, Promise, DOM node or native callback never becomes a value
* retained by React. This is not a second playback clock.
*/
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
export type RerunNotify = (message: string) => void;
export interface RerunFrameApi {
version: 2;
connect: (notify: RerunNotify) => void;
invoke: RerunInvoke;
}
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
@@ -28,19 +28,25 @@ export async function resolveCanonicalLabReplay(
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
sourceKind?: "legacy-vegetation" | "portable-tgs";
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!(sourceKind === "portable-tgs" ? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId) : SAFE_RESULT_ID.test(resultId))
!(sourceKind === "portable-tgs"
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-semantic"
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-objects"
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
: SAFE_RESULT_ID.test(resultId))
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
}
const sourceUrl = sourceKind === "portable-tgs"
const sourceUrl = sourceKind !== "legacy-vegetation"
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
@@ -76,3 +82,53 @@ export async function resolveCanonicalLabReplay(
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
export async function resolveAICompositionReplay(
runId: string,
launch: ObservationSessionReplayLaunch,
{
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
}
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
const response = await fetcher(descriptorUrl.href, {
method: "HEAD",
credentials: "same-origin",
headers: { Accept: "application/vnd.rerun.rrd" },
signal,
});
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
const byteLength = Number(response.headers.get("Content-Length"));
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
if (
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
) {
throw new Error("Составной replay LAB не прошёл проверку.");
}
return {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
byteLength,
sha256,
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
@@ -0,0 +1,53 @@
/** Small lease for the server's ephemeral blueprint, never for the recording. */
export function keepRecordedBlueprintSession({
endpointUrl,
origin,
applicationId,
recordingId,
ownerId,
fetcher = fetch,
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
cancel = (handle: number) => window.clearInterval(handle),
}: {
endpointUrl: string;
origin: string;
applicationId: string;
recordingId: string;
ownerId: string;
fetcher?: typeof fetch;
schedule?: (callback: () => void) => number;
cancel?: (handle: number) => void;
}): () => void {
const endpoint = new URL(endpointUrl, origin);
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
}
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
const identity = { application_id: applicationId, recording_id: recordingId,
blueprint_session_id: ownerId };
let closed = false;
let pending: AbortController | null = null;
const renew = () => {
if (closed) return;
pending?.abort();
pending = new AbortController();
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
};
const timer = schedule(renew);
return () => {
if (closed) return;
closed = true;
cancel(timer);
pending?.abort();
pending = null;
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "release" }),
keepalive: true, signal: AbortSignal.timeout(5_000),
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
};
}
@@ -0,0 +1,72 @@
import {
decodeObservationSessionCatalog,
ObservationSessionApiError,
ObservationSessionContractError,
type ObservationSessionCatalog,
type ObservationSessionFetch,
type ObservationSessionScope,
} from "./sessionArchive";
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
readonly nextCursor: string | null;
}
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
export async function fetchObservationSessionCatalogPage({
scope,
limit = 100,
cursor = null,
signal,
fetcher = globalThis.fetch,
}: {
scope: ObservationSessionScope;
limit?: number;
cursor?: string | null;
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
}): Promise<ObservationSessionCatalogPage> {
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|| (cursor !== null && !CURSOR.test(cursor))) {
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
}
signal?.throwIfAborted();
const query = new URLSearchParams({ limit: String(limit), scope });
if (scope === "laboratory") query.set("lab_contract", "v3");
query.set("pagination", "cursor-v1");
if (cursor !== null) query.set("cursor", cursor);
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
method: "GET", headers: { Accept: "application/json" }, signal,
});
if (!response.ok) {
throw new ObservationSessionApiError(
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
}
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
}
const page = body as Record<string, unknown>;
if (Object.keys(page).length !== 3
|| page.schema_version !== PAGE_SCHEMA
|| !Object.hasOwn(page, "items")
|| !(page.next_cursor === null
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
}
const catalog = decodeObservationSessionCatalog({ items: page.items });
if (catalog.items.length > limit) {
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
}
signal?.throwIfAborted();
return { ...catalog, nextCursor: page.next_cursor };
}
@@ -24,6 +24,7 @@ export interface RerunPlaybackController {
export interface RecordedPerceptionLayers {
enabled: boolean;
cameraImage?: boolean;
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
@@ -76,6 +77,8 @@ export interface RecordedSessionRerunProfile {
semanticLayer?: "city" | "vegetation";
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
unifiedPerception?: boolean;
/** Controlled camera-column share for the native horizontal Rerun container. */
unifiedCameraShare?: number;
/** Requests the canonical top-down eye without changing the world coordinate frame. */
planView?: boolean;
perceptionRetryGeneration: number;
@@ -0,0 +1,347 @@
import {
decodeObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
const SHA256 = /^[a-f0-9]{64}$/;
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
export interface AIModule {
readonly moduleId: string;
readonly moduleSha256: string;
readonly label: string;
readonly dockerName: string;
readonly requires: readonly string[];
readonly provides: readonly string[];
readonly defaults: Readonly<Record<string, unknown>>;
}
export interface AIModuleGroup {
readonly group: AIGroupId;
readonly modules: readonly AIModule[];
}
export interface AIModuleCatalog {
readonly groups: readonly AIModuleGroup[];
}
export interface AICompositionReceipt {
readonly compositionSha256: string;
readonly created: boolean;
readonly dispatchReady: boolean;
readonly dispatchReason: string;
readonly setupIds: readonly string[];
readonly jobs: readonly ObservatoryRecordedJob[];
readonly run: AICompositionRun;
}
export interface AIViewerLayer {
readonly layerId: string;
readonly paneId: "camera" | "spatial";
readonly label: string;
readonly control: "toggle" | "toggle-with-settings";
readonly order: number;
}
export interface AICompositionPresentation {
readonly configurationLabel: string;
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
readonly viewerLayers: readonly AIViewerLayer[];
}
export interface AICompositionRun {
readonly runId: string;
readonly sourceSessionId: string;
readonly compositionSha256: string;
readonly moduleIds: readonly string[];
readonly setupIds: readonly string[];
readonly jobIds: readonly string[];
readonly resultIds: readonly string[];
readonly createdAtUtc: string;
readonly state: "running" | "ready" | "failed";
readonly configurationLabel: string;
readonly displayName: string | null;
readonly presentation: AICompositionPresentation;
readonly jobs: readonly ObservatoryRecordedJob[];
}
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
ddrnet: "ai-segmentation-ddrnet-v1",
eomt: "ai-segmentation-eomt-v1",
tgs: "m49-tgs-portable-v2",
"rf-detr": "ai-detection-rf-detr-v1",
"object-distance": "ai-range-object-distance-v1",
});
export function aiModuleIdForSetup(setupId: string): string | null {
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
}
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "каталог AI-модулей");
exactKeys(root, ["authority", "groups", "schema_version"]);
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
const groups = array(root.groups).map((value): AIModuleGroup => {
const group = record(value, "группа AI-модулей");
exactKeys(group, ["group", "modules"]);
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
}
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
});
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
}
return { groups };
}
export async function saveAIComposition(
sourceSessionId: string,
selections: readonly { group: AIGroupId; module: AIModule }[],
): Promise<AICompositionReceipt> {
const response = await fetch("/api/v1/observatory/ai-compositions", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: COMPOSITION_SCHEMA,
source_session_id: sourceSessionId,
idempotency_key: createIdempotencyKey(),
selections: selections.map(({ group, module }) => ({
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
parameters: module.defaults,
})),
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "конфигурация AI-слоя");
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
throw new Error("Сервер вернул конфигурацию для другой записи.");
}
const digest = text(root.composition_sha256);
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
const dispatch = record(root.dispatch, "готовность композиции");
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
if (dispatch.ready !== true) {
throw new Error(text(dispatch.reason));
}
const setupIds = array(dispatch.setup_ids).map(text);
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
if (!jobs.length || jobs.length !== setupIds.length
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
throw new Error("Сервер вернул неполную очередь композиции.");
}
const run = compositionRunOf(root.run);
return {
compositionSha256: digest,
created: boolean(dispatch && root.created),
dispatchReady: boolean(dispatch.ready),
dispatchReason: text(dispatch.reason),
setupIds,
jobs,
run,
};
}
export async function fetchAICompositionRuns(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly AICompositionRun[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "запуски композиций");
exactKeys(root, ["items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
throw new Error("Версия запусков AI-композиций изменилась.");
}
return array(root.items).map(compositionRunOf);
}
export async function renameAICompositionRunProjection(
runId: string,
displayName: string,
): Promise<string> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI inference.");
}
const normalized = displayName.trim();
if (!normalized || normalized.length > 160) {
throw new Error("Название результата должно содержать от 1 до 160 символов.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "PATCH",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
display_name: normalized,
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "результат AI inference");
exactKeys(row, ["display_name", "run_id", "schema_version"]);
if (
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|| row.run_id !== runId
|| row.display_name !== normalized
) throw new Error("Сервер не подтвердил переименование результата AI inference.");
return normalized;
}
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI inference.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!response.ok || response.status !== 204) {
throw apiError(await bodyOf(response), response.status);
}
}
function compositionRunOf(value: unknown): AICompositionRun {
const row = record(value, "запуск AI-композиции");
const expected = [
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
"source_session_id", "state",
];
// The receipt embeds the immutable run before its live state projection.
const receiptShape = [
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
"run_id", "schema_version", "setup_ids", "source_session_id",
];
const keys = Object.keys(row);
const projected = keys.length === expected.length;
exactKeys(row, projected ? expected : receiptShape);
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
throw new Error("Версия запуска AI-композиции изменилась.");
}
const sourceSessionId = text(row.source_session_id);
const compositionSha256 = text(row.composition_sha256);
const runId = text(row.run_id);
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
throw new Error("Некорректная идентичность запуска AI-композиции.");
}
const presentation = presentationOf(row.presentation);
const state = projected ? text(row.state) : "running";
if (!new Set(["running", "ready", "failed"]).has(state)) {
throw new Error("Неизвестное состояние запуска AI-композиции.");
}
return {
runId,
sourceSessionId,
compositionSha256,
moduleIds: array(row.module_ids).map(text),
setupIds: array(row.setup_ids).map(text),
jobIds: array(row.job_ids).map(text),
resultIds: projected ? array(row.result_ids).map(text) : [],
createdAtUtc: text(row.created_at_utc),
state: state as AICompositionRun["state"],
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
displayName: projected
? row.display_name === null ? null : text(row.display_name)
: null,
presentation,
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
};
}
function presentationOf(value: unknown): AICompositionPresentation {
const row = record(value, "проекция AI-композиции");
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
throw new Error("Версия проекции AI-композиции изменилась.");
}
const modules = array(row.modules).map((value) => {
const module = record(value, "модуль проекции");
exactKeys(module, ["label", "module_id"]);
return { moduleId: text(module.module_id), label: text(module.label) };
});
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
const layer = record(value, "слой viewer");
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
const paneId = text(layer.pane_id);
const control = text(layer.control);
if (!new Set(["camera", "spatial"]).has(paneId)
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|| typeof layer.order !== "number") {
throw new Error("Некорректная проекция слоя viewer.");
}
return {
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
};
});
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
}
export async function fetchAICompositionJobs(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "очередь AI-слоёв");
exactKeys(root, ["authority", "items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
throw new Error("Версия очереди AI-слоёв изменилась.");
}
return array(root.items).map(decodeObservatoryRecordedJob);
}
function moduleOf(value: unknown): AIModule {
const row = record(value, "AI-модуль");
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
const digest = text(row.module_sha256);
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
return {
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
};
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
return value as Record<string, unknown>;
}
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
}
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
function apiError(body: unknown, status: number): Error {
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `ai-layer:${entropy}`;
}
@@ -1,9 +1,9 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
import {
observatoryRecordedRunBinding,
type ObservatoryRecordedRunBinding,
@@ -164,19 +164,23 @@ function boundedLimit(limit: number): number {
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence {
): ObservatoryEvidence | null {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
// Historical LABs retain their archive and replay adapters. Observatory is
// the portable-profile product; a familiar LAB label is not admission.
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
recordedRun,
};
}
@@ -208,11 +212,11 @@ export function buildObservatoryCatalog(
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
if (!evidence) continue;
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// Both API projections are bounded newest-first windows without a
// cursor. Absence from the source window is not proof of a broken
// relationship and must not be presented as an integrity fault.
// A bounded traversal may stop before this source. Absence alone is
// not proof of a broken relationship or permission to remove data.
unresolvedEvidence.push(evidence);
continue;
}
@@ -236,7 +240,8 @@ export function buildObservatoryCatalog(
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: laboratorySessions.length,
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
+ unresolvedEvidence.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
@@ -253,9 +258,64 @@ export async function fetchObservatoryCatalog({
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservatoryCatalog> {
const safeLimit = boundedLimit(limit);
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
]);
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
const request = new AbortController();
const abort = () => request.abort(signal?.reason);
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
try {
const [sources, laboratories] = await Promise.all([
readCatalogPages("source", safeLimit, fetcher, request.signal),
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
]);
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
return {
...catalog,
window: {
...catalog.window,
sourceLimitReached: sources.hasMore,
laboratoryLimitReached: laboratories.hasMore,
},
};
} finally {
request.abort();
signal?.removeEventListener("abort", abort);
}
}
// Metadata only, never recordings or result artifacts. The cap is an explicit
// partial window, not silent completeness or an unbounded background crawler.
const MAX_CATALOG_PAGES = 256;
async function readCatalogPages(
scope: "source" | "laboratory",
limit: number,
fetcher: ObservationSessionFetch,
signal: AbortSignal,
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
const items: ObservationSessionSummary[] = [];
const seenIds = new Set<string>();
const cursors = new Set<string>();
let cursor: string | null = null;
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
for (const item of page.items) {
if (seenIds.has(item.id)) {
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
}
seenIds.add(item.id);
// Do not accumulate legacy provenance while walking the shared catalog.
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
items.push(item);
} else if (!item.lab) {
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
}
}
cursor = page.nextCursor;
if (cursor === null) return { items, hasMore: false };
if (cursors.has(cursor)) {
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
}
cursors.add(cursor);
}
return { items, hasMore: true };
}
@@ -0,0 +1,104 @@
import type { SceneSettings } from "../../sceneSettings";
const PROFILE_SCHEMA = "missioncore.observatory-lab-view-profile/v1";
export async function fetchLabViewProfile(
resultId: string,
signal?: AbortSignal,
): Promise<SceneSettings | null> {
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
headers: { Accept: "application/json" },
signal,
});
if (response.status === 404) return null;
const body: unknown = await response.json();
if (!response.ok) throw new Error("Профиль отображения LAB не загрузился.");
return decodeProfile(body, resultId);
}
export async function saveLabViewProfile(
resultId: string,
settings: SceneSettings,
signal?: AbortSignal,
): Promise<SceneSettings> {
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
method: "PUT",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: PROFILE_SCHEMA,
result_id: resultId,
scene_settings: {
point_size: settings.pointSize,
accumulation_seconds: settings.accumulationSeconds,
color_mode: settings.colorMode,
palette: settings.palette,
show_grid: settings.showGrid,
show_labels: settings.showLabels,
show_camera_frustums: settings.showCameraFrustums,
},
}),
signal,
});
const body: unknown = await response.json();
if (!response.ok) throw new Error("Настройки LAB не сохранились. Повторите закрытие окна.");
return decodeProfile(body, resultId);
}
function decodeProfile(value: unknown, expectedResultId: string): SceneSettings {
const row = record(value);
exactKeys(row, ["result_id", "scene_settings", "schema_version", "updated_at_utc"]);
if (row.schema_version !== PROFILE_SCHEMA || row.result_id !== expectedResultId) {
throw new Error("Сервер вернул профиль другой LAB.");
}
const settings = record(row.scene_settings);
exactKeys(settings, [
"accumulation_seconds", "color_mode", "palette", "point_size",
"show_camera_frustums", "show_grid", "show_labels",
]);
const pointSize = finite(settings.point_size);
const accumulationSeconds = finite(settings.accumulation_seconds);
const colorMode = settings.color_mode;
const palette = settings.palette;
if (
pointSize < 0.1
|| accumulationSeconds < 0
|| !new Set(["intensity", "height", "distance", "rgb", "class"]).has(String(colorMode))
|| !new Set(["turbo", "viridis", "plasma", "grayscale"]).has(String(palette))
|| typeof settings.show_grid !== "boolean"
|| typeof settings.show_labels !== "boolean"
|| typeof settings.show_camera_frustums !== "boolean"
) throw new Error("Сервер вернул повреждённый профиль LAB.");
return {
projection: "3d",
pointSize,
colorMode: colorMode as SceneSettings["colorMode"],
palette: palette as SceneSettings["palette"],
customColor: "#35d7c1",
accumulationSeconds,
showPoints: true,
showTrajectory: true,
showGrid: settings.show_grid,
showLabels: settings.show_labels,
showCameraFrustums: settings.show_camera_frustums,
};
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Некорректный профиль отображения LAB.");
}
return value as Record<string, unknown>;
}
function finite(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("Некорректное числовое значение профиля LAB.");
}
return value;
}
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
if (Object.keys(value).sort().join() !== [...expected].sort().join()) {
throw new Error("Контракт профиля отображения LAB изменился.");
}
}
@@ -86,7 +86,7 @@ export async function fetchObservatoryRecordedJobs(
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
observationAuthority(row.authority);
return array(row.items, "items").map(decodeJob).filter((job) => (
return array(row.items, "items").map(decodeObservatoryRecordedJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
));
@@ -125,7 +125,7 @@ export async function submitObservatoryRecordedJob(
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
const job = decodeObservatoryRecordedJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
throw new ObservatoryRecordedJobContractError(
@@ -156,7 +156,7 @@ export async function retryObservatoryRecordedJobPublication(
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
const job = decodeObservatoryRecordedJob(body);
if (job.jobId !== jobId) {
throw new ObservatoryRecordedJobContractError(
"Повтор публикации вернул другой расчёт.",
@@ -165,7 +165,7 @@ export async function retryObservatoryRecordedJobPublication(
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob {
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
@@ -20,6 +20,8 @@ export interface RecordedProgressView {
readonly completed: number;
readonly total: number | null;
readonly ageSeconds: number;
readonly elapsedSeconds: number;
readonly phaseElapsedSeconds: number;
}
export async function fetchRecordedProgress(
@@ -71,7 +73,8 @@ export function decodeRecordedProgress(
const completed = integer(progress.completed);
const total = progress.total === null ? null : integer(progress.total, 1);
const elapsed = finite(progress.elapsed_seconds);
if ((total !== null && completed > total) || finite(progress.phase_elapsed_seconds) > elapsed) {
const phaseElapsed = finite(progress.phase_elapsed_seconds);
if ((total !== null && completed > total) || phaseElapsed > elapsed) {
throw new Error("Некорректные счётчики прогресса.");
}
integer(progress.phase_index);
@@ -79,6 +82,7 @@ export function decodeRecordedProgress(
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
};
}
@@ -87,6 +91,8 @@ export function recordedProgressLabel(
): string {
if (job?.publication.state === "pending") return "Сохраняем результат";
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
if (job.state === "failed") return "Ошибка расчёта";
if (job.state === "succeeded") return "Расчёт завершён";
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
if (!progress || progress.jobId !== job.jobId
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import {
AI_MODULE_SETUP_IDS,
fetchAIModuleCatalog,
fetchAICompositionJobs,
fetchAICompositionRuns,
type AICompositionRun,
} from "./aiComposition";
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
import type { ObservatoryRecordedJob } from "./recordedJobs";
const OPEN = new Set(["accepted", "queued", "claimed", "running", "paused",
"preemption-pending", "reconciliation-required"]);
interface Snapshot {
readonly sourceSessionId: string;
readonly jobs: readonly ObservatoryRecordedJob[];
readonly runs: readonly AICompositionRun[];
readonly moduleLabelsBySetup: Readonly<Record<string, string>>;
readonly progress: Readonly<Record<string, RecordedProgressView>>;
readonly error: string | null;
}
export function useAICompositionJobs(sourceSessionId: string) {
const [revision, setRevision] = useState(0);
const [snapshot, setSnapshot] = useState<Snapshot>({
sourceSessionId: "", jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
});
const current = snapshot.sourceSessionId === sourceSessionId ? snapshot : {
sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
};
useEffect(() => {
if (!sourceSessionId) {
setSnapshot({ sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null });
return;
}
const request = new AbortController();
void Promise.all([
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
fetchAIModuleCatalog(request.signal),
]).then(async ([jobs, runs, catalog]) => {
const active = jobs.filter((job) => OPEN.has(job.state));
const samples = await Promise.all(active.map(async (job) => [
job.jobId, await fetchRecordedProgress(job, { signal: request.signal }).catch(() => null),
] as const));
if (request.signal.aborted) return;
const catalogLabels = catalog.groups.flatMap(({ modules }) => (
modules.flatMap((module) => {
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
return setupId ? [[setupId, module.label] as const] : [];
})
));
const projectedLabels = runs.flatMap((run) => run.presentation.modules.flatMap((module) => {
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
return setupId ? [[setupId, module.label] as const] : [];
}));
setSnapshot({
sourceSessionId, jobs, runs,
moduleLabelsBySetup: Object.fromEntries([...catalogLabels, ...projectedLabels]),
progress: Object.fromEntries(samples.filter(
(sample): sample is readonly [string, RecordedProgressView] => sample[1] !== null,
)),
error: null,
});
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setSnapshot((value) => ({
sourceSessionId, jobs: value.sourceSessionId === sourceSessionId ? value.jobs : [],
runs: value.sourceSessionId === sourceSessionId ? value.runs : [],
moduleLabelsBySetup: value.sourceSessionId === sourceSessionId
? value.moduleLabelsBySetup : {},
progress: value.sourceSessionId === sourceSessionId ? value.progress : {},
error: error instanceof Error ? error.message : "Очередь AI-слоёв недоступна.",
}));
});
return () => request.abort();
}, [revision, sourceSessionId]);
const active = current.runs.some((run) => run.state === "running")
|| current.jobs.some((job) => OPEN.has(job.state) || job.publication.state === "pending");
useEffect(() => {
if (!active) return;
const timer = globalThis.setTimeout(() => setRevision((value) => value + 1), 1_500);
return () => globalThis.clearTimeout(timer);
}, [active, revision]);
return {
jobs: current.jobs,
runs: current.runs,
moduleLabelsBySetup: current.moduleLabelsBySetup,
progress: current.progress,
error: current.error,
refresh: useCallback(() => setRevision((value) => value + 1), []),
};
}
+2 -2
View File
@@ -171,9 +171,9 @@ export const workspaces: WorkspaceDefinition[] = [
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
title: "AI inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
@@ -89,16 +89,6 @@
min-height: 0;
}
.canonical-vegetation-rerun-replay__viewport-lock
.rerun-viewport__camera-lock {
width: var(--canonical-rerun-camera-pane, 100%);
}
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
.rerun-viewport__camera-lock {
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
}
.canonical-vegetation-rerun-replay__loading {
position: absolute;
z-index: 6;
@@ -155,29 +145,16 @@
display: flex;
min-width: 0;
align-items: center;
align-content: flex-start;
flex-wrap: wrap;
gap: 0.35rem;
pointer-events: none;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
justify-content: flex-end;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"] {
flex-wrap: wrap;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
> .m4-replay-threat-visual__pane-layer-controls {
flex: 1 0 100%;
justify-content: flex-start;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
> .m4-replay-threat-visual__pane-mode-controls {
margin-left: auto;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]:has(
.m4-replay-threat-visual__review-controls
) .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
@@ -185,12 +162,17 @@
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
justify-content: space-between;
justify-content: flex-start;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
right: 3.8rem;
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"],
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.m4-replay-threat-visual__deck:not([data-split="true"])
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
/* The two viewer-wide actions stay at the deck's right edge. Keep their
complete hit area out of this viewport-owned toolbar at every split. */
right: 7rem;
}
.m4-replay-threat-visual__pane-toolbar > *,
@@ -199,18 +181,15 @@
}
.m4-replay-threat-visual__spatial-toolbar-end {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
display: contents;
}
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
display: none;
.m4-replay-threat-visual__pane-toolbar > .m4-replay-threat-visual__pane-layer-controls,
.m4-replay-threat-visual__spatial-toolbar-end > .m4-replay-threat-visual__pane-layer-controls {
/* A viewport toolbar is one adaptive flex sequence. `display: contents`
keeps the semantic layer group while allowing each control to move to the
next row only when the viewport itself runs out of width. */
display: contents;
}
.m4-replay-threat-visual__pane-mode-controls {
@@ -259,7 +238,8 @@
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--nodedc-glass-outline);
border: 0;
outline: 0;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-floating-surface);
padding: 0.42rem 0.58rem;
@@ -280,6 +260,15 @@
pointer-events: none;
}
.canonical-vegetation-rerun-replay
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.laboratory-evidence-viewer__controls {
right: 0.6rem;
left: auto;
width: auto;
justify-content: flex-end;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
pointer-events: auto;
}
@@ -304,6 +293,30 @@
scrollbar-width: none;
}
.m4-replay-threat-visual__layer-with-settings {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.18rem;
border: 0;
border-radius: 999px;
background: var(--nodedc-glass-control-bg);
padding: 0.12rem;
}
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button {
width: 1.75rem;
height: 1.75rem;
border: 0;
outline: 0;
box-shadow: none;
}
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button:focus-visible {
background: var(--nodedc-focus-surface);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
.m4-replay-threat-visual__review-controls,
.m4-replay-threat-visual__review-controls > *,
.m4-replay-threat-visual__single-pane-controls,
+246 -7
View File
@@ -10,7 +10,6 @@
container-type: inline-size;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
@@ -21,18 +20,15 @@
gap: 1rem;
}
.observatory-lead,
.observatory-notice {
flex-wrap: wrap;
}
.observatory-lead > div,
.observatory-catalog-bar__copy,
.observatory-evidence header > div {
min-width: 0;
}
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
@@ -47,7 +43,6 @@
line-height: 1.15;
}
.observatory-lead p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
@@ -197,6 +192,50 @@
padding: 0 0.2rem;
}
.observatory-evidence__ordering {
display: flex;
flex: none;
align-items: center;
gap: 0.55rem;
}
.observatory-evidence__sort {
display: grid;
width: 1.7rem;
height: 1.7rem;
place-items: center;
border: 0;
outline: 0;
border-radius: 0;
background: transparent;
padding: 0;
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.observatory-evidence__sort:hover,
.observatory-evidence__sort:focus-visible {
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-primary);
}
.observatory-evidence__sort svg {
width: 1.1rem;
height: 1.1rem;
transition: transform 160ms ease-out;
}
.observatory-evidence__sort path {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-width: 1.5;
}
.observatory-evidence__sort[data-direction="oldest"] svg {
transform: rotate(180deg);
}
.observatory-evidence-list {
display: grid;
gap: 0.4rem;
@@ -241,6 +280,14 @@
white-space: nowrap;
}
.observatory-evidence-card__copy strong {
overflow: visible;
text-overflow: clip;
white-space: normal;
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.observatory-evidence-card__copy span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
@@ -251,6 +298,28 @@
font-size: var(--nodedc-font-size-xs);
}
.observatory-evidence-card--progress {
grid-template-columns: auto minmax(0, 1fr) auto auto;
border: 0;
outline: 0;
}
.observatory-evidence-card__progress {
height: 0.28rem;
margin: 0.18rem 0 0.08rem;
overflow: hidden;
border-radius: 999px;
background: rgb(var(--nodedc-accent-rgb) / 0.12);
}
.observatory-evidence-card__progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: rgb(var(--nodedc-accent-rgb));
transition: width 180ms ease-out;
}
.observatory-evidence-card__actions,
.observatory-replay__header,
.observatory-replay-state,
@@ -282,6 +351,7 @@
.observatory-replay {
display: grid;
gap: 0.85rem;
font-family: var(--nodedc-font-family);
}
.observatory-replay__header {
@@ -293,10 +363,37 @@
margin: 0.3rem 0 0;
}
.observatory-replay__header h3 {
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.observatory-replay__header p,
.observatory-replay-state p {
.observatory-replay-state p,
.observatory-replay__description {
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
font-family: var(--nodedc-font-family);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-regular);
line-height: 1.35;
}
.observatory-replay__description {
margin-right: 0.25rem;
margin-left: 0.25rem;
}
.observatory-replay .m4-replay-threat-visual__buffering {
border: 0;
outline: 0;
}
.observatory-replay .laboratory-evidence-viewer {
border: 0;
outline: 0;
border-radius: 0;
box-shadow: none;
}
.observatory-evidence-empty {
@@ -369,7 +466,6 @@
}
@container observatory-workspace (max-width: 920px) {
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
@@ -399,3 +495,146 @@
flex-direction: column;
}
}
.observatory-ai-config {
display: grid;
gap: var(--nodedc-space-4);
}
.observatory-ai-config__lead,
.observatory-ai-config__error {
margin: 0;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.45;
}
.observatory-ai-config__error {
color: rgb(var(--nodedc-danger-rgb));
}
.observatory-ai-config__duplicate {
margin-right: auto;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.4;
}
@keyframes observatory-ai-calculate-pulse {
0%,
100% {
background-color: var(--nodedc-primary-action-bg);
}
50% {
background-color: color-mix(
in srgb,
var(--nodedc-primary-action-bg) 88%,
var(--nodedc-canvas-soft)
);
}
}
.observatory-ai-config__calculate--saving:disabled {
animation: observatory-ai-calculate-pulse 1.8s ease-in-out infinite;
cursor: progress;
opacity: 1;
}
.observatory-ai-config-window {
border: 0;
outline: 0;
box-shadow: var(--nodedc-shadow-window);
}
.observatory-ai-config-window::before,
.observatory-ai-config-window::after {
display: none;
}
.observatory-ai-module-group {
overflow: hidden;
border: 0;
outline: 0;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
}
.observatory-ai-module-group > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nodedc-space-3);
padding: var(--nodedc-space-3) var(--nodedc-space-4);
border: 0;
outline: 0;
}
.observatory-ai-module-group > header > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.observatory-ai-module-group > header .nodedc-status {
min-width: 0;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
}
.observatory-ai-module-group > header span {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-ai-module-group__body {
padding: var(--nodedc-space-4);
border: 0;
outline: 0;
}
.observatory-ai-module-group__body .nodedc-select-anchor {
width: 100%;
}
.observatory-ai-config-window .nodedc-select-trigger,
.observatory-ai-config-window .nodedc-window__close {
border: 0;
outline: 0;
box-shadow: none;
}
.observatory-ai-config-window .nodedc-select-trigger:focus-visible,
.observatory-ai-config-window .nodedc-window__close:focus-visible {
background-color: var(--nodedc-focus-surface);
box-shadow:
var(--nodedc-glass-control-shadow),
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
@media (max-width: 560px) {
.observatory-ai-config-window .nodedc-window__title {
white-space: normal;
}
.observatory-ai-module-group > header {
align-items: flex-start;
flex-direction: column;
}
.observatory-ai-module-group > header .nodedc-status {
max-width: 100%;
justify-content: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.observatory-ai-config__calculate--saving:disabled {
animation: none;
background-color: color-mix(
in srgb,
var(--nodedc-primary-action-bg) 94%,
var(--nodedc-canvas-soft)
);
}
}
@@ -88,6 +88,13 @@
height: calc(100% + var(--rerun-native-chrome-height));
}
.rerun-viewport__runtime {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.rerun-viewport__canvas canvas {
display: block;
width: 100% !important;
@@ -15,7 +15,9 @@ import {
} from "@nodedc/ui-react";
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
import { AICompositionReplay } from "../../components/laboratory/AICompositionReplay";
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
import { AIConfigurationWindow } from "../../components/observatory/AIConfigurationWindow";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
import {
@@ -32,9 +34,14 @@ import {
renameObservatoryLabProjection,
} from "../../core/observatory/catalogMutations";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
import { recordedProgressLabel } from "../../core/observatory/recordedProgress";
import { useAICompositionJobs } from "../../core/observatory/useAICompositionJobs";
import {
deleteAICompositionRunProjection,
renameAICompositionRunProjection,
type AICompositionRun,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
import type { RecordedProgressView } from "../../core/observatory/recordedProgress";
import type { WorkspaceDefinition } from "../../productModel";
const EMPTY_OBSERVATORY_ITEMS = [] as const;
@@ -53,8 +60,31 @@ type ObservatoryMutationReconciliation =
readonly sessionId: string;
};
type EvidenceSortDirection = "newest" | "oldest";
type ObservatoryEvidenceRow =
| {
readonly kind: "composition";
readonly id: string;
readonly timestamp: string;
readonly run: AICompositionRun;
}
| {
readonly kind: "job";
readonly id: string;
readonly timestamp: string;
readonly job: ObservatoryRecordedJob;
}
| {
readonly kind: "evidence";
readonly id: string;
readonly timestamp: string;
readonly evidence: ObservatoryEvidence;
};
type ObservatoryReplayState =
| { readonly kind: "closed" }
| { readonly kind: "composition"; readonly run: AICompositionRun }
| {
readonly kind: "loading";
readonly binding: ObservatoryRecordedRunBinding;
@@ -114,15 +144,198 @@ function formatDuration(seconds: number): string {
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function compositionEventTimestamp(run: AICompositionRun): string {
if (run.state === "running" || run.jobs.length === 0) return run.createdAtUtc;
return run.jobs.reduce((latest, job) => (
Date.parse(job.updatedAtUtc) > Date.parse(latest) ? job.updatedAtUtc : latest
), run.createdAtUtc);
}
function compositionProducedEvidence(
run: AICompositionRun,
evidence: ObservatoryEvidence,
): boolean {
if (!run.resultIds.includes(evidence.sessionId)) return false;
const publishedAt = Date.parse(evidence.publishedAtUtc);
const compositionCreatedAt = Date.parse(run.createdAtUtc);
return Number.isFinite(publishedAt) && Number.isFinite(compositionCreatedAt)
&& publishedAt >= compositionCreatedAt;
}
function mutationErrorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось изменить лабораторный результат в Обсерватории.";
}
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
const profileName = evidence.lab.calculationProfile?.displayName;
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
function evidenceConfigurationLabel(
evidence: ObservatoryEvidence,
moduleLabelsBySetup: Readonly<Record<string, string>>,
jobs: readonly ObservatoryRecordedJob[],
): string {
const setupId = evidence.lab.calculationProfile?.setupId
?? jobs.find((job) => job.resultId === evidence.sessionId)?.setupId;
const profileName = setupId
? moduleLabelsBySetup[setupId] ?? evidence.lab.calculationProfile?.displayName
: evidence.lab.calculationProfile?.displayName;
return profileName ?? evidence.label;
}
function aiJobTone(job: ObservatoryRecordedJob): "accent" | "success" | "danger" | "warning" {
if (job.state === "failed" || job.publication.state === "failed") return "danger";
if (job.state === "paused" || job.state === "preemption-pending") return "warning";
if (job.state === "succeeded") return "success";
return "accent";
}
const AI_PROGRESS_PHASE_RANGES = {
"source-transfer": [0, 5],
"source-preparation": [5, 25],
computing: [25, 90],
"result-assembly": [90, 95],
"result-transfer": [95, 99],
} as const;
function aiJobProgressPercent(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
): number {
if (job.state === "succeeded") return 100;
if (!progress) return 0;
const [start, end] = AI_PROGRESS_PHASE_RANGES[progress.phase];
if (progress.total === null || progress.total === 0) return start;
const phaseFraction = Math.max(0, Math.min(1, progress.completed / progress.total));
return start + ((end - start) * phaseFraction);
}
function aiJobStage(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
): string {
if (job.state === "failed" || job.publication.state === "failed") return "Ошибка";
if (job.state === "accepted" || job.state === "queued" || job.state === "claimed" || !progress) {
return "Инициализация";
}
if (progress.phase === "source-transfer" || progress.phase === "source-preparation") {
return "Передача на сервер";
}
return "Просчёт";
}
function computingStageDetail(
job: ObservatoryRecordedJob,
progress: RecordedProgressView,
recordingDurationSeconds: number,
): string {
if (job.setupId === "ai-range-object-distance-v1") {
if (progress.completed === 0) {
return "Подготавливаем камеру и LiDAR";
}
if (progress.completed === 1) {
return "Собираем видеоряд для модели";
}
if (progress.completed === 2) {
return "RF-DETR обрабатывает запись · измеренная скорость Worker 006: 1718 FPS";
}
if (progress.completed === 3) return "Сопоставляем рамки с LiDAR · осталось несколько минут";
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-segmentation-ddrnet-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `DDRNet обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-segmentation-eomt-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `EoMT обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-detection-rf-detr-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `RF-DETR обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
return "Обрабатываем запись выбранным модулем";
}
function aiJobStageDetail(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
recordingDurationSeconds: number,
): string {
if (job.state === "failed") {
return "Не удалось запустить модель. Конфигурацию можно отправить повторно";
}
if (job.publication.state === "failed") {
return "Результат рассчитан, но не сохранён в Обсерватории";
}
if (job.state === "succeeded") {
if (job.publication.state === "pending") return "Завершаем сохранение результата";
if (job.publication.state === "not-required") return "Публикация результата не требуется";
return "Результат сохранён в Обсерватории";
}
if (!progress) {
return "Готовим конфигурацию и место в очереди";
}
if (progress.phase === "source-transfer") {
if (progress.total !== null && progress.total > 0) {
return `${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} файлов`;
}
return "Копируем исходную запись на Worker 006";
}
if (progress.phase === "source-preparation") return "Проверяем целостность переданных данных";
if (progress.phase === "computing") {
return computingStageDetail(job, progress, recordingDurationSeconds);
}
return "Собираем, передаём и сохраняем результат";
}
function presentAIJob(job: ObservatoryRecordedJob): boolean {
if (job.state === "failed") return true;
if (job.publication.state === "pending" || job.publication.state === "failed") return true;
return job.state !== "succeeded";
}
function presentLatestAIJobs(
jobs: readonly ObservatoryRecordedJob[],
): readonly ObservatoryRecordedJob[] {
const seenSetups = new Set<string>();
return jobs.filter((job) => {
if (seenSetups.has(job.setupId)) return false;
seenSetups.add(job.setupId);
return presentAIJob(job);
});
}
function compositionProgressPercent(
run: AICompositionRun,
progress: Readonly<Record<string, RecordedProgressView>>,
): number {
if (run.state === "ready") return 100;
if (run.jobs.length === 0) return 0;
return run.jobs.reduce(
(total, job) => total + aiJobProgressPercent(job, progress[job.jobId]),
0,
) / run.jobs.length;
}
function compositionStage(
run: AICompositionRun,
progress: Readonly<Record<string, RecordedProgressView>>,
): string {
if (run.state === "ready") return "Расчёт завершён";
if (run.state === "failed") return "Ошибка расчёта";
const current = run.jobs.find((job) => job.state !== "succeeded") ?? run.jobs[0];
return current ? aiJobStage(current, progress[current.jobId]) : "Инициализация";
}
export function ObservatoryWorkspace({
@@ -132,22 +345,21 @@ export function ObservatoryWorkspace({
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
const recordedJobsController = useObservatoryRecordedJobs(
selectedSessionId,
setupController.selectedSetupId,
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
);
const [aiConfigurationOpen, setAIConfigurationOpen] = useState(false);
const [evidenceSortDirection, setEvidenceSortDirection] = useState<EvidenceSortDirection>("newest");
const aiJobsController = useAICompositionJobs(selectedSessionId);
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
const [compositionRenameTarget, setCompositionRenameTarget] = useState<AICompositionRun | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
const [compositionDeleteTarget, setCompositionDeleteTarget] = useState<AICompositionRun | null>(null);
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutationReconciliation, setMutationReconciliation] = useState<
ObservatoryMutationReconciliation | null
>(null);
const overviewRef = useRef<HTMLElement | null>(null);
const overviewRef = useRef<HTMLDivElement | null>(null);
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
const refreshedPublishedJobRef = useRef<string | null>(null);
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
@@ -165,6 +377,7 @@ export function ObservatoryWorkspace({
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
closeReplay();
setAIConfigurationOpen(false);
setSelectedSessionId(items[0]?.source.id ?? "");
}, [closeReplay, items, selectedSessionId]);
@@ -173,62 +386,54 @@ export function ObservatoryWorkspace({
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence ?? [];
const presentedCompositionRuns = aiJobsController.runs;
const presentedEvidence = (selectedSession?.evidence ?? []).filter(
(evidence) => !presentedCompositionRuns.some(
(run) => compositionProducedEvidence(run, evidence),
),
);
const options = useMemo(() => items.map(({ source, evidence }) => ({
value: source.id,
label: source.label,
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
})), [items]);
const setupOptions = useMemo(() => (
setupController.selectableSetups.map((setup) => ({
value: setup.setupId,
label: setup.displayName,
description: setup.description,
}))
), [setupController.selectableSetups]);
const preflightCandidate = setupController.preflight.kind === "ready"
? setupController.preflight.value
: null;
const runPreflight = preflightCandidate
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
&& preflightCandidate.definitionSha256
=== setupController.selectedSetup?.runDefinition?.definitionSha256
? preflightCandidate
: null;
const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible
&& runPreflight?.outcome === "queueable"
&& runPreflight.submissionAllowed,
const compositionJobIds = useMemo(() => new Set(
presentedCompositionRuns.flatMap((run) => run.jobIds),
), [presentedCompositionRuns]);
const presentedAIJobs = presentLatestAIJobs(aiJobsController.jobs).filter(
(job) => !compositionJobIds.has(job.jobId),
);
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
const publicationFailed = presentedJob?.publication.state === "failed";
const calculationPending = recordedJobsController.activeJob !== null
|| recordedJobsController.publicationPending
|| recordedJobsController.state === "submitting"
|| recordedJobsController.state === "retrying-publication";
const showCalculate = setupController.selectedSetup !== null
&& !calculationPending && !publicationFailed;
const canSubmitRecordedJob = queueSubmissionAllowed
&& setupController.state === "ready"
&& recordedJobsController.state === "ready"
&& !calculationPending && !publicationFailed;
const queueStatusError = setupController.preflight.kind === "error"
? setupController.preflight.message
: recordedJobsController.error
?? (publicationFailed
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
: recordedJobsController.computationFailed
? "Не удалось выполнить расчёт. Можно повторить запуск."
: runPreflight?.outcome === "blocked"
? runPreflight.checks.filter((check) => check.outcome === "fail")
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
: null);
const presentedRows = useMemo<readonly ObservatoryEvidenceRow[]>(() => {
const rows: ObservatoryEvidenceRow[] = [
...presentedCompositionRuns.map((run): ObservatoryEvidenceRow => ({
kind: "composition",
id: run.runId,
timestamp: compositionEventTimestamp(run),
run,
})),
...presentedAIJobs.map((job): ObservatoryEvidenceRow => ({
kind: "job",
id: job.jobId,
timestamp: job.updatedAtUtc,
job,
})),
...presentedEvidence.map((evidence): ObservatoryEvidenceRow => ({
kind: "evidence",
id: evidence.sessionId,
timestamp: evidence.publishedAtUtc,
evidence,
})),
];
const direction = evidenceSortDirection === "newest" ? -1 : 1;
return rows.sort((left, right) => (
direction * (Date.parse(left.timestamp) - Date.parse(right.timestamp))
|| left.id.localeCompare(right.id)
));
}, [evidenceSortDirection, presentedAIJobs, presentedCompositionRuns, presentedEvidence]);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
const replayEvidenceId = replay.kind === "closed"
const replayEvidenceId = replay.kind === "closed" || replay.kind === "composition"
? null
: replay.binding.evidenceSessionId;
const replayEvidence = replayEvidenceId === null
@@ -238,14 +443,13 @@ export function ObservatoryWorkspace({
) ?? null;
useEffect(() => {
const publishedJob = recordedJobsController.jobs.find(
const publishedJob = aiJobsController.jobs.find(
(job) => job.publication.state === "published" && job.resultId !== null,
);
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
refreshedPublishedJobRef.current = publishedJob.jobId;
void controller.refresh();
setupController.refresh();
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
}, [aiJobsController.jobs, controller.refresh]);
useEffect(() => {
if (
@@ -276,8 +480,7 @@ export function ObservatoryWorkspace({
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
if (mutationReconciliation.kind === "delete") setupController.refresh();
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
}, [controller.catalog, mutationPending, mutationReconciliation]);
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
const attempt = replayCoordinatorRef.current.begin();
@@ -307,6 +510,8 @@ export function ObservatoryWorkspace({
const openRename = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setCompositionRenameTarget(null);
setCompositionDeleteTarget(null);
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
@@ -316,13 +521,52 @@ export function ObservatoryWorkspace({
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setCompositionRenameTarget(null);
setCompositionDeleteTarget(null);
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setDeleteTarget(evidence);
}, []);
const openCompositionRename = useCallback((run: AICompositionRun) => {
setRenameTarget(null);
setDeleteTarget(null);
setCompositionDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setRenameValue(run.displayName ?? run.presentation.configurationLabel);
setCompositionRenameTarget(run);
}, []);
const openCompositionDelete = useCallback((run: AICompositionRun) => {
setRenameTarget(null);
setDeleteTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setCompositionDeleteTarget(run);
}, []);
const submitRename = useCallback(async () => {
if (compositionRenameTarget) {
if (mutationPending !== null) return;
setMutationPending("rename");
setMutationError(null);
try {
await renameAICompositionRunProjection(
compositionRenameTarget.runId,
renameValue,
);
setCompositionRenameTarget(null);
aiJobsController.refresh();
} catch (error) {
setMutationError(mutationErrorMessage(error));
} finally {
setMutationPending(null);
}
return;
}
if (!renameTarget?.recordedRun || mutationPending !== null) return;
const requestedDisplayName = renameValue.trim();
const reconciliation: ObservatoryMutationReconciliation = {
@@ -356,9 +600,27 @@ export function ObservatoryWorkspace({
} finally {
setMutationPending(null);
}
}, [controller, mutationPending, renameTarget, renameValue]);
}, [aiJobsController, compositionRenameTarget, controller, mutationPending, renameTarget, renameValue]);
const confirmDelete = useCallback(async () => {
if (compositionDeleteTarget) {
if (mutationPending !== null) return;
setMutationPending("delete");
setMutationError(null);
try {
if (replay.kind === "composition" && replay.run.runId === compositionDeleteTarget.runId) {
flushSync(() => closeReplay());
}
await deleteAICompositionRunProjection(compositionDeleteTarget.runId);
setCompositionDeleteTarget(null);
aiJobsController.refresh();
} catch (error) {
setMutationError(mutationErrorMessage(error));
} finally {
setMutationPending(null);
}
return;
}
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
const reconciliation: ObservatoryMutationReconciliation = {
kind: "delete",
@@ -370,6 +632,7 @@ export function ObservatoryWorkspace({
try {
if (
replay.kind !== "closed"
&& replay.kind !== "composition"
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
) {
flushSync(() => {
@@ -378,7 +641,6 @@ export function ObservatoryWorkspace({
}
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
controller.applyEvidenceDeletion(deleteTarget.sessionId);
setupController.refresh();
setDeleteTarget(null);
setMutationReconciliation(null);
void controller.refresh();
@@ -399,25 +661,16 @@ export function ObservatoryWorkspace({
} finally {
setMutationPending(null);
}
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
}, [aiJobsController, closeReplay, compositionDeleteTarget, controller, deleteTarget, mutationPending, replay]);
return (
<div
ref={overviewRef}
className="observatory-workspace"
data-workspace-id={definition.id}
data-observatory-authority="observation-only"
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
data-observatory-viewer={replay.kind === "ready" || replay.kind === "composition" ? "attached" : "detached"}
>
<section ref={overviewRef} className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>
Записанные источники и строго связанные результаты без запуска тяжёлого
визуализатора и без доступа к управлению аппаратом.
</p>
</div>
</section>
<GlassSurface className="observatory-catalog-bar" padding="sm">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
@@ -436,83 +689,23 @@ export function ObservatoryWorkspace({
menuWidth={460}
onChange={selectSession}
/>
<Select
label="Выбрать сетап лаборатории"
value={setupController.selectedSetupId}
options={setupOptions}
disabled={!selectedSessionId || setupOptions.length === 0}
searchable
searchPlaceholder="Поиск по сетапам"
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
minMenuWidth={360}
menuWidth={500}
onChange={setupController.selectSetup}
/>
<Button
size="compact"
variant="secondary"
disabled={
controller.state === "loading"
|| controller.state === "refreshing"
|| setupController.state === "loading"
|| setupController.state === "refreshing"
}
icon={<Icon name="refresh" />}
onClick={() => {
void controller.refresh();
setupController.refresh();
recordedJobsController.refresh();
}}
variant="primary"
disabled={!selectedSession}
onClick={() => setAIConfigurationOpen(true)}
>
Обновить
Сконфигурировать AI-слой
</Button>
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
{calculationPending ? (
<>
<ActivityIndicator size="compact" />
<span role="status">
{recordedProgressLabel(presentedJob, recordedJobsController.progress)}
</span>
</>
) : null}
{showCalculate ? (
<Button
size="compact"
variant="primary"
disabled={!canSubmitRecordedJob}
onClick={() => {
void recordedJobsController.submit(
setupController.selectedSetup?.origin === "portable-definition"
&& runPreflight?.definitionSha256
&& runPreflight.checkSha256
? {
definitionSha256: runPreflight.definitionSha256,
checkSha256: runPreflight.checkSha256,
}
: null,
);
}}
>
Рассчитать
</Button>
) : null}
</div>
</div>
</GlassSurface>
{queueStatusError ? (
{aiJobsController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{queueStatusError}</span>
{publicationFailed ? (
<Button
size="compact"
variant="ghost"
disabled={recordedJobsController.state === "retrying-publication"}
onClick={() => void recordedJobsController.retryPublication()}
>
Повторить публикацию
</Button>
) : null}
<span className="observatory-notice__copy">{aiJobsController.error}</span>
<Button size="compact" variant="ghost" onClick={aiJobsController.refresh}>
Повторить
</Button>
</GlassSurface>
) : null}
@@ -524,13 +717,6 @@ export function ObservatoryWorkspace({
</GlassSurface>
) : null}
{setupController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{setupController.error}</span>
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{controller.catalog
&& (controller.catalog.window.sourceLimitReached
|| controller.catalog.window.laboratoryLimitReached) ? (
@@ -616,23 +802,150 @@ export function ObservatoryWorkspace({
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
<div className="observatory-evidence__ordering">
<StatusBadge tone={presentedRows.length > 0 ? "accent" : "neutral"}>
{presentedRows.length}
</StatusBadge>
<button
type="button"
className="observatory-evidence__sort"
data-direction={evidenceSortDirection}
aria-label={evidenceSortDirection === "newest"
? "Показать сначала старые результаты"
: "Показать сначала новые результаты"}
title={evidenceSortDirection === "newest"
? "Сначала новые"
: "Сначала старые"}
onClick={() => setEvidenceSortDirection((current) => (
current === "newest" ? "oldest" : "newest"
))}
>
<svg viewBox="0 0 18 18" aria-hidden="true">
<path d="M3 5h12M5 9h10M7 13h8" />
</svg>
</button>
</div>
</header>
{selectedSession.evidence.length > 0 ? (
{presentedRows.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
{presentedRows.map((row) => {
if (row.kind === "composition") {
const { run } = row;
const failed = run.state === "failed";
const active = run.state === "running";
const progressPercent = compositionProgressPercent(run, aiJobsController.progress);
return (
<li key={run.runId}>
<div className={`observatory-evidence-card${active
? " observatory-evidence-card--progress" : ""}`}
aria-busy={active}>
<span className="observatory-evidence-card__icon" aria-hidden="true">
{failed ? <Icon name="alert" size={16} />
: run.state === "ready" ? <Icon name="clipboard" size={18} />
: <ActivityIndicator size="compact" />}
</span>
<div className="observatory-evidence-card__copy">
<strong>{run.displayName ?? run.presentation.configurationLabel}</strong>
{active ? (
<div className="observatory-evidence-card__progress" aria-hidden="true">
<span style={{ width: `${progressPercent}%` }} />
</div>
) : null}
<small>{run.state === "ready"
? formatTimestamp(row.timestamp)
: `${compositionStage(run, aiJobsController.progress)} · ${formatTimestamp(row.timestamp)}`}</small>
</div>
{active ? (
<StatusBadge tone="accent">{Math.round(progressPercent)}%</StatusBadge>
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
<div className="observatory-evidence-card__actions">
{run.state === "ready" ? (
<>
<IconButton
label={`Удалить ${run.displayName ?? run.presentation.configurationLabel} из Обсерватории`}
onClick={() => openCompositionDelete(run)}
>
<Icon name="trash" size={16} />
</IconButton>
<IconButton
label={`Переименовать ${run.displayName ?? run.presentation.configurationLabel}`}
onClick={() => openCompositionRename(run)}
>
<Icon name="edit" size={16} />
</IconButton>
<IconButton label={`Открыть визуальный разбор: ${run.configurationLabel}`}
onClick={() => setReplay({ kind: "composition", run })}>
<Icon name="eye" size={16} />
</IconButton>
</>
) : null}
</div>
</div>
</li>
);
}
if (row.kind === "job") {
const { job } = row;
const progress = aiJobsController.progress[job.jobId];
const progressPercent = aiJobProgressPercent(job, progress);
const moduleLabel = aiJobsController.moduleLabelsBySetup[job.setupId] ?? job.setupId;
const failed = job.state === "failed" || job.publication.state === "failed";
const active = !failed && (
job.state !== "succeeded" || job.publication.state === "pending"
);
return (
<li key={job.jobId}>
<div
className={`observatory-evidence-card${active
? " observatory-evidence-card--progress" : ""}`}
aria-busy={active}
>
<span className="observatory-evidence-card__icon" aria-hidden="true">
{failed
? <Icon name="alert" size={16} />
: job.state === "succeeded"
? <Icon name="check" size={16} />
: <ActivityIndicator size="compact" />}
</span>
<div className="observatory-evidence-card__copy">
<strong>{moduleLabel}</strong>
{active ? (
<div className="observatory-evidence-card__progress" aria-hidden="true">
<span style={{ width: `${progressPercent}%` }} />
</div>
) : null}
<small>
{aiJobStage(job, progress)} · {aiJobStageDetail(
job,
progress,
selectedSession.source.durationSeconds,
)}{progress && !failed && job.state !== "succeeded"
? ` · прошло ${formatDuration(progress.elapsedSeconds)}`
: ""} · {formatTimestamp(row.timestamp)}
</small>
</div>
{active ? (
<StatusBadge tone={aiJobTone(job)}>{Math.round(progressPercent)}%</StatusBadge>
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
</div>
</li>
);
}
const { evidence } = row;
return (
<li key={evidence.sessionId}>
<div className="observatory-evidence-card">
<span className="observatory-evidence-card__icon" aria-hidden="true">
<Icon name="clipboard" size={18} />
</span>
<div className="observatory-evidence-card__copy">
<strong>{evidence.lab.labId}</strong>
<span>{evidenceResultSubtitle(evidence)}</span>
<strong>{evidenceConfigurationLabel(
evidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)}</strong>
<small>
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
{formatTimestamp(row.timestamp)}
</small>
</div>
<div className="observatory-evidence-card__actions">
@@ -668,7 +981,8 @@ export function ObservatoryWorkspace({
</div>
</div>
</li>
))}
);
})}
</ol>
) : (
<div className="observatory-evidence-empty">
@@ -711,6 +1025,20 @@ export function ObservatoryWorkspace({
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
</div>
</GlassSurface>
) : replay.kind === "composition" ? (
<section className="observatory-replay" aria-label="Визуальный разбор конфигурации">
<header className="observatory-replay__header">
<div>
<span className="section-eyebrow">СОХРАНЁННАЯ КОНФИГУРАЦИЯ</span>
<h3>{replay.run.configurationLabel}</h3>
</div>
<Button size="compact" variant="secondary" icon={<Icon name="close" size={14} />}
onClick={returnToOverview}>
Закрыть разбор
</Button>
</header>
<AICompositionReplay run={replay.run} />
</section>
) : replay.kind === "ready" ? (
<section className="observatory-replay" aria-label="Визуальный разбор результата">
<header className="observatory-replay__header">
@@ -718,12 +1046,13 @@ export function ObservatoryWorkspace({
<span className="section-eyebrow">
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
</span>
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
<p>
{replay.review.kind === "canonical-recorded-rerun"
? "Записанный маршрут синхронизирован по общей временной шкале."
: "Просмотр сохранённых данных результата без повторного расчёта."}
</p>
<h3>{replayEvidence && selectedSession
? evidenceConfigurationLabel(
replayEvidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)
: replay.binding.resultId}</h3>
</div>
<Button
size="compact"
@@ -740,7 +1069,14 @@ export function ObservatoryWorkspace({
review={replay.review.review}
/>
) : (
<PortableResultReplay review={replay.review} />
<PortableResultReplay review={replay.review}
label={replayEvidence && selectedSession
? evidenceConfigurationLabel(
replayEvidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)
: replay.binding.resultId} />
)}
</section>
) : null}
@@ -756,7 +1092,7 @@ export function ObservatoryWorkspace({
) : null}
<Window
open={renameTarget !== null}
open={renameTarget !== null || compositionRenameTarget !== null}
title="Переименовать лабораторный результат"
subtitle="Меняется только отображаемое название в Обсерватории"
size="sm"
@@ -765,6 +1101,7 @@ export function ObservatoryWorkspace({
onClose={() => {
if (mutationPending === "rename") return;
setRenameTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -774,6 +1111,7 @@ export function ObservatoryWorkspace({
disabled={mutationPending === "rename"}
onClick={() => {
setRenameTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -808,19 +1146,26 @@ export function ObservatoryWorkspace({
disabled={mutationPending === "rename"}
onChange={(event) => setRenameValue(event.currentTarget.value)}
/>
{mutationError && renameTarget ? (
{mutationError && (renameTarget || compositionRenameTarget) ? (
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
) : null}
</form>
</Window>
<AIConfigurationWindow
open={aiConfigurationOpen && selectedSession !== null}
sourceSessionId={selectedSession?.source.id ?? ""}
sourceLabel={selectedSession?.source.label ?? ""}
onClose={() => setAIConfigurationOpen(false)}
onCalculated={() => aiJobsController.refresh()}
/>
<ConfirmationModal
open={deleteTarget !== null}
open={deleteTarget !== null || compositionDeleteTarget !== null}
title="Удалить результат из Обсерватории?"
description={deleteTarget ? (
description={deleteTarget || compositionDeleteTarget ? (
<div className="observatory-delete-confirmation">
<p>
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
Будут удалены только каталожная проекция <strong>{deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}</strong>
{" "}и её отображаемое название в Обсерватории.
</p>
<p>
@@ -838,6 +1183,7 @@ export function ObservatoryWorkspace({
onClose={() => {
if (mutationPending === "delete") return;
setDeleteTarget(null);
setCompositionDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -125,7 +125,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
assert.match(observatory, /data-observatory-authority="observation-only"/);
assert.match(
observatory,
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
/data-observatory-viewer={replay\.kind === "ready" \|\| replay\.kind === "composition" \? "attached" : "detached"}/,
);
assert.doesNotMatch(
`${observatory}\n${observatoryCore}`,
@@ -144,8 +144,8 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.match(sharedReplay, /useState<0 \| 1>\(costmap \? 1 : 0\)/);
assert.match(sharedReplay, /costmap \? 0\.000001 : 0/);
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
const source = await read(`components/laboratory/${adapter}.tsx`);
assert.match(source, /<CanonicalResultRerunReplay/);
@@ -133,9 +133,12 @@ test("canonical LAB resolves one generation-bound merged RRD", async () => {
});
});
test("portable TGS resolves Core cache for the exact result and base, never a legacy LAB", async () => {
for (const [prefix, sourceKind] of [
["m49-tgs-portable-review", "portable-tgs"],
["lab-v1-eomt-ddrnet", "portable-semantic"],
]) test(`${sourceKind} resolves Core cache for the exact result and base, never a legacy LAB`, async () => {
const base = "a".repeat(64), generation = "b".repeat(64);
const resultId = `m49-tgs-portable-review-${"c".repeat(64)}`;
const resultId = `${prefix}-${"c".repeat(64)}`;
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
sha256: base };
@@ -147,11 +150,14 @@ test("portable TGS resolves Core cache for the exact result and base, never a le
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
};
const options = { sourceKind: "portable-tgs", origin: "http://mission-core.test", fetcher };
const options = { sourceKind, origin: "http://mission-core.test", fetcher };
const replay = await resolveCanonicalLabReplay(resultId, launch, options);
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
assert.equal(replay.blueprintSourceUrl, "/api/v1/observation-sessions/source/blueprint.rrd");
await assert.rejects(resolveCanonicalLabReplay(`lab-v1-vegetation-shadow-${"c".repeat(64)}`, launch, options));
await assert.rejects(resolveCanonicalLabReplay(resultId, launch, {
...options, sourceKind: sourceKind === "portable-tgs" ? "portable-semantic" : "portable-tgs",
}));
await assert.rejects(resolveCanonicalLabReplay(resultId, { ...launch, sha256: "d".repeat(64) }, options));
assert.equal(calls, 1);
});
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, fetchLabViewProfile, saveLabViewProfile, normalizeLabSceneSettings;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
({ fetchLabViewProfile, saveLabViewProfile } = await server.ssrLoadModule(
"/src/core/observatory/labViewProfile.ts"));
({ normalizeLabSceneSettings } = await server.ssrLoadModule(
"/src/components/laboratory/CanonicalResultRerunReplay.tsx"));
});
after(async () => { await server?.close(); });
const serverProfile = {
schema_version: "missioncore.observatory-lab-view-profile/v1",
result_id: "result-1",
scene_settings: {
point_size: 4.7,
accumulation_seconds: 8,
color_mode: "height",
palette: "viridis",
show_grid: false,
show_labels: true,
show_camera_frustums: false,
},
updated_at_utc: "2026-09-04T09:30:00.000Z",
};
test("LAB view profile round-trips through the result-scoped server endpoint", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init = {}) => {
requests.push({ url, init });
return new Response(JSON.stringify(serverProfile), {
status: 200, headers: { "Content-Type": "application/json" },
});
};
try {
const settings = await fetchLabViewProfile("result-1");
assert.equal(settings.pointSize, 4.7);
assert.equal(settings.palette, "viridis");
const saved = await saveLabViewProfile("result-1", settings);
assert.equal(saved.accumulationSeconds, 8);
assert.equal(requests[0].url, "/api/v1/observatory/lab-view-profiles/result-1");
assert.equal(requests[1].init.method, "PUT");
assert.deepEqual(JSON.parse(requests[1].init.body).scene_settings, serverProfile.scene_settings);
} finally {
globalThis.fetch = originalFetch;
}
});
test("LAB view profile fails closed on a foreign result identity", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({
...serverProfile, result_id: "foreign-result",
}), { status: 200, headers: { "Content-Type": "application/json" } });
try {
await assert.rejects(fetchLabViewProfile("result-1"), /другой LAB/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("LAB scene settings normalize typed values before persistence", () => {
assert.deepEqual(
normalizeLabSceneSettings({
pointSize: 28,
accumulationSeconds: 305,
colorMode: "height",
palette: "viridis",
customColor: "#ffffff",
showGrid: false,
showPoints: true,
showTrajectory: true,
showLabels: true,
showCameraFrustums: false,
projection: "3d",
}),
{
pointSize: 28,
accumulationSeconds: 305,
colorMode: "height",
palette: "viridis",
customColor: "#ffffff",
showGrid: false,
showPoints: true,
showTrajectory: true,
showLabels: true,
showCameraFrustums: false,
projection: "3d",
},
);
});
@@ -886,7 +886,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(canonical, /primary=\{mediaPane\}/);
assert.match(canonical, /secondary=\{spatialPane/);
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonical, /resizable=\{splitView\}/);
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/);
@@ -40,6 +40,7 @@ let formatAccumulationDuration;
let resolveRerunSourceUrl;
let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd;
let recordedCameraJournalContract;
let resolveRecordedPerceptionUrl;
let fetchRecordedPerceptionRrd;
let resolveRecordedPerceptionViewerSourceUrl;
@@ -110,6 +111,7 @@ before(async () => {
resolveRerunSourceUrl,
resolveRecordedBlueprintUrl,
fetchRecordedBlueprintRrd,
recordedCameraJournalContract,
resolveRecordedPerceptionUrl,
fetchRecordedPerceptionRrd,
resolveRecordedPerceptionViewerSourceUrl,
@@ -118,7 +120,7 @@ before(async () => {
fetchRecordedPointColorsRrd,
recordedPointColorKey,
isRecordedPlaybackFullyBuffered,
} = await server.ssrLoadModule(
} = await server.ssrLoadModule(
"/src/components/RerunViewport.tsx",
));
({ recordedObservationSources } = await server.ssrLoadModule(
@@ -129,6 +131,25 @@ before(async () => {
));
});
test("follow toggles retain the current recorded camera journal", () => {
const before = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: false,
});
const afterFollowToggle = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: true,
});
const afterPlanToggle = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: true, followTrajectory: true,
});
const afterReset = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 1, planView: false, followTrajectory: false,
});
assert.equal(afterFollowToggle, before);
assert.notEqual(afterPlanToggle, before);
assert.notEqual(afterReset, before);
});
test("observation camera windows tile from the bottom-right above the live timeline", () => {
const bounds = { width: 1280, height: 720 };
const left = initialObservationWindowRect(0, 2, bounds);
@@ -444,6 +465,7 @@ test("recorded replay becomes ready only after the complete declared timeline is
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
const calls = [];
const cameraLimits = [];
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const result = await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
@@ -464,6 +486,9 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
activeView: "perception3d",
viewResetGeneration: 1,
followTrajectory: true,
currentTimeNs: 39_215_263_458,
onCameraMaxOrbitalRadius: value => cameraLimits.push(value),
unifiedCameraShare: 0.73,
perceptionLayers: {
enabled: true,
detections2d: true,
@@ -474,7 +499,10 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: { "Content-Type": "application/vnd.rerun.rrd" },
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"X-MissionCore-Camera-Max-Orbital-Radius": "686.024231",
},
});
},
},
@@ -497,13 +525,20 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
active_view: "perception3d",
view_reset_generation: 1,
follow_trajectory: true,
current_time_ns: 39_215_263_458,
unified_perception: true,
unified_camera_share: 0.73,
semantic_layer: null,
plan_view: false,
eye_position: null,
eye_look_target: null,
eye_up: null,
show_camera_image: true,
show_detections_2d: true,
show_segmentation: false,
show_cuboids_3d: true,
});
assert.deepEqual(cameraLimits, [686.024231]);
await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
@@ -537,6 +572,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
},
);
assert.equal(calls[1].body.unified_perception, false);
assert.equal(calls[1].body.unified_camera_share, 0.46);
assert.equal(calls[1].body.show_cuboids_3d, true);
await assert.rejects(
@@ -63,14 +63,31 @@ function evidence(id, sourceSessionId, publishedAtUtc) {
labId: `LAB-${id}`,
sourceSessionId,
resultKind: "recorded-evidence",
resultId: `result-${id}`,
resultId: id,
sourceResultId: null,
configSha256: "a".repeat(64),
runCreatedAtUtc: publishedAtUtc,
publishedAtUtc,
replayCapability: null,
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review", viewerProfile: "portable-result",
timeline: "result-defined", activation: "explicit", commandsEnabled: false,
},
calculationProfile: null,
provenance: { verdict: "must-not-be-inferred" },
provenance: {
schema_version: "missioncore.observatory-portable-result-publication/v1",
authority: { commands_enabled: false },
calculation_profile: null, calculation_profile_sha256: "1".repeat(64),
job: {}, method: {}, storage: {},
source: { session_id: sourceSessionId },
run_definition: { definition_sha256: "a".repeat(64) },
result_package: { manifest_sha256: "b".repeat(64), artifact_manifest_id: "c".repeat(64) },
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review", viewer_profile: "portable-result",
timeline: "result-defined", activation: "explicit", commands_enabled: false,
},
},
},
};
}
@@ -154,7 +171,9 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify({ items: [] }), {
return new Response(JSON.stringify({
schema_version: "missioncore.observation-session-page/v1", items: [], next_cursor: null,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -175,8 +194,8 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
assert.deepEqual(
calls.map(({ input }) => input).sort(),
[
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
"/api/v1/observation-sessions?limit=50&scope=source",
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3&pagination=cursor-v1",
"/api/v1/observation-sessions?limit=50&scope=source&pagination=cursor-v1",
],
);
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
@@ -203,7 +222,7 @@ test("Observatory exposes bounded-window uncertainty without inventing a broken
);
});
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
test("historical canonical replay stays out of Observatory without modifying its archive record", () => {
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const canonical = evidence(
canonicalResultId,
@@ -227,20 +246,144 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
commandsEnabled: false,
},
};
const before = structuredClone(canonical);
const catalog = buildObservatoryCatalog(
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
[canonical],
);
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
kind: "canonical-recorded-rerun",
evidenceSessionId: canonicalResultId,
sourceSessionId: "20260828T130511Z_viewer_live",
resultId: canonicalResultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
assert.deepEqual(catalog.items[0].evidence, []);
assert.equal(catalog.window.laboratoryCount, 0);
assert.deepEqual(canonical, before);
});
test("only admitted portable evidence is visible regardless of LAB label, age or installed version", () => {
const legacy = evidence("legacy", "source", "2026-09-03T13:00:00Z");
legacy.lab.labId = "LAB M4.9T5";
legacy.lab.replayCapability = null;
const historical = evidence("old-portable", "source", "2026-07-20T10:00:00Z");
historical.lab.labId = "LAB E24"; // Labels must never become an allow/deny list.
const current = evidence("new-portable", "source", "2026-09-03T12:00:00Z");
const catalog = buildObservatoryCatalog(
[source("source", "2026-07-19T10:00:00Z")], [legacy, historical, current],
);
assert.deepEqual(catalog.items[0].evidence.map((item) => item.sessionId), ["new-portable", "old-portable"]);
assert.equal(catalog.window.laboratoryCount, 2);
assert.equal(findObservatoryEvidence(catalog, "legacy"), null);
});
test("a malformed portable binding errors instead of silently disappearing as legacy", () => {
const broken = evidence("broken", "source", "2026-09-03T12:00:00Z");
broken.lab.provenance.source.session_id = "another-source";
assert.throws(() => buildObservatoryCatalog(
[source("source", "2026-07-19T10:00:00Z")], [broken],
), /identity/);
});
function wire(item) {
const lab = item.lab;
return {
id: item.id, label: item.label, started_at_utc: item.startedAtUtc,
completed_at_utc: item.completedAtUtc, status: item.status,
modalities: item.modalities, duration_seconds: item.durationSeconds, replayable: item.replayable,
...(lab ? { lab: {
lab_id: "LAB M4.9T5", source_session_id: lab.sourceSessionId, result_kind: lab.resultKind,
result_id: lab.resultId, source_result_id: lab.sourceResultId, config_sha256: lab.configSha256,
run_created_at_utc: lab.runCreatedAtUtc, published_at_utc: lab.publishedAtUtc,
provenance: lab.provenance, replay_capability: lab.replayCapability ? lab.provenance.replay_capability : null,
calculation_profile: null,
} } : {}),
};
}
function pageResponse(items, nextCursor = null) {
return new Response(JSON.stringify({
schema_version: "missioncore.observation-session-page/v1",
items: items.map(wire), next_cursor: nextCursor,
}), { headers: { "Content-Type": "application/json" } });
}
test("500 sources and later-page results are searchable metadata, with no archive or viewer requests", async () => {
const sources = Array.from({ length: 500 }, (_, index) => source(`source-${index}`, "2026-08-29T10:00:00Z"));
const labs = Array.from({ length: 101 }, (_, index) => {
const item = evidence(`legacy-${index}`, sources[0].id, "2026-08-29T11:00:00Z");
item.lab.replayCapability = null;
return item;
});
labs.push(evidence("portable-after-legacy", sources[499].id, "2026-08-29T12:00:00Z"));
const calls = [];
const catalog = await fetchObservatoryCatalog({ fetcher: async (input, init) => {
const url = new URL(String(input), "http://localhost");
calls.push(url);
assert.equal(init.method, "GET");
assert.equal(url.pathname, "/api/v1/observation-sessions");
const rows = url.searchParams.get("scope") === "source" ? sources : labs;
const cursor = url.searchParams.get("cursor");
const start = cursor === null ? 0 : rows.findIndex((item) => item.id === cursor) + 1;
const page = rows.slice(start, start + 100);
return pageResponse(page, start + page.length < rows.length ? page.at(-1).id : null);
} });
assert.equal(calls.length, 7);
assert.equal(catalog.items.length, 500);
assert.equal(catalog.window.laboratoryCount, 1);
assert.equal(catalog.window.sourceLimitReached, false);
assert.equal(catalog.window.laboratoryLimitReached, false);
assert.equal(catalog.items.find((item) => item.source.id === "source-499").evidence[0].sessionId, "portable-after-legacy");
assert.deepEqual(catalog.unresolvedEvidence, []);
});
test("exactly one full page with no cursor is complete, not a guessed truncated window", async () => {
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) =>
String(url).includes("scope=source")
? pageResponse([source("source", "2026-08-29T10:00:00Z")])
: pageResponse([evidence("result", "source", "2026-08-29T11:00:00Z")]),
});
assert.equal(catalog.window.sourceLimitReached, false);
assert.equal(catalog.window.laboratoryLimitReached, false);
});
test("paging rejects repeated records, cursor cycles, unsafe cursors and unversioned responses", async () => {
for (const failure of ["duplicate", "cycle", "unsafe", "unversioned", "too-large"]) {
let index = 0;
await assert.rejects(fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
if (String(url).includes("scope=laboratory")) return pageResponse([]);
index += 1;
if (failure === "unversioned") return new Response(JSON.stringify({ items: [] }));
if (failure === "unsafe") return pageResponse([], "../../private");
const id = failure === "duplicate" ? "same" : `source-${index}`;
const rows = [source(id, "2026-08-29T10:00:00Z")];
if (failure === "too-large") rows.push(source("extra", "2026-08-29T10:00:00Z"));
return pageResponse(rows, failure === "cycle" ? "cycle" : id);
} }));
assert.ok(index <= 2);
}
});
test("the traversal cap is explicit and cannot confirm absence of a result", async () => {
let count = 0;
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
if (String(url).includes("scope=source")) return pageResponse([]);
count += 1;
return pageResponse([], `cursor-${count}`);
} });
assert.equal(count, 256);
assert.equal(catalog.window.laboratoryLimitReached, true);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(catalog, "missing"), false);
});
test("failure aborts the sibling traversal and an already aborted caller starts no requests", async () => {
const signals = [];
await assert.rejects(fetchObservatoryCatalog({ fetcher: async (url, init) => {
signals.push(init.signal);
if (String(url).includes("scope=source")) return new Response("unavailable", { status: 503 });
return pageResponse([]);
} }), /503/);
assert.ok(signals.every((signal) => signal.aborted));
const request = new AbortController();
request.abort();
await assert.rejects(fetchObservatoryCatalog({ signal: request.signal, fetcher: async () => {
assert.fail("aborted catalog cannot start a request");
} }), { name: "AbortError" });
});
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
@@ -68,6 +68,10 @@ test("missing and stale telemetry show no fabricated advancement", () => {
assert.equal(label(job, null), "Ожидаем данные расчёта");
assert.equal(label(job, decode({ ...view(), age_seconds: 30 }, job)), "Ожидаем обновление прогресса");
});
test("terminal jobs keep their real state when progress telemetry is absent", () => {
assert.equal(label({ ...job, state: "failed" }, null), "Ошибка расчёта");
assert.equal(label({ ...job, state: "succeeded" }, null), "Расчёт завершён");
});
test("progress is a read-only request bound to the active job", async () => {
const calls = [];
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
@@ -35,9 +35,9 @@ test("Observatory is the third independent Polygon workspace", () => {
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
title: "AI inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
@@ -73,9 +73,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
assert.match(workspace, /useObservatoryCatalog/);
assert.doesNotMatch(workspace, /observatory-lead/);
assert.doesNotMatch(workspace, /Расчёт записанных маршрутов выбранными профилями/);
assert.match(workspace, /Связанных результатов нет/);
assert.match(workspace, /не является выводом о качестве/);
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
assert.match(workspace, /function compositionProducedEvidence\(/);
assert.match(workspace, /publishedAt >= compositionCreatedAt/);
assert.match(workspace, /const presentedCompositionRuns = aiJobsController\.runs/);
assert.match(workspace, /presentedEvidence = \(selectedSession\?\.evidence \?\? \[\]\)\.filter/);
assert.match(workspace, /!presentedCompositionRuns\.some\([\s\S]*compositionProducedEvidence\(run, evidence\)/);
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
assert.match(workspace, /Полнота исторических/);
assert.match(workspace, /вне текущего загруженного среза/);
@@ -86,12 +92,13 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.match(workspace, /observatory-evidence-card__copy/);
assert.match(
workspace,
/function evidenceResultSubtitle[\s\S]*calculationProfile\?\.displayName[\s\S]*`\$\{evidence\.label\} · \$\{profileName\}`[\s\S]*: evidence\.label/,
/function evidenceConfigurationLabel[\s\S]*calculationProfile\?\.displayName[\s\S]*return profileName \?\? evidence\.label/,
);
assert.match(
workspace,
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
/<strong>\{evidenceConfigurationLabel\([\s\S]*evidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)\}<\/strong>/,
);
assert.doesNotMatch(workspace, /evidenceConfigurationLabel\([^)]*,\s*selectedSession\.source\.label/);
assert.match(
workspace,
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
@@ -101,9 +108,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
const portable = await read("components/laboratory/PortableResultReplay.tsx");
assert.match(portable, /<summary>Документ результата<\/summary>/);
assert.doesNotMatch(portable, /Документ результата|JSON.stringify|StatusBadge/);
assert.match(portable, /missioncore.recorded-eomt-ddrnet-review\/v2/);
assert.match(portable, /sourceKind: "portable-semantic"/);
assert.match(portable, /sourceKind: "portable-objects"/);
assert.match(portable, /costmap=\{tgs\} semantics=\{semantics\}/);
assert.match(portable, /detections=\{objects\}/);
assert.match(portable, /<CanonicalResultRerunReplay/);
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
assert.doesNotMatch(workspace, /evidence\.lab\.resultKind|без запуска тяжёлого/);
assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/);
@@ -112,7 +125,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
workspace,
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
);
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
assert.match(
workspace,
/<h3>\{replayEvidence && selectedSession[\s\S]*evidenceConfigurationLabel\([\s\S]*replayEvidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)[\s\S]*replay\.binding\.resultId\}<\/h3>/,
);
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
assert.match(workspace, /data-observatory-authority="observation-only"/);
@@ -186,6 +202,10 @@ test("Observatory rename and delete use admitted projection mutations and canoni
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
assert.match(workspace, /renameAICompositionRunProjection/);
assert.match(workspace, /deleteAICompositionRunProjection/);
assert.match(workspace, /openCompositionRename\(run\)/);
assert.match(workspace, /openCompositionDelete\(run\)/);
assert.match(
workspace,
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
@@ -196,9 +216,12 @@ test("Observatory rename and delete use admitted projection mutations and canoni
);
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
const deleteFlowEnd = workspace.indexOf("}, [aiJobsController, closeReplay", deleteFlowStart);
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const wholeDeleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const evidenceFlowStart = wholeDeleteFlow.indexOf("if (!deleteTarget?.recordedRun");
assert.ok(evidenceFlowStart >= 0);
const deleteFlow = wholeDeleteFlow.slice(evidenceFlowStart);
const teardown = deleteFlow.indexOf("closeReplay();");
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
@@ -219,77 +242,128 @@ test("Observatory rename and delete use admitted projection mutations and canoni
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
});
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
test("Observatory configures independent AI modules and shows queued evidence progress", async () => {
const [workspace, styles, configWindow, jobsHook, sharedReplay] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("styles/observatory.css"),
read("core/observatory/useObservatoryLaboratorySetups.ts"),
read("core/observatory/useObservatoryRecordedJobs.ts"),
read("components/observatory/AIConfigurationWindow.tsx"),
read("core/observatory/useAICompositionJobs.ts"),
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
]);
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
assert.match(workspace, /setupController\.error \? \(/);
assert.match(workspace, />\s*Сконфигурировать AI-слой\s*<\/Button>/);
assert.doesNotMatch(workspace, /label="Выбрать сетап лаборатории"/);
assert.doesNotMatch(workspace, />\s*Обновить\s*<\/Button>/);
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
assert.match(workspace, /useObservatoryRecordedJobs/);
assert.doesNotMatch(workspace, /useObservatoryRecordedJobs|useObservatoryLaboratorySetups/);
assert.match(configWindow, /title="Сконфигурировать AI-слой"/);
assert.match(configWindow, /label: "Сегментация"/);
assert.match(configWindow, /Зависимые блоки подключаются явно/);
assert.match(configWindow, /label: "Облако точек \/ TGS"/);
assert.match(configWindow, /label: "Дистанция"/);
assert.match(configWindow, /Дистанция использует рамки детектора и синхронное облако точек/);
assert.match(configWindow, /TGS обрабатывает LiDAR независимо от сегментации и детектора/);
assert.match(configWindow, /providers\.length === 1/);
assert.match(
workspace,
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
configWindow,
/<WindowFooterActions>[\s\S]*"Отправляем на Worker…" : "Рассчитать"/,
);
assert.match(configWindow, /className=\{state === "saving" \? "observatory-ai-config__calculate--saving"/);
assert.match(configWindow, /aria-busy=\{state === "saving"\}/);
assert.match(styles, /@keyframes observatory-ai-calculate-pulse/);
assert.match(
workspace,
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
styles,
/\.observatory-ai-config__calculate--saving:disabled \{[\s\S]*animation: observatory-ai-calculate-pulse 1\.8s ease-in-out infinite;[\s\S]*opacity: 1;/,
);
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
assert.match(workspace, /setupController\.selectableSetups\.map/);
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
assert.match(workspace, /aria-busy=\{calculationPending\}/);
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
assert.doesNotMatch(runBar, /StatusBadge/);
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
assert.match(workspace, /useAICompositionJobs\(selectedSessionId\)/);
assert.match(workspace, /observatory-evidence-card--progress/);
assert.match(workspace, /const active = run\.state === "running"/);
assert.match(workspace, /\{active \? \([\s\S]*observatory-evidence-card__progress/);
assert.match(workspace, /run\.state === "ready" \? <Icon name="clipboard"/);
assert.match(workspace, /function aiJobStage\(/);
assert.match(workspace, /return "Инициализация"/);
assert.match(workspace, /return "Передача на сервер"/);
assert.match(workspace, /return "Просчёт"/);
assert.match(workspace, /Собираем видеоряд для модели/);
assert.match(workspace, /измеренная скорость Worker 006: 1718 FPS/);
assert.doesNotMatch(workspace, /phaseElapsedSeconds \/ progress\.completed/);
assert.match(workspace, /AI_PROGRESS_PHASE_RANGES/);
assert.match(workspace, /"source-preparation": \[5, 25\]/);
assert.match(workspace, /computing: \[25, 90\]/);
assert.match(workspace, /Подготавливаем камеру и LiDAR/);
assert.match(workspace, /прошло.*formatDuration\(progress\.elapsedSeconds\)/s);
assert.match(workspace, /Не удалось запустить модель/);
assert.doesNotMatch(workspace, /job\.terminalMessage|job\.publication\.error/);
assert.doesNotMatch(workspace, /function aiModuleLabel\(/);
assert.match(workspace, /aiJobsController\.moduleLabelsBySetup\[job\.setupId\] \?\? job\.setupId/);
assert.match(jobsHook, /fetchAIModuleCatalog\(request\.signal\)/);
assert.match(jobsHook, /AI_MODULE_SETUP_IDS\[module\.moduleId\]/);
assert.match(jobsHook, /run\.presentation\.modules\.flatMap/);
assert.match(jobsHook, /Object\.fromEntries\(\[\.\.\.catalogLabels, \.\.\.projectedLabels\]\)/);
assert.match(workspace, /<strong>\{moduleLabel\}<\/strong>/);
assert.match(workspace, /<strong>\{run\.displayName \?\? run\.presentation\.configurationLabel\}<\/strong>/);
assert.doesNotMatch(workspace, /<strong>\{selectedSession\.source\.label\} ·/);
assert.match(workspace, /type EvidenceSortDirection = "newest" \| "oldest"/);
assert.match(workspace, /const presentedRows = useMemo<readonly ObservatoryEvidenceRow\[]>/);
assert.match(workspace, /direction \* \(Date\.parse\(left\.timestamp\) - Date\.parse\(right\.timestamp\)\)/);
assert.match(workspace, /className="observatory-evidence__sort"/);
assert.match(workspace, /data-direction=\{evidenceSortDirection\}/);
assert.match(workspace, /formatTimestamp\(row\.timestamp\)/);
assert.match(styles, /\.observatory-evidence-card\s*\{[\s\S]*grid-template-columns:\s*auto minmax\(0, 1fr\) auto auto/);
assert.match(styles, /\.observatory-evidence__sort\s*\{[\s\S]*border:\s*0;[\s\S]*outline:\s*0/);
assert.match(
workspace,
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
styles,
/\.observatory-evidence-card__copy strong \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
);
assert.match(sharedReplay, /LAB_SCENE_SETTINGS_STORAGE_PREFIX/);
assert.match(sharedReplay, /window\.localStorage\.getItem/);
assert.match(sharedReplay, /window\.localStorage\.setItem/);
assert.match(sharedReplay, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
assert.match(sharedReplay, /setSettingsOpen\(false\);[\s\S]*void saveLabViewProfile/);
assert.match(
jobsHook,
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
sharedReplay,
/showPoints: spatialMode !== null && \(showSourcePoints \|\| showLocalSlam\)/,
);
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
assert.match(sharedReplay, /showTrajectory: spatialMode !== null && showLocalSlam/);
assert.match(
sharedReplay,
/accumulationSeconds: showLocalSlam \? sceneDraft\.accumulationSeconds : 0/,
);
assert.match(workspace, /<AICompositionReplay run=\{replay\.run\} \/>/);
assert.match(workspace, /function presentAIJob\(job: ObservatoryRecordedJob\)/);
assert.match(workspace, /if \(job\.state === "failed"\) return true/);
assert.match(workspace, /return job\.state !== "succeeded"/);
assert.match(workspace, /function presentLatestAIJobs/);
assert.match(workspace, /seenSetups\.has\(job\.setupId\)/);
assert.match(configWindow, /fetchAICompositionJobs\(sourceSessionId, request\.signal\)/);
assert.match(
configWindow,
/if \(job\.state === "failed" \|\| job\.publication\.state === "failed"\) continue;/,
);
assert.match(configWindow, /Текущая конфигурация уже рассчитана/);
assert.match(configWindow, /configurationAlreadyExists/);
assert.match(configWindow, /Готовый результат будет использован без повторного расчёта/);
assert.doesNotMatch(configWindow, /disabled: existingByModule\.has\(module\.moduleId\)/);
assert.doesNotMatch(configWindow, /selections\.some\(\(\{ module \}\) => existingByModule/);
assert.match(jobsHook, /const OPEN = new Set/);
assert.match(jobsHook, /1_500/);
assert.match(jobsHook, /globalThis\.setTimeout/);
assert.doesNotMatch(jobsHook, /setInterval/);
assert.match(workspace, /job\.publication\.state === "published"/);
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
assert.match(workspace, /onCalculated=\{\(\) => aiJobsController\.refresh\(\)\}/);
assert.match(
styles,
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
);
assert.match(
styles,
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
);
assert.match(styles, /\.observatory-evidence-card__progress \{/);
assert.match(styles, /\.observatory-ai-module-group > header \{/);
assert.match(styles, /\.observatory-ai-module-group \{[\s\S]*border: 0;[\s\S]*outline: 0;/);
assert.match(
styles,
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
);
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
assert.match(setupHook, /preflightRequest\.current !== request/);
assert.match(
setupHook,
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
);
});
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, createRecordedRerunCameraJournal, initialEye;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
const module = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunCameraJournal.ts");
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
});
after(async () => { await server?.close(); });
class FakeEvent {
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
}
class FakeTarget {
listeners = new Map();
emitted = [];
addEventListener(type, listener) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener); this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
}
dispatchEvent(event) {
this.emitted.push(event);
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
return true;
}
}
const radius = (eye) => Math.hypot(
eye.position[0] - eye.lookTarget[0],
eye.position[1] - eye.lookTarget[1],
eye.position[2] - eye.lookTarget[2],
);
test("recorded orbital eye tracks only 3D viewport navigation", () => {
const scope = new FakeTarget();
const timers = [];
Object.assign(scope, {
WheelEvent: FakeEvent,
requestAnimationFrame(callback) { callback(); return 1; },
setTimeout(callback) { timers.push(callback); return timers.length; },
});
const canvas = new FakeTarget();
Object.assign(canvas, {
clientHeight: 200,
isConnected: true,
getBoundingClientRect: () => ({
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
}),
});
const journal = createRecordedRerunCameraJournal(canvas, scope);
journal.configure(initialEye, 0.46);
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
});
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
assert.deepEqual(journal.current(), initialEye);
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
const rotated = journal.current();
assert.notDeepEqual(rotated.position, initialEye.position);
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
journal.setMaxOrbitalRadius(40);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
journal.configure(rotated, 0.46);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
const zoomed = journal.current();
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
const snapshot = journal.current();
journal.setSpatialViewportStart(0.8);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
}));
assert.deepEqual(journal.current(), snapshot);
journal.dispose();
});
@@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, createIsolatedRerunHost, keepRecordedBlueprintSession, createRecordedRerunFacade, createRecordedRerunOwner, relayIsolatedRerunInput;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
({ createIsolatedRerunHost } = await server.ssrLoadModule(
"/src/components/rerun/isolatedRerunHost.ts"));
({ keepRecordedBlueprintSession } = await server.ssrLoadModule(
"/src/core/observation/recordedBlueprintLifecycle.ts"));
({ createRecordedRerunFacade } = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunFacade.ts"));
({ createRecordedRerunOwner } = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunOwner.ts"));
({ relayIsolatedRerunInput } = await server.ssrLoadModule(
"/src/components/rerun/isolatedRerunInput.ts"));
});
after(async () => { await server?.close(); });
function fixture(t, { stopFails = false } = {}) {
const timers = new Map();
let timerId = 0, creates = 0, stops = 0, removals = 0;
t.mock.method(globalThis, "setTimeout", (fn) => {
timers.set(++timerId, fn); return timerId;
});
t.mock.method(globalThis, "clearTimeout", (id) => timers.delete(id));
const listeners = new Map();
const viewer = { stop() { stops++; if (stopFails) throw new Error("partial start"); } };
const mount = {};
const frame = {
contentWindow: {
missionCoreRerun: createRecordedRerunOwner(() => { creates++; return viewer; }, mount),
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: (name) => listeners.delete(name),
},
remove() { removals++; },
};
const host = { dataset: {}, ownerDocument: { createElement(name) {
assert.equal(name, "iframe"); return frame;
} }, append(child) { assert.equal(child, frame); } };
return { host, frame, viewer, mount, timers, listeners,
counts: () => ({ creates, stops, removals }) };
}
function bridge(native, mount = {}) {
const child = createRecordedRerunOwner(() => native, mount);
const parent = createRecordedRerunFacade((request, bytes) => {
assert.equal(typeof request, "string");
const result = child.invoke(request, bytes);
assert.equal(typeof result, "string");
return result;
});
child.connect(message => {
assert.equal(typeof message, "string");
parent.notify(message);
});
return parent;
}
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
const f = fixture(t, { stopFails: true });
const scope = createIsolatedRerunHost(f.host);
assert.equal(f.frame.src, "/rerun-runtime.html");
f.frame.onload();
const ready = await scope.ready;
assert.notEqual(ready.viewer, f.viewer);
assert.equal(ready.mount, f.host);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 1);
scope.dispose(); scope.dispose();
assert.deepEqual(f.counts(), { creates: 1, stops: 1, removals: 1 });
assert.equal(f.listeners.size, 0);
assert.equal(f.frame.onload, null);
assert.equal(ready.viewer.ready, false);
});
test("native frame input relays Escape and leaves pointer ownership inside Rerun", () => {
const listeners = new Map(), events = [];
class ParentEvent { constructor(type, init) { this.type = type; Object.assign(this, init); } }
const host = { dataset: {}, ownerDocument: { defaultView: {
KeyboardEvent: ParentEvent, PointerEvent: ParentEvent,
} }, dispatchEvent: event => events.push(event) };
const child = {
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: name => listeners.delete(name),
};
const release = relayIsolatedRerunInput(host, child, null);
assert.equal(listeners.has("pointerdown"), false);
listeners.get("keydown")({ key: "Escape", defaultPrevented: false });
assert.equal(events[0].key, "Escape");
release(); release();
assert.equal(listeners.size, 0); assert.equal(events.length, 1);
});
test("disposed facade, channel and unsubscribe reject native use and copy event values", async () => {
let eventCallback, unsubscribed = 0, sends = 0, starts = 0;
const range = { min: 1, max: 2 }, mount = {};
const native = {
ready: true, stop() {},
async start(source, parent) { assert.equal(parent, mount); starts++; },
get_time_range: () => range,
on(event, callback) { eventCallback = callback; return () => { unsubscribed++; }; },
open_channel: () => ({ ready: true, send_rrd() { sends++; }, close() {} }),
};
const { facade, dispose } = bridge(native, mount);
await facade.start(null, {}, null);
assert.equal(starts, 1);
let event;
const unsubscribe = facade.on("time_update", value => { event = value; });
eventCallback(range);
assert.deepEqual(event, range); assert.notEqual(event, range);
assert.notEqual(facade.get_time_range(), range);
const channel = facade.open_channel();
channel.send_rrd(new Uint8Array());
dispose(); dispose(); unsubscribe();
assert.equal(unsubscribed, 1);
assert.equal(channel.ready, false);
channel.send_rrd(new Uint8Array());
eventCallback({ min: 3, max: 4 });
assert.deepEqual(event, range);
assert.equal(sends, 1);
await assert.rejects(facade.start(null, {}, null), /disposed/);
});
test("close before frame load cancels startup and cannot reopen", async (t) => {
const f = fixture(t);
const scope = createIsolatedRerunHost(f.host);
const lateLoad = f.frame.onload;
const rejected = assert.rejects(scope.ready, /disposed/);
scope.dispose(); lateLoad();
await rejected;
assert.deepEqual(f.counts(), { creates: 0, stops: 0, removals: 1 });
assert.equal(f.timers.size, 0);
});
test("closing during SDK startup also releases a late successful start", async () => {
let finish, stops = 0;
const owner = bridge({
start: () => new Promise(resolve => { finish = resolve; }),
stop() { stops++; },
}, {});
const pending = owner.facade.start(null, {}, null);
const rejected = assert.rejects(pending, /disposed/);
owner.dispose();
assert.equal(stops, 1);
finish();
await rejected;
await Promise.resolve();
assert.equal(stops, 2);
assert.equal(owner.facade.ready, false);
});
test("late failed startup cannot revive a disposed parent and still finishes cleanup", async () => {
let fail, stops = 0;
const owner = bridge({
start: () => new Promise((_resolve, reject) => { fail = reject; }),
stop() { stops++; if (stops > 1) throw new Error("already gone"); },
}, {});
const pending = owner.facade.start(null, {}, null);
const rejected = assert.rejects(pending, /disposed/);
owner.dispose();
fail(new Error("startup failed"));
await rejected;
await Promise.resolve();
assert.equal(stops, 2);
assert.equal(owner.facade.ready, false);
});
test("primitive bridge preserves all public clock/control arguments and errors", async () => {
const calls = [];
const methods = {
open: [["one.rrd", "two.rrd"]], close: ["one.rrd"],
override_panel_state: ["time", "hidden"],
get_active_recording_id: [], get_active_timeline: ["recording"],
get_current_time: ["recording", "session_time"], get_playing: ["recording"],
get_time_range: ["recording", "session_time"],
set_active_timeline: ["recording", "session_time"],
set_current_time: ["recording", "session_time", 123.456],
set_playing: ["recording", false],
};
const native = { stop() {}, start: async () => { throw new Error("native startup failure"); } };
for (const name of Object.keys(methods)) native[name] = (...args) => { calls.push([name, args]); return 1; };
const { facade, dispose } = bridge(native);
for (const [name, args] of Object.entries(methods)) facade[name](...args);
assert.deepEqual(calls, Object.entries(methods));
await assert.rejects(facade.start(null, {}, null), /native startup failure/);
dispose();
});
test("sync native failures become parent-owned errors and optional channel names stay undefined", () => {
const nativeError = new Error("native failure");
let channelName = "unobserved";
const { facade, dispose } = bridge({
stop() {},
open() { throw nativeError; },
open_channel(name) { channelName = name; return { close() {} }; },
});
assert.throws(() => facade.open("rrd"), error => error !== nativeError && /native failure/.test(error.message));
facade.open_channel();
assert.equal(channelName, undefined);
dispose();
});
test("dispose closes every auxiliary channel even if one channel fails", () => {
const closed = [];
let stops = 0;
const owner = bridge({
stop() { stops++; },
open_channel: name => ({ close() {
closed.push(name);
if (name === "first") throw new Error("already gone");
} }),
}, {});
const first = owner.facade.open_channel("first");
const second = owner.facade.open_channel("second");
owner.dispose(); owner.dispose(); first.close(); second.close();
assert.deepEqual(closed, ["first", "second"]);
assert.equal(stops, 1);
});
test("host load timeout removes the frame and clears its timer", async (t) => {
const f = fixture(t);
const scope = createIsolatedRerunHost(f.host);
const rejected = assert.rejects(scope.ready, /timed out/);
[...f.timers.values()][0]();
await rejected;
assert.equal(f.counts().removals, 1);
assert.equal(f.timers.size, 0);
});
test("blueprint termination cancels renewal and sends one bounded keepalive release", async () => {
let tick, canceled = 0;
const requests = [];
const release = keepRecordedBlueprintSession({
endpointUrl: "/api/v1/observation-sessions/source/blueprint.rrd",
origin: "http://core.test", applicationId: "nodedc_mission_core_recorded",
recordingId: "recording", ownerId: "a".repeat(32),
fetcher: async (url, init) => { requests.push({ url, ...init }); return {}; },
schedule(fn) { tick = fn; return 1; }, cancel() { canceled++; },
});
tick();
const renewal = requests[0];
release(); release(); tick();
assert.equal(canceled, 1);
assert.equal(requests.length, 2);
assert.equal(renewal.signal.aborted, true);
assert.equal(requests[1].keepalive, true);
assert.equal(requests[1].url, "http://core.test/api/v1/observation-sessions/source/blueprint-lifecycle");
assert.deepEqual(JSON.parse(requests[1].body), {
action: "release", application_id: "nodedc_mission_core_recorded",
recording_id: "recording", blueprint_session_id: "a".repeat(32),
});
});
test("blueprint lease refuses a foreign endpoint", () => {
assert.throws(() => keepRecordedBlueprintSession({ endpointUrl: "https://other.test/blueprint.rrd",
origin: "http://core.test", applicationId: "app", recordingId: "recording", ownerId: "owner",
}), /Unsafe/);
});
@@ -90,8 +90,15 @@ test("one canonical LAB replay generation reaches the same native receiver", ()
);
});
test("portable replay uses recorded admission and the shared source blueprint", () => {
const source = `/api/v1/observatory/portable-results/m49-tgs-portable-review-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
for (const prefix of [
"m49-tgs-portable-review",
"lab-v1-eomt-ddrnet",
"ai-layer-ddrnet",
"ai-layer-eomt",
"ai-layer-rf-detr",
"ai-layer-object-distance",
]) test(`${prefix} replay uses recorded admission and the shared source blueprint`, () => {
const source = `/api/v1/observatory/portable-results/${prefix}-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
const blueprint = "/api/v1/observation-sessions/source/blueprint.rrd";
assert.equal(isRecordedRrdSource(source), true);
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
@@ -97,7 +97,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
assert.match(source, /aria-label="Слои 3D и плана"/);
assert.match(canonical, /data-pane-mode="media"/);
assert.match(canonical, /data-pane-mode="spatial"/);
assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
assert.match(canonical, /modeControlsVisible=\{!splitView && !paneToolbarsAlwaysVisible\}/);
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
});
@@ -497,14 +497,17 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
assert.match(rerunSource, /resolveReplay\(resultId, value/);
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
assert.match(rerunSource, /unifiedPerception: splitView/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: false/);
assert.match(rerunSource, /&& !semantics[\s\S]*&& !detections[\s\S]*&& !costmap/);
assert.doesNotMatch(rerunSource, /trackNativeSplit|rerunNativeCursor|rerunNativeInput/);
assert.match(canonicalSource, /resizable=\{splitView\}/);
assert.match(rerunSource, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
assert.match(rerunSource, /max=\{500\}/);
assert.match(rerunSource, /exactValueBounds=\{\{ min: 0 \}\}/);
assert.match(replayStyles, /m4-replay-threat-visual__pane-toolbar \{[\s\S]*flex-wrap: wrap;/);
assert.match(replayStyles, /m4-replay-threat-visual__spatial-toolbar-end \{[\s\S]*display: contents;/);
assert.match(rerunSource, /<ToastStack items=\{toasts\}/);
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
@@ -517,22 +520,14 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
replayStyles,
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
);
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: !costmap/);
assert.match(rerunSource, /hasTgs \? toggle\("TGS", showTgs/);
assert.match(canonicalSource, /primary=\{mediaPane\}/);
assert.match(canonicalSource, /secondary=\{spatialPane/);
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonicalSource, /resizable=\{splitView\}/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
+6
View File
@@ -91,6 +91,12 @@ export default defineConfig(({ mode }) => {
},
build: {
target: "esnext",
rollupOptions: {
input: {
app: fileURLToPath(new URL("./index.html", import.meta.url)),
rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)),
},
},
},
server: {
host: "127.0.0.1",
+161
View File
@@ -0,0 +1,161 @@
{
"schema_version": "missioncore.observatory-ai-module-registry/v1",
"modules": [
{
"module_id": "camera-source",
"label": "Подготовка камеры K1",
"group": "preparation",
"image_sha256": "da926459aee0a841bbdfaf80a0eb5fbead354c56794d1f3384eeba66d0a49e00",
"implementation_sha256": "a0b1a74887e5f1cf78e867f81f246bae08e872fed320a2939986c17913418524",
"model_sha256": null,
"contract_sha256": "87c9e8864f9469719e69bfd750c418e077944a6260ffc6fd3ce12daf4a1da7d5",
"requires": [
"source.camera"
],
"provides": [
"camera.frames"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "ddrnet",
"label": "DDRNet-39 · GOOSE",
"group": "segmentation",
"image_sha256": "489fc7d1157fd0f1cd1d82e06a15737b7b2aaaf72b2ddb2aca2992b91a97488e",
"implementation_sha256": "9a3fceb65374ccc3dccdbe02691c2b62376ff3a3a6d16439cb9f6266e73c78b0",
"model_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
"contract_sha256": "197c9d7dd0f55f79992215a57e450c5442f3d1510d74083bcf123577f85da199",
"requires": [
"camera.frames"
],
"provides": [
"segmentation.mask"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "eomt",
"label": "EoMT Large · Cityscapes",
"group": "segmentation",
"image_sha256": "5b770178e4a5c8fbe8f8ddab3b83a598973dbe103b669851110b11a0918ab846",
"implementation_sha256": "17ac471d5c51f70b3af6fb6046e1880c731691253c338b1626702ce4ca375d2b",
"model_sha256": "c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782",
"contract_sha256": "09ca841400b08af0607d19d346f78ab768567256c8c58789a6ade4ede8f425a0",
"requires": [
"camera.frames"
],
"provides": [
"segmentation.mask"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "tgs",
"label": "TRAVEL TGS · облако и карта проходимости",
"group": "geometry",
"image_sha256": "f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3",
"implementation_sha256": "52813392aabd02efc5c2b8f7c22ed88e3ef4cc8ad3aafeba2792efe503e29fe9",
"model_sha256": null,
"contract_sha256": "9dd80c8e2504559d2156fca933de6eb27901e35305e6853aeb84707e1cb13892",
"requires": [
"source.calibration",
"source.lidar",
"source.pose"
],
"provides": [
"geometry.costmap",
"geometry.ground"
],
"optional_inputs": [],
"parameter_choices": {
"history-seconds": [
1
]
},
"defaults": {
"history-seconds": 1
},
"state_policy": "causal-reset-at-source-start"
},
{
"module_id": "rf-detr",
"label": "RF-DETR Large · рамки объектов",
"group": "detection",
"image_sha256": "2b8b44be8e9ee4060aa6997fc4c025ad7120f37ecd720a9e59b5e02ac6c90f66",
"implementation_sha256": "4eb580eb938164bf0624e94fdc3b046aacf32fb8022c93c67fa26bab7c3d19d0",
"model_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
"contract_sha256": "815d0afd24355da3a462e0423dab8284165c89cebd136d7c8584a32602178b96",
"requires": [
"camera.frames"
],
"provides": [
"detections.2d"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "object-distance",
"label": "Дистанция до объектов · K1 LiDAR",
"group": "range",
"image_sha256": "69d68f64981b41e5bcce1e642433e3d466b88c51942a5ba3aec9180d8ed04263",
"implementation_sha256": "0e3e426e2f768bc019a3715a50fc77a88626869abd9fa919e1aef5763d0c53da",
"model_sha256": null,
"contract_sha256": "5a6e446f009420352587f83388331b368d00950ab2fb896c6350aaa9b31c2e2e",
"requires": [
"detections.2d",
"source.calibration",
"source.lidar",
"source.pose"
],
"provides": [
"objects.ranged"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
}
]
}
+336
View File
@@ -0,0 +1,336 @@
{
"schema_version": "missioncore.observatory-domain-ontology/v1",
"ontology_id": "mission-core.observatory",
"version": "1.3.0",
"owner": "NODE.DC Mission Core",
"lifecycle": "local-runtime-canonical",
"entities": [
{
"id": "mission.transport-unit",
"identity": "transport_id",
"owner": "transport registry",
"lifecycle": "registered-to-retired"
},
{
"id": "mission.equipment-unit",
"identity": "equipment_id",
"owner": "equipment registry",
"lifecycle": "registered-to-retired"
},
{
"id": "observatory.equipment-mount",
"identity": "mount_id",
"owner": "equipment capture registry",
"lifecycle": "time-bounded"
},
{
"id": "observatory.capture-profile",
"identity": "capture_profile_id",
"owner": "equipment capture registry",
"lifecycle": "versioned"
},
{
"id": "observatory.recorded-session",
"identity": "source_session_id",
"owner": "session archive",
"lifecycle": "recording-to-immutable"
},
{
"id": "observatory.module-version",
"identity": "module_sha256",
"owner": "module registry",
"lifecycle": "installed-or-retired"
},
{
"id": "observatory.container-image",
"identity": "image_sha256",
"owner": "module registry",
"lifecycle": "built-to-retired"
},
{
"id": "observatory.worker-node",
"identity": "worker_node_id",
"owner": "worker registry",
"lifecycle": "registered-to-retired"
},
{
"id": "observatory.composition",
"identity": "composition_sha256",
"owner": "composition store",
"lifecycle": "immutable"
},
{
"id": "observatory.composition-run",
"identity": "run_id",
"owner": "composition run store",
"lifecycle": "append-only"
},
{
"id": "observatory.recorded-job",
"identity": "job_id",
"owner": "recorded job queue",
"lifecycle": "queued-to-terminal"
},
{
"id": "observatory.portable-result",
"identity": "result_id",
"owner": "portable result publisher",
"lifecycle": "immutable"
},
{
"id": "observatory.lab-projection",
"identity": "result_id",
"owner": "session catalog",
"lifecycle": "published"
},
{
"id": "observatory.lab-view-profile",
"identity": "result_id",
"owner": "replay presentation",
"lifecycle": "mutable-per-result"
},
{
"id": "observatory.viewer-pane",
"identity": "pane_id",
"owner": "replay presentation",
"lifecycle": "versioned-contract"
},
{
"id": "observatory.viewer-layer",
"identity": "layer_id",
"owner": "replay presentation",
"lifecycle": "versioned-contract"
}
],
"relations": [
{
"id": "observatory.equipment-mount.mounts_equipment",
"from": "observatory.equipment-mount",
"to": "mission.equipment-unit",
"cardinality": "many-to-one"
},
{
"id": "observatory.equipment-mount.attaches_to_transport",
"from": "observatory.equipment-mount",
"to": "mission.transport-unit",
"cardinality": "many-to-one"
},
{
"id": "observatory.recorded-session.captured_on_transport",
"from": "observatory.recorded-session",
"to": "mission.transport-unit",
"cardinality": "many-to-zero-or-one"
},
{
"id": "observatory.recorded-session.captured_with_equipment",
"from": "observatory.recorded-session",
"to": "mission.equipment-unit",
"cardinality": "many-to-many"
},
{
"id": "observatory.recorded-session.uses_equipment_mount",
"from": "observatory.recorded-session",
"to": "observatory.equipment-mount",
"cardinality": "many-to-many"
},
{
"id": "observatory.recorded-session.uses_capture_profile",
"from": "observatory.recorded-session",
"to": "observatory.capture-profile",
"cardinality": "many-to-one"
},
{
"id": "observatory.composition-run.uses_recorded_session",
"from": "observatory.composition-run",
"to": "observatory.recorded-session",
"cardinality": "many-to-one"
},
{
"id": "observatory.composition.contains_module",
"from": "observatory.composition",
"to": "observatory.module-version",
"cardinality": "one-to-many"
},
{
"id": "observatory.module-version.implemented_by_image",
"from": "observatory.module-version",
"to": "observatory.container-image",
"cardinality": "many-to-one"
},
{
"id": "observatory.container-image.installed_on_worker",
"from": "observatory.container-image",
"to": "observatory.worker-node",
"cardinality": "many-to-many"
},
{
"id": "observatory.composition-run.applies_composition",
"from": "observatory.composition-run",
"to": "observatory.composition",
"cardinality": "many-to-one"
},
{
"id": "observatory.composition-run.uses_job",
"from": "observatory.composition-run",
"to": "observatory.recorded-job",
"cardinality": "one-to-many"
},
{
"id": "observatory.recorded-job.executes_on_worker",
"from": "observatory.recorded-job",
"to": "observatory.worker-node",
"cardinality": "many-to-one"
},
{
"id": "observatory.recorded-job.publishes_result",
"from": "observatory.recorded-job",
"to": "observatory.portable-result",
"cardinality": "one-to-zero-or-one"
},
{
"id": "observatory.composition-run.projects_lab",
"from": "observatory.composition-run",
"to": "observatory.lab-projection",
"cardinality": "one-to-zero-or-one"
},
{
"id": "observatory.lab-projection.has_view_profile",
"from": "observatory.lab-projection",
"to": "observatory.lab-view-profile",
"cardinality": "one-to-zero-or-one"
},
{
"id": "observatory.module-version.exposes_layer",
"from": "observatory.module-version",
"to": "observatory.viewer-layer",
"cardinality": "many-to-many"
},
{
"id": "observatory.viewer-layer.belongs_to_pane",
"from": "observatory.viewer-layer",
"to": "observatory.viewer-pane",
"cardinality": "many-to-one"
}
],
"panes": [
{
"pane_id": "camera",
"label": "Камера",
"order": 10
},
{
"pane_id": "spatial",
"label": "Облако точек",
"order": 20
}
],
"layers": [
{
"layer_id": "camera.source",
"pane_id": "camera",
"label": "КАМЕРА",
"control": "toggle",
"order": 10
},
{
"layer_id": "camera.ddrnet",
"pane_id": "camera",
"label": "DDRNET",
"control": "toggle",
"order": 20
},
{
"layer_id": "camera.eomt",
"pane_id": "camera",
"label": "EOMT",
"control": "toggle",
"order": 30
},
{
"layer_id": "camera.detections",
"pane_id": "camera",
"label": "РАМКИ",
"control": "toggle",
"order": 40
},
{
"layer_id": "spatial.source-points",
"pane_id": "spatial",
"label": "ИСХ. ТОЧКИ",
"control": "toggle",
"order": 10
},
{
"layer_id": "spatial.local-slam",
"pane_id": "spatial",
"label": "ЛОК. SLAM",
"control": "toggle-with-settings",
"order": 20
},
{
"layer_id": "spatial.tgs",
"pane_id": "spatial",
"label": "TGS",
"control": "toggle",
"order": 30
}
],
"module_projections": [
{
"module_id": "ddrnet",
"configuration_label": "DDRNet-39 · GOOSE",
"layers": [
"camera.source",
"camera.ddrnet"
]
},
{
"module_id": "eomt",
"configuration_label": "EoMT Large · Cityscapes",
"layers": [
"camera.source",
"camera.eomt"
]
},
{
"module_id": "rf-detr",
"configuration_label": "RF-DETR Large",
"layers": [
"camera.source",
"camera.detections"
]
},
{
"module_id": "tgs",
"configuration_label": "TRAVEL TGS",
"layers": [
"spatial.source-points",
"spatial.local-slam",
"spatial.tgs"
]
},
{
"module_id": "object-distance",
"configuration_label": "Дистанция до объектов · K1 LiDAR",
"layers": [
"camera.source",
"camera.detections",
"spatial.source-points",
"spatial.local-slam"
]
}
],
"named_queries": [
"recording.capture-context",
"composition.configuration-label",
"composition.member-results",
"composition.viewer-layers",
"composition.ready-state",
"lab.view-profile"
],
"platform_sync": {
"mode": "none",
"promotion_gate": "stable cross-product meaning with an agreed Platform Ontology migration",
"platform_repository_is_runtime_dependency": false
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11 -5
View File
@@ -227,9 +227,13 @@ Mission Core already has three semantic mechanisms:
3. `docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md` for
meanings that are not yet stable enough for Platform Ontology.
These mechanisms are sufficient before A3. A new RDF/graph store, ontology
service, or parallel entity catalog would introduce duplicated identity and
migration work without a current query or integration consumer.
These mechanisms were sufficient before A3. Observatory modular compositions
crossed the gate on 2026-09-04: planning, Worker dispatch, LAB publication and
replay presentation require the same module/composition/result/layer relations,
and typed single-result traversal lost the TGS member of a composed run. The
bounded local package is defined by
`docs/domain-model/observatory-domain-ontology-v1.md`. It keeps identity in the
existing stores and has no Platform Ontology runtime dependency.
A formal local runtime ontology is introduced only when:
@@ -239,8 +243,10 @@ A formal local runtime ontology is introduced only when:
- the graph answers named queries used by the product or automation;
- promotion or synchronization with NODE.DC Platform Ontology is defined.
Until those conditions hold, new stable meanings extend the versioned local
vocabulary and executable contracts. They do not create a second runtime model.
Other domains continue to extend the versioned local vocabulary and executable
contracts until they independently satisfy these conditions. The admitted
Observatory package projects existing store identities and cannot become a
second authority for them.
## Automated boundary gate
+301 -28
View File
@@ -1,11 +1,191 @@
# Observatory: модульные Docker-композиции → записанные LAB → CUDA-борт
## ЕДИНСТВЕННЫЙ ТЕКУЩИЙ МАРШРУТ — 2026-09-03, модульное решение
Этот раздел заменяет прежний маршрут ниже. Исторические `CURRENT`, «следующий
шаг», номера этапов и требования «один профиль = один полный Docker» в архивной
части не являются актуальными заданиями. Замеры и выполненная работа сохранены.
Решение владельца: [ADR 0051](adr/0051-modular-observatory-profiles.md).
Фактическая сверка и очистка:
[handoff report](../experiments/perception/OBSERVATORY_MODULAR_HANDOFF_2026-09-03.md).
### Цель и термины
Запись K1 → настройка AI-слоя по функциональным группам → расчёт выбранной
композиции → неизменяемая LAB и кэш на Core → повторный просмотр без Worker.
Один модуль используется разными композициями, одна композиция — разными
совместимыми записями. Удачная композиция позднее переносится на CUDA-борт.
- **Модуль:** отдельный версионированный Docker-образ модели или связанной
функции, например DDRNet, EoMT, RF-DETR, LiDAR/TGS geometry. Не отдельный
микросервис для каждого арифметического шага.
- **Профиль:** проверенная immutable композиция модулей, параметров, связей и
политики исполнения. Новая комбинация не требует сборки монолитного образа.
- **LAB:** применение точной композиции к точному снимку записи, с доказательствами,
границами покрытия и измерениями. Сохранённый результат не меняется задним числом.
- **Борт:** будущий совместимый CUDA-компьютер, не Mac Mini. Конкретные CPU/GPU,
драйверы и архитектура контейнеров квалифицируются после выбора оборудования.
Работа recorded-first. Расчёт медленнее записи допустим; достижение remote
realtime через Wi-Fi/LTE не блокирует лаборатории. Runtime всё равно сохраняет
потоковые контракты и оптимизированную подготовку данных. FPS проигрывания кэша
и скорость сборки из кэшированных узлов не выдаются за вычислительный FPS борта.
### CURRENT: установленное и доказанное
- Ветка `codex/m5-1-observatory`, HEAD `eff60e4`, поверх него есть незакоммиченные
изменения предыдущих инкрементов. Не сбрасывать и не считать всё новым diff
этой архитектурной правки. Полный список даёт `git status`.
- Core на8000: admission, очередь/claim v3, exact-cache/idempotency, publication
outbox/recovery, metadata pagination, отделение portable LAB от Legacy,
общий сохранённый viewer. На Worker установлен source CAS двух агентов и
bounded heartbeat retry. Это основа миграции, не повод написать всё заново.
- M4.9T5: CPU TGS, отдельный специализированный агент. Job
`observatory-run-b230216709dc4c59bc56c98c7e329bf1` ×004TREE опубликована:
6830 camera anchors,6811 LiDAR,19 UNOBSERVED; цикл1416.763s. Расписание
39.215757.160s внутри808.779s записи: не заявлять полное покрытие всей записи
или полный AI-граф. Сохранённую публикацию и рабочий release сохранить.
- LAB V1: установлен фиксированный стек prepare → EoMT → DDRNet → assemble.
Это старый сравнительный состав, не новый выбор одного segmenter. Последняя
job `observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` завершена failed после
lease loss на EoMT; нового опубликованного LAB V1 нет. Исправления heartbeat
установлены, но успешный полный повтор ещё не доказан.
- Есть прототип DDRNet/RF-DETR/geometry/distance/motion/TGS/costmap/policy-shadow.
Переиспользовать алгоритмы, не называть прототип принятым автономным профилем.
- Реестр пользовательских композиций, per-node result-cache, групповые настройки
и независимо упакованные модули нового формата **ещё не реализованы**.
- Реальная полная visual/memory-lifecycle приёмка saved replay открыта. Прежние
749 frontend/166 backend checks — проверки предыдущего инкремента, не новой
модульной архитектуры. Исторические тесты не заменяют новые приёмочные сценарии.
- Audit:24 контейнера →11 после удаления13 проверенных остановленных
предшественников/debug.80 образов и9 volumes оставлены. Сохранены два текущих
агента, последняя rollback-пара, рабочие результаты и зависимости. Активные
Gaussian/Triton/perception требуют отдельной проверки потребителей.
- EoMT floor250GiB + working set изменён в source и узких тестах; установленный
образ пока350GiB. Нужен новый sealed release, не правка digest существующего.
### Продуктовые правила нового AI-конфигуратора
В существующей Обсерватории у выбранной записи — «Настройка AI-слоя» и компактное
окно с функциональными группами. Использовать существующие `Window`, `FieldFrame`,
`Select`, `WindowFooterActions` и канонические кнопки; не создавать новый workspace
или локальную дизайн-систему. Обычная checkbox-матрица Docker не подходит.
Группа сегментации: один выбор DDRNet / EoMT / «Не использовать», если частичный
состав допустим. Два segmenter в одной новой LAB запрещены сервером, не только UI.
Другие кандидаты групп: детекция, LiDAR/TGS/costmap, связь объектов с расстоянием,
motion, policy-shadow. Их окончательные границы определяются кодом/контрактами.
Один модуль с несколькими capabilities не запускается несколько раз.
Сервер проверяет зависимости: object-distance требует детекцию, облако и
калибровку; геометрические препятствия могут вычисляться без семантического
детектора. Не требовать необязательный модуль для независимого результата.
Для недоступного сочетания — понятная причина, не молчаливое включение другой ML.
Точная конфигурация уже опубликована → открыть существующую LAB, «Рассчитать» нет.
Нет результата → «Рассчитать», при активной job — существующий реальный progress
без дубликата. Нет кнопки/плашки «Расчёт завершён». Поставщики остаются в dropdown:
рассчитанность относится ко всей композиции, а не к отдельной модели. Это заменяет
старое правило удаления готового полного профиля из единственной выпадашки.
### Четыре этапа — действуют только эти
#### 1. Контракты композиции и безопасная граница миграции
- Сверка Desktop/кода/installed runtime, retention inventory и первая очистка
остановленных экземпляров выполнены в этом handoff. Спецификация ADR принята.
- Следующее: реализовать версионированные Module/Composition contracts поверх
installed-package boundary; определить producer/consumer схемы и capability
группы по фактическим алгоритмам. Не считать описательный ADR готовым API.
- Зафиксировать content identity: source, image/code/weights, параметры,
preprocessing/calibration, cadence/precision, temporal state и graph edges.
Порядок щелчков в UI не меняет identity; любые значимые входы — меняют.
- Retention manifest должен охватить pinned packages, текущий M49, старый V1,
прототип полного графа, модельные assets и один rollback. Затем отдельно
согласованно вывести активные устаревшие сервисы; только после проверки
зависимостей удалять недостижимые images/build caches/temp directories.
Приёмка: контрактные тесты несовместимости и exact identity; доказанный список
сохраняемых артефактов; никакой утраты записей/Legacy/публикаций. Этап целиком открыт.
#### 2. Переиспользуемые Docker-модули и общий Worker runtime
- Упаковать DDRNet и EoMT отдельно с закреплёнными зависимостями/весами;
LiDAR/TGS и остальные функции выделять по согласованным границам. Сохранить
общие базовые слои/проверенные assets без копирования всего набора в каждый image.
Веса входят в переносимый дистрибутив как image layers либо явные immutable
model assets с проверкой digest; случайный host checkout/conda environment
не является допустимой скрытой зависимостью модуля.
- Расширить generic launcher, не создавать агент под каждую модель. Одна аренда
на композицию Worker006, тяжёлые GPU шаги последовательно. Несколько активных
CPU/служебных контейнеров не означают разрешённый параллельный ML inference.
- Один source/preparation path, локальные межмодульные данные на Worker; не
отправлять промежуточные кадры/облака через Core туда-обратно. Сохранить bounded
потоковый I/O; целиковый cold input barrier не превращать в вечную архитектуру.
- Добавить exact per-node cache и зависимое invalidation, stateful history binding,
прогресс computed/reused/failed. Worker source CAS сам по себе это не реализует.
- Результат публикуется и просматривается с Core. Повтор publication не запускает
inference заново. Окончание/cancel освобождают временные процессы/RAM/VRAM,
не удаляют постоянные результаты. Ресурсную политику250GiB активировать новым release.
Приёмка: standalone cold start без developer checkout и необъявленных mount;
последовательные реальные прогоны двух допустимых композиций; exact cache hit,
частичный reuse, смена зависимости и безопасный failure/recovery.
#### 3. Конфигуратор, сохранённый просмотр и продуктовая приёмка
- Реализовать групповые Select и server admission в существующей Обсерватории.
Legacy не переносить назад; старый dual-segmentation V1 только совместимость/история.
- Доказать source × composition: новая совместимая запись без LAB, расчёт,
открытие сохранённой LAB, новая конфигурация, отсутствие дубликата точного повтора.
Минимум две совместимые записи и две допустимые композиции, последовательно.
- Проверить общую временную/пространственную привязку camera/segmentation/objects/
range/TGS по фактически выбранным outputs, full configured coverage и явные gaps.
- Завершить реальную normal/expanded/Escape/close/reopen приёмку и освобождение
viewer RAM/GPU после закрытия; не ограничиваться контрактными тестами.
- Проверить большие каталоги, рестарты Core/Worker, publication retry, отмену,
полную наблюдаемость результата без работающих моделей.
Приёмка: оператор выполняет весь цикл без инженерных команд; просмотр берётся
с Core, рабочий Worker не нужен. Этапы2–3 могут иметь согласованные инкременты,
но непройденный сквозной сценарий не считается закрытым.
#### 4. Перенос принятой композиции на CUDA-борт — позже
Те же логические модули и версии; аппаратно-совместимая упаковка, локальный
транспорт, долгоживущие процессы/модели вместо старта Docker на каждый кадр.
Квалифицировать полный граф и совместное потребление памяти, не сумму независимых
FPS и не cached replay. Горячие узлы можно позднее объединять по измерениям без
потери логической модульности. Mac Mini не является целевым бортом.
Моторы, автономное движение, ArduRover и safety acceptance — отдельная будущая
работа, сейчас observation-only/policy-shadow.
### Инструкция следующему чату
Прочитать актуальную верхушку Desktop `_MISSING_CORE_…FINAL_STATUS…md`, ADR0051,
этот раздел и handoff report. Проверить `git status`, Core8000 и точные installed
identities read-only; начать с незакрытых контрактов этапа1. Старые installer
scripts с зашитыми predecessor IDs не запускать повторно. Не начинать с новой
полной сборки старого dual-segment LAB V1 или глобального Docker prune.
Не менять Synology/деплой21, Little Snitch, чужие сервисы, Docker Desktop limits.
Не коммитить существующий общий dirty diff как собственную новую работу.
---
## АРХИВ ПРЕЖНЕГО МАРШРУТА — до модульного решения 2026-09-03
Весь следующий текст — история. Слова «актуальный», CURRENT и следующие шаги
внутри него описывают состояние своего инкремента, не текущий план.
# Observatory: четыре этапа — записанные лаборатории → переносимые профили → борт
## Актуальный маршрут — 2026-09-03
Сверено с кодом `bee8552`, установкой `54c8d82`, исправлением reindex `bd947b4`
и последними решениями владельца. M4.9T5 ×004TREE рассчитан через UI, опубликован
и повторно открыт как документ из кэша после перезапуска. Визуальный replay ещё
не реализован для этого portable-результата. Разделы до журнала инкрементов — текущий
и последними решениями владельца. Проверки и сборка текущего инкремента поверх
`eff60e4` завершены; возврат к основному маршруту зафиксирован ниже. M4.9T5 ×004TREE рассчитан через UI, опубликован
и повторно открыт из кэша после перезапуска. Общий camera/TGS replay реализован;
подробная визуальная и memory-lifecycle приёмка ещё открыта. Разделы до журнала инкрементов — текущий
маршрут; исторические «следующий шаг» и «этап открыт/закрыт» ниже не команды.
### Цель и результат
@@ -29,9 +209,11 @@ PASS через Wi-Fi/LTE не является условием готовно
рассчитаны → выбор пуст, «Рассчитать» нет, «Обновить» остаётся. Плашек завершения
нет; во время работы общий индикатор показывает настоящую фазу и доступные счётчики.
Внутри первичного decode/передачи архива детальные счётчики ещё отсутствуют.
Лимит первых 6 карточек снят,
но пагинация сотен записей ещё не доказана.
- **Последняя приёмка:** 726 frontend tests, 128 focused progress/queue/API/
Обсерватория теперь показывает только identity-bound portable результаты;
исторические LAB остаются в Legacy. Каталог дочитывает cursor pages по100
metadata entries (до256 страниц на scope с явным partial flag), поиск видит
все загруженные источники. Fixture500 sources и real20-page proof PASS.
- **Предыдущая функциональная приёмка:** 726 frontend tests, 128 focused progress/queue/API/
transport tests, отдельно40 runtime/wiring и3 installer tests, typecheck/build,
Ruff/mypy и browser QA. Для найденного при рестарте cache-дефекта — ещё89
session/publication и72 admission/cache/queue/API tests. Эти проходы пересекаются,
@@ -47,12 +229,49 @@ PASS через Wi-Fi/LTE не является условием готовно
- **Текущие составы:** M4.9T5 — CPU TGS; LAB V1 — последовательные EoMT и DDRNet.
Ни один не равен полному будущему AI-профилю рига. Архивные overlays в одном
viewer не доказывают, что один Docker вычислил все слои.
- **Последний viewer-инкремент:** primitive-only owner v2 и передача Escape/pointer
из iframe для существующих внешних controls. 740 frontend/64 focused backend
tests, полный typecheck/build PASS, новая сборка обслуживается на8000.
Разделитель native viewer снова передаёт координаты внешним панелям; проверено
контрактным тестом, реальная visual acceptance этой версии ещё не выполнена.
- **Входы Worker, новый инкремент2A:** общий source CAS двух агентов установлен;
другая job/generation/конфигурация переиспользует exact cached bytes, частичная
camera-cache требует только отсутствующие members. M49 проверяет готовый
LiDAR pack до raw decode; прежний producer/identity сохранён.111 focused tests,
Ruff/mypy и реальный cross-agent synthetic proof PASS, моделей не запускали.
Это не кэш viewer (он остаётся на Core) и не устранение cold whole-input barrier.
- **Последний продуктовый инкремент2D:** Legacy separation и pagination активны
на8000 (`app-9nK8VtbK.js`).747 frontend/41 backend tests, typecheck/build,
Ruff/mypy PASS. Browser catalog/search/normal/expanded/Refresh/dropdown Escape
принят; тяжёлый replay не открывался. Legacy API до/после побайтно неизменён.
- **Освобождение памяти:** найден остаток записанного viewer после закрытия.
Добавлены disposable upstream realm, размыкающий ссылки facade и backend
release/TTL. Предыдущий browser proof подтвердил освобождение GPU, но прежний
facade оставил829 MB renderer после закрытия (112 MB до). Для v2 реальный
open/close ещё не проверен: browser auto-review отклонил тяжёлое открытие при
pressure2; запрошено отдельное разрешение. Это не блокирует код/контракты
оставшихся частей этапа2 и не считается доказанным исправлением memory issue.
Активные данные/качество не урезаются, дисковый cache не удаляется.
- **Крупный проход saved review/recovery:** общий viewer подключён к сохранённым
EoMT/DDRNet masks LAB V1 через новый строгий adapter; M49 cache identity сохранена.
Outbox больше не застревает за префиксом exhausted/backoff rows. Краткие
heartbeat transport failures повторяются в пределах аренды с тем же sequence;
новый control layer установлен в оба агента.749 frontend/166 backend tests,
typecheck/build/Ruff/mypy PASS; Core `app-w6onjKPq.js` healthy на8000.
- **Реальный LAB V1 ×004TREE пока FAIL:** новая job
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` потеряла lease на шаге EoMT.
После независимой проверки освобождения ресурсов штатно reconciled→failed;
история сохранена, очередь11 failed/2 succeeded,0 live leases. Новый LAB не
опубликован. Отдельно подтверждён устаревший EoMT disk floor350GiB сверх
рабочего набора: при текущих397.47GB free admission не пройдёт. Перед повтором
нужна новая sealed resource policy, не очистка данных/не покупка памяти.
[Полный отчёт и точные границы доказательств](../experiments/perception/OBSERVATORY_SAVED_REVIEW_RECOVERY_2026-09-03.md).
- **Есть инженерный прототип полного графа:** DDRNet/RF-DETR/geometry/distance/
motion/TGS/costmap/policy-shadow и короткие потоковые proofs. Не пишем заново.
Самостоятельный продуктовый образ и полный recorded-run этого состава не приняты.
- **Не закрыто:** полный recorded-analysis текущими
профилями, синхронный сохранённый visual replay, матрица двух профилей/записей, полный поиск/
пагинация каталога, standalone и фактический перенос на борт.
профилями, синхронный сохранённый visual replay, матрица двух профилей/записей,
оставшиеся recovery cases, standalone и фактический перенос на борт.
Источники: [ADR 0050](adr/0050-recorded-observatory-first.md),
[cache/UI evidence](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md),
@@ -62,6 +281,8 @@ Desktop final-status — операторская сводка и история
сохранение выполненной работы, не неподтверждённые сведения о runtime.
Текущее исполнение и точные declarations:
[progress / первый запуск](../experiments/perception/OBSERVATORY_RECORDED_PROGRESS_2026-09-03.md).
Последняя сверка и проверки:
[возврат к основному сценарию](../experiments/perception/OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md).
## Границы исполнения
@@ -106,8 +327,12 @@ normal/expanded UI; последний код `e436fb5` и отчёты выше
**Частично выполнено:** bounded attempt-scoped progress реализован и активирован.
Новый M49-run использовал прежний профиль и полностью выполнил его расписание.
Это не закрывает 2A: целиковый input barrier и `_capture_arrays()` до replay-cache
lookup ещё сохранены. Progress observation не является новым режимом исполнения.
Это не закрывает2A: целиковый input barrier ещё сохранён. Новый preparation adapter
проверяет exact LiDAR-cache до вызова прежнего producer, общий source CAS уже
установлен в оба агента. На warm input не повторяются raw decode и передача
готовых members, но cold materialization по-прежнему ждёт весь вход. Progress
observation и warm reuse не являются новым режимом исполнения.
Evidence: [source reuse](../experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md).
- Сохранить source timestamps и causal ordering. Медленный Worker притормаживает
подачу, а не выбрасывает обязательные кадры ради 1×. Учитывается каждый вход/
@@ -133,32 +358,45 @@ timeline. Это приёмка CPU TGS, не полного detector/segmentati
точный cache hit, исчезновение M49 из выбора и открытие документа после рестарта.
Найден и исправлен no-op reindex: служебная переиндексация больше не меняет
source identity; реальное изменение по-прежнему требует нового расчёта.
**Осталось в2B:** portable review сейчас JSON, не проигрыватель. Подключить
typed result/frame reader и общий `CanonicalRecordedLabReplay`, ленивое чтение
сохранённых TGS/costmap и точную камеру/временную привязку. Проверить coverage,
seek/play/pause, normal/expanded/restore/Escape и повторное открытие без job.
Сначала использовать уже sealed пакет; не считать заново и не подмешивать
архивные E47/M49 semantics. Название «полный маршрут и воспроизведение» само
по себе не доказывает доступность replay или полноту всей исходной сессии.
Общий `CanonicalRecordedLabReplay` уже получает сохранённый camera/source/TGS
RRD с Core; JSON не является единственным review. **Осталось в2B:** сохранение
ручного ракурса при blueprint activation, подробная визуальная оценка и
подтверждённое освобождение памяти закрытого viewer. Использовать уже sealed
пакет, не считать заново и не подмешивать архивные E47/M49 semantics. Название
«полный маршрут» не доказывает покрытия всех исходных timestamps.
**2C. LAB V1 и другая запись.** Выполнить LAB V1 × 004TREE, затем оба профиля
× RAVNOVES00, строго последовательно. Медленная EoMT не возвращает нас к гонке
за сетевым FPS. Не менять незаметно состав, cadence или разрешение ради PASS.
RAVNOVES01 — отрицательный случай до подтверждённой аттестации.
**Попытка2026-09-03:** LAB V1 ×004TREE запущен штатно, но после transfer и prepare
не завершил EoMT: lease expired, результата нет. Heartbeat retry исправлен и
установлен, exact-job resources освобождены, failed receipt сохранён. Перед
следующим расчётом изменить350GiB disk floor через новую sealed версию с
проверяемым бюджетом рабочего набора; предусмотреть дешёвый preflight до долгих
asset checks и bounded diagnostics до cleanup. Не считать этот canary успешным.
**2D. Повторное использование, сбои и большой каталог.**
- Refresh, повторный вход и перезапуск открывают тот же результат без новой job.
Все текущие профили рассчитаны → выбор пуст, «Рассчитать» нет; новая версия → новый расчёт,
старая остаётся ниже. Проверить transient disconnect, compute failure,
publication retry, missing/corrupt artifacts и отсутствие дубликатов.
- Исторические результаты получают допуск к review только при достаточном
evidence, без подделки provenance. Если binding отсутствует — исправить
проверяемую проекцию либо выполнить новый расчёт, не объявлять legacy новым.
- Довести постраничный поиск записей и результатов. Нельзя спрятать профиль
- **Реализовано:** Обсерватория не показывает Legacy/canonical архивные LAB;
они остаются в существующем архиве без миграции/удаления. Portable результаты
прошлых версий сохраняются при точном binding, независимо от имён и даты.
- **Реализовано и проверено:** постраничный каталог и поиск по загруженным
источникам. Нельзя спрятать профиль
из-за cache hit, соответствующий результат которого нельзя найти/открыть
из-за окна каталога. Сотни metadata entries проверяются функциональными
fixtures, не нагрузкой на Mac и не загрузкой всех видео в RAM.
[Evidence и границы traversal](../experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md).
- **Новый recovery proof:** keyset outbox проходит мимо exhausted/backoff rows;
interruption до publish/после publish до ACK восстанавливает тот же результат
без compute. Transient heartbeat/lost ACK повторяет тот же sequence, истёкшая
аренда/чужая identity/HTTP401/403/409 не принимаются. Это contract tests и
установленный control layer, не реальная long-outage qualification.
**Приёмка:** матрица 2 профиля × 2 совместимые записи, полные обязательные выходы,
честный прогресс, published package, сохранённый просмотр без compute после
@@ -218,12 +456,16 @@ watchdog, stop/slow при пропаже данных, моторы и авто
- [x] 2026-09-03 — этап 1: queue/cache/identity/selector реализованы и локально проверены.
- [x] 2026-09-03 — история сохранена, completed-status UI удалён.
- [x] Этап 2A, часть progress — реальные bounded Worker → backend → UI snapshots.
- [x] Этап 2A, warm source reuse — shared CAS и LiDAR early lookup установлены на Worker;111 tests и cross-agent synthetic proof.
- [ ] Этап 2A, остаток — versioned recorded-analysis и устранение полного input barrier.
- [x] Этап 2B, compute/publish/cache — M4.9 ×004TREE завершён; документ повторно открыт после рестарта.
- [x] Найденный reindex-дефект exact cache исправлен; исходная связь восстановлена по точному SHA proof.
- [x] Этап 2B, saved replay — camera/source cloud/TGS в одном native Rerun, выдача с Core после рестарта без compute.
- [ ] Этап 2B, visual acceptance — сохранение ручного ракурса и подробная визуальная оценка; не подменять работающим проигрывателем.
- [ ] Этап 2C–2D — LAB V1/вторая запись, cache/recovery/каталог.
- [x] Primitive-only owner v2 и мост native input:16 focused/740 frontend/64 backend tests, typecheck/build; сборка на8000.
- [ ] Этап 2B/2D, memory lifecycle — GPU/server cleanup проверены в предыдущих циклах; прежний renderer удерживается нестабильно. Browser acceptance v2 отдельно не разрешена auto-review; остальные части этапа2 продолжаются.
- [x] Этап 2D, каталог — Legacy separation, cursor pagination, fixture500 и real20-page proof; UI на8000 проверен.
- [ ] Этап 2C2D — LAB V1/вторая запись и оставшиеся cache/recovery cases.
- [ ] Этап 3 — самостоятельный полный профиль и сравнение конфигураций.
- [ ] Этап 4 — кандидат, затем фактическая бортовая квалификация.
- [x] 2026-09-03 — план и Desktop-сводка синхронизируются с фактическим исполнением.
@@ -236,12 +478,40 @@ Core хранит/выдаёт 193,622,178-byte replay; Worker не нужен
blueprint clone activation. Не объявлять stable source ID сохранением eye;
выбирать только поддержанный native-механизм либо отдельно согласованный upgrade,
не патчить WASM и не создавать второй renderer. Подробная визуальная приёмка открыта.
Первым действием после нормализации памяти Mac повторить browser first-open для
последней правки initial cursor +1мкс; до этого не писать PASS начальной сцены.
Автотесты/build проходят; последняя визуальная проверка остановлена resource gate.
Отдельно закрыть остаток2A версионированным incremental input;
не объявлять прежний whole-recording materializer потоковым.
Не начинать снова с claim/v3, memory repair, селектора или сетевого canary.
Начальный cursor +1мкс показал camera/TGS в промежуточной проверке, но в финальном
expanded first-frame камера была пустой до playback; устойчивость не принята.
Прежний facade функционально работает, GPU715→375 MB освобождается,
но renderer112→829 MB после закрытия. Follow-up с `vmmap`: один цикл834→215 MB
за35с с исчезновением крупных VM областей, повторный812→824 MB за91с.
Это не принято как стабильное исправление memory issue; retained root не доказан.
В коде введён primitive-only owner v2: SDK handles/Promise/errors остаются в iframe,
parent получает JSON-строки, late start и все каналы завершаются независимо.
После решения владельца вернуться к основной работе завершены последовательно
полный typecheck,740 frontend tests,64 focused backend tests и build. Дополнительно
устранена потеря pointer/Escape при iframe boundary для существующих controls;
16 focused tests включают перевод координат native divider и cleanup listeners.
На8000 `app-DHVEqf5x.js`, backend89747 не перезапускался. Build15.14с,
max RSS1994817536 bytes; OOM нет. Swap за интервал финальной сборки вырос
1934.69→2173.69MiB: это не доказательство отсутствия resource pressure.
Browser auto-review отдельно отклонил тяжёлый M49 viewer при pressure2; запрос
владельцу отправлен, обхода не было, проверочная вкладка закрыта. Этот конкретный
visual gate открыт, но он не останавливает работу над input/catalog/contracts.
Подробности: [memory lifecycle](../experiments/perception/OBSERVATORY_MEMORY_LIFECYCLE_2026-09-03.md).
**Непосредственно дальше — основной этап2:** перед повтором LAB V1 исправить
ресурсную политику диска новой sealed версией и сохранение диагностики отказа;
не повторять известный350GiB admission FAIL. Затем закончить2C. Также открыты
versioned recorded-analysis и
incremental input2A, завершение saved-viewer2B, затем последовательная матрица
LAB V1/вторая запись2C и recovery/постраничный каталог2D. Подготовку2A и metadata
fixtures2D можно продолжать независимо от разрешения на тяжёлую browser-проверку.
Прежний `_capture_arrays()` остаётся только в cold producer path нового adapter;
early warm lookup и общий Worker source CAS проверены и установлены. Не называть
их новым incremental execution. Producer SHA pack неизменен; новый input path
должен сохранять старые exact-cache результаты. Memory lifecycle остаётся
критерием завершения операции, не отдельной
бесконечной оптимизацией или условием переписать план. Не повторять claim/v3,
ремонт селектора, Docker VM или сетевой canary. Лабы в целом ещё не закрыты.
## Карта продолжения, проверка и восстановление
@@ -268,6 +538,9 @@ visual entities требуют отдельного решения. Провер
- 2026-09-02, владелец: recorded-first вместо сетевого realtime-first; старые
FAIL сохраняются, лаборатории не зависят от LTE/борта.
- 2026-09-03, владелец: результат внизу вместо completion button/status — реализовано.
- 2026-09-03, владелец: активной операции разрешён необходимый расход памяти;
завершённая обязана освободить временные ресурсы. Не заменять cleanup снижением
качества/новыми жёсткими RAM caps. Little Snitch не трогать; файлы результатов сохранить.
- 2026-09-03, актуализация: этап 1 — реализованная основа, сквозной proof явно
в этапе 2. Это уточнение границы приёмки, не приписывание успешного end-to-end run.
- Desktop17/20, утверждение об отсутствии online prototype и безусловный1×
@@ -110,3 +110,48 @@ not a realtime inference or navigation claim.
- The migration does not improve DDRNet quality, prove terrain traversability
or grant navigation/actuation authority. Those remain separate model and
safety acceptance questions.
## Resource ownership addendum — 2026-09-03
An active calculation/view may allocate what its admitted workload needs. On
termination its ephemeral resources must become releasable; lowering evidence
quality or deleting durable results is not a substitute for lifecycle cleanup.
Recorded viewers run the same unmodified upstream SDK in a disposable,
same-origin iframe inside the existing viewer surface. A small synchronous
facade exposes only the public methods already used by the product adapter.
The revision-2 boundary returns primitive JSON strings, including events and
errors; the parent parses them in its own realm. Native handles, DOM nodes,
channels, unsubscribe closures and SDK startup Promises stay inside the iframe.
Parent-owned RRD bytes enter a synchronous channel call, without transfer or
changing the payload/quality. Parent startup Promises are settled by primitive
notifications and rejected on disposal. Every channel is closed independently;
a late SDK startup completion is stopped again. Teardown severs references and
removes the iframe. There is still exactly one native renderer and clock,
no new playback UI, SDK patch or second backend. The live source path stays
direct upstream. Page-cache restoration starts a fresh viewer lifecycle.
The iframe relays only Escape and primitive pointer fields to parent-owned
events for the existing outer controls. Coordinates are translated by the
iframe bounds, including the native-chrome crop. Native Rerun still owns its
canvas gestures and divider; nothing is cancelled or sent back to the canvas.
The parent tracks the same divider for control alignment, not a second layout
or renderer. Teardown removes every relay listener and foreign frame reference.
The backend's ephemeral blueprint store has an exact viewport owner, explicit
release, idle expiry (300 seconds; reaped every 30 seconds), and shutdown cleanup.
An open owner renews every 30 seconds. Release is serialized with rendering;
late updates for that released identity are rejected. Expiry never stops an
active render, a recording, a Worker profile or a vehicle. Durable files are not
removed. The pre-existing 32-entry cache bound is unchanged; no new active data
or image-quality cap is introduced.
DOM disappearance is not memory acceptance. Compare process footprints before,
during and after ordinary sequential open/close cycles without forced GC or
page reload. See the dated memory-lifecycle report for measurements and limits.
Revision 2 and the input relay passed full typecheck, 740 frontend tests,
64 focused backend tests and a production build, served on canonical 8000.
Real browser acceptance of this revision remains pending: auto-review rejected
opening the heavy saved viewer under elevated pressure; explicit owner approval
was requested. This does not gate unrelated input/catalog contract work, nor
does a successful build prove that the recorded memory retention is fixed.
@@ -1,5 +1,10 @@
# ADR 0050 — Recorded Observatory first; portable profiles then onboard
> 2026-09-03: [ADR 0051](0051-modular-observatory-profiles.md) now defines
> modular Docker composition and grouped single-provider settings. Its UI and
> packaging rules supersede the finite whole-profile selector/one-image target
> below. Recorded-first, exact cache, preserved evidence and safety remain.
Date: 2026-09-02. Decision accepted by the owner; implementation in progress.
This supersedes ADR 0049's realtime-first product gate and execution order,
not its live-stream integrity, ownership or safety contracts.
@@ -182,6 +187,29 @@ See [real-run and restart evidence](../../experiments/perception/OBSERVATORY_REC
## Boundaries
Owner clarification, 2026-09-03: Observatory's result list includes only admitted
portable-profile publications, not every historical LAB linked to a source.
Legacy contours, canonical archive projectors and stored evidence remain
unchanged. Filter by executable replay/publication binding, never by LAB label,
date, model name or equality to the currently installed definition. Older valid
portable versions remain reviewable. Versioned opt-in cursor pages feed the
existing source search; legacy catalog consumers retain their previous response.
See [catalog boundary evidence](../../experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md).
Source-reuse increment (2026-09-03): both current control agents now share a
Worker-local source CAS. Every generation still obtains its own authenticated,
claim-bound manifest and checks exact digest/length before reusing bytes. Missing
camera members are fetched individually instead of retransferring a partly cached
epoch. Job/output/profile identities and model packages are unchanged. Cached
visual results remain served by Core, not this Worker input cache.
M49 now selects an exact current-producer LiDAR v2 pack before raw decoding;
the legacy producer file and its identity hash remain unchanged. Full source
hash and strict NPZ integrity validation still occur on reuse. Cold whole-source
materialization, versioned recorded-analysis and full workflow acceptance remain
open. Shared-cache synthetic Worker proof is not a model run or a throughput
measurement. See [source-reuse evidence](../../experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md).
No Synology deployment, hardware actuation, motor integration, new capture,
silent model substitutions or deletion of recordings/results. A Docker image's
portability does not promise that a CUDA build runs unchanged on Apple Silicon.
@@ -0,0 +1,181 @@
# ADR 0051 — Modular AI containers and composed Observatory profiles
Date: 2026-09-03. Owner-approved target; the first local modular vertical slice is installed.
## Decision and supersession
A reusable module is an independently packaged Docker image for a model or a
cohesive algorithmic function. A profile is an immutable, validated composition
of module versions, parameters, input/output connections and execution policy.
A LAB run applies that composition to an exact recording snapshot. A new
combination does not require building another image containing all models.
This replaces the earlier **one full profile = one image/container** packaging
requirement in the historical plan. It keeps ADR 0050's recorded-first purpose,
ADR 0048's generic installed-package boundary and the stream contracts from
ADR 0049. Several containers are not intrinsically a defect. Per-LAB application
forks, hidden host dependencies and duplicated data preparation remain defects.
The future onboard host is CUDA-capable, architecture/runtime compatible
hardware. It is explicitly **not Mac Mini**. Exact CPU architecture, GPU,
driver and inference-runtime compatibility still need target qualification;
CUDA availability alone is not a promise that every existing image will run.
New owned module images/containers use the `ndc-` namespace, readable model or
function names, explicit versions and ownership labels. A composition has its
own readable LAB label; module/composition/job identities are linked in runtime
metadata. A reused module need not be renamed to match every LAB that uses it.
## Operator composition
Each recording exposes AI-layer settings inside the existing Observatory.
Use functional groups with a single-choice Select, not a checklist of arbitrary
containers. Segmentation admits **at most one** provider: DDRNet or EoMT, never
both in the same new LAB. Optional groups may offer None if the resulting graph
still satisfies its declared outputs. A geometry-only or segmentation-only LAB
is valid and must not claim the omitted capabilities.
Candidate groups are segmentation, object detection, LiDAR/TGS geometry and
costmap, object/range association, motion and policy-shadow. Their final physical
module boundaries follow existing code ownership and measured data exchange,
not a mandatory container per mathematical operation. Multiple capabilities
provided by one module do not instantiate that module multiple times.
The server validates provider cardinality, dependencies, source capabilities,
calibration/time frames, output contracts and installed Worker versions. A client
cannot supply executable argv, image names, host paths or arbitrary resource
settings. Invalid dependencies are explained, never silently supplemented by
old LAB overlays or an unselected second segmenter.
An exact existing published composition is opened, not recomputed. The settings
remain editable so the operator can create another composition. There is no
Calculate action for a complete exact cache hit, no “calculation complete”
button/badge, and no duplicate LAB. The grouped selectors keep already-used
providers: completion belongs to the whole composition, not to an individual
dropdown option. This supersedes the old finite-profile selector grammar, not
its exact-cache and idempotency guarantees.
## Execution, identity and caches
- Keep one recorded owner on Worker006 and serialize heavy GPU execution.
Selecting several modules does not authorize concurrent GPU model jobs.
- Share source delivery/preparation locally on Worker. Intermediate image/point
buffers do not round-trip through Core between modules. Do not reintroduce
whole-recording upload/decode as a permanent streaming-start prerequisite.
- Keep a common timestamped, bounded I/O contract with explicit sensor gaps,
coordinate frames, unavailable outputs and causal temporal state. Recorded
analysis may be slower than acquisition; live has separate freshness gates.
- Freeze graph topology, module image/weights/code/config identities, input
identities, preprocessing, calibration, cadence, precision and state policy.
Canonical ordering of the selection UI is not part of the semantic identity.
- Reuse a node only for exact validated inputs and producer identity. A changed
detector invalidates its dependent range/motion results, not an independent
segmentation result. Stateful reuse also binds initialization and history.
- Final LAB/cache publication remains on Core. Worker source/preparation caches
are not the final viewer store. Failed publication retries the sealed result,
not inference. A partially cached graph is not a complete ready LAB.
- Record reused versus computed nodes and provenance. Cache playback or cached
composition assembly is never reported as measured onboard processing FPS.
Onboard modules are long-lived within a mission; do not start a container or
reload weights per frame. Resident memory, local transport, bounded queues and
end-to-end latency require a joint benchmark. An optional later co-location of
hot nodes is a measured deployment optimization, not a reason to remove logical
modularity. No automatic actuation or navigation acceptance is introduced.
## Current implementation and migration
Current M49 runs CPU TGS in its specialized agent. Current LAB V1 is an installed
fixed stack: prepare → EoMT → DDRNet → assemble, with host-bound model/runtime
assets. Neither is the new modular composition system. Keep the successful M49
publication and exact existing releases; retain old dual-segmentation results
as historical comparisons, without admitting that combination in new profiles.
Reuse `InstalledLabPackage`, its generic launcher, queue/claim/recovery, source
CAS, verified publication and common viewer. Extend the versioned contracts for
compositions and per-node reuse. Do not redesign the queue or create another
agent per model. Source CAS alone does not implement intermediate-result reuse.
The owner authorized Worker cleanup. Classify every exact container/image/cache
against running services, pinned packages, model assets, rollback and retained
evidence before deleting. Stopped instances can be retired independently of
images and volumes. Preserve raw recordings, published LABs, Legacy views,
required source/model caches and one usable rollback; no global Docker prune.
The accepted EoMT disk floor is now **250 GiB plus its working-set estimate**.
Source and narrow tests have changed; installed EoMT still has 350 GiB until a
new sealed image/release is activated. Never patch a historical digest in place.
## Acceptance and route
Done means: select one provider per group; compute two distinct compositions on
compatible recordings with exact cache reuse; view their complete bound outputs
from Core; recover publication without compute; prove exclusive GPU scheduling
and cleanup; cold-start the selected module distribution without developer
checkout or unlisted caches. Onboard qualification remains future work.
The historical combined route remains documented in
[the four-stage ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
## Local implementation record — 2026-09-03
The first implemented slice replaces the Observatory's fixed profile selector
and separate refresh/calculate controls with one always-available
`Сконфигурировать AI-слой` action for the selected source. Its canonical Window
contains functional groups, one provider Select per group, the close action and
one `Рассчитать` action. The Window uses background, spacing and typography for
grouping; it has no group outlines, header divider lines or modal rim.
DDRNet and EoMT are independent alternatives in the Segmentation group. They
share only the model-neutral `camera-source` preparation step and neither model
consumes the other model's result. The installed module repositories and exact
local image identities are:
- `ndc/mission-core-ai-module-camera-source`
`da926459aee0a841bbdfaf80a0eb5fbead354c56794d1f3384eeba66d0a49e00`;
- `ndc/mission-core-ai-module-ddrnet`
`489fc7d1157fd0f1cd1d82e06a15737b7b2aaaf72b2ddb2aca2992b91a97488e`;
- `ndc/mission-core-ai-module-eomt`
`5b770178e4a5c8fbe8f8ddab3b83a598973dbe103b669851110b11a0918ab846`;
- `ndc/mission-core-ai-module-rf-detr`
`2b8b44be8e9ee4060aa6997fc4c025ad7120f37ecd720a9e59b5e02ac6c90f66`;
- `ndc/mission-core-ai-module-object-distance`
`69d68f64981b41e5bcce1e642433e3d466b88c51942a5ba3aec9180d8ed04263`;
- `ndc/mission-core-ai-module-tgs`
`f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3`.
The executable graph also contains `ai-detection-rf-detr-v1` for image-space
boxes and `ai-range-object-distance-v1` for box-to-LiDAR association. The range
package executes RF-DETR as its explicit dependency, then uses synchronized
LiDAR, pose and calibration to estimate range. TGS consumes LiDAR, pose and its
own history independently. Selecting TGS and range therefore produces two
independent durable jobs; segmentation does not feed either of them. The common
Worker agent launches package steps with job-specific container names under
`ndc-mission-core-ai-module-*`, serializes GPU work, publishes a
`missioncore.recorded-ai-layer-review/v1` result and leaves the historical
combined LAB V1 and Legacy contour intact.
Submission creates a durable Observatory job immediately. The source-bound
evidence area shows its selected configuration, current phase, measured
progress and an estimated remaining time. A published result moves into the
existing evidence list and uses the same open, rename and delete actions as
the other portable LAB results.
The active Worker 006 directory release is `20260903-v5`, release SHA-256
`a87c82adfa1058f14f850f488ace38e5c7c9d9e2145d4678a81b52f2a1aad573`.
Its common agent image SHA-256 is
`d6f2ef1a3f38ebc8503b62296ca9def10472a60a41ffc5edd123df565520dec8`.
The DDRNet, EoMT, RF-DETR and object-distance definition SHA-256 values are
respectively
`b04206a8472588fee22e0282228e51b7817c80f9739180e834b704733d7aaf76`,
`6d13402883e8fa79a8ea97b15e81e704d3c0f9dac5d94445e5e53c18dcebe98f`,
`a89f51a66a070deb50f4904f596545cdcd7289df7e42b8511aec9547dedd52e8`
and `cc56e72fdfb38565a0b402c90a91917821463d980f5855e4cdf4d8b9f262a94c`.
The standalone detector and composite range package have distinct resource
profile identities, so their otherwise shared RF-DETR model manifest cannot
collapse into one executor registration. All four package executor identities
are unique. DDRNet, EoMT, RF-DETR and object-distance pass live source,
executor, sealed-definition and durable-queue preflight for
`20260828T130511Z_viewer_live`; the published M49 TGS result is an exact cache
hit. No new full-recording RF-DETR or object-distance inference was run as part
of this installation acceptance.
@@ -0,0 +1,68 @@
# Missing Core Observatory domain ontology v1
Status: local runtime canonical. Owner: NODE.DC Mission Core.
The Observatory now has four independent consumers of the same relationships:
composition validation, Worker job dispatch, LAB/result projection and replay
controls. The previous typed-contract-only approach lost the relation between a
multi-module submission and its member results. This satisfies the local
ontology admission gate in `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
The executable source is `config/observatory-domain-ontology.json`. It defines
transport and equipment units, time-bounded equipment mounts, capture profiles,
recorded sessions, module versions, immutable compositions, append-only
composition runs, container images, worker nodes, jobs, portable results, LAB
projections, viewer panes and viewer layers. These are peer subjects with independent identities and
lifecycles. A per-result LAB view profile owns the operator's mutable SLAM
display choices. The entities follow the NODE.DC Platform ontology architecture without making
Platform a dependency. Its named queries are recording capture context,
configuration label, member results, viewer layers and ready state.
A recorded session binds to the transport, equipment units, equipment mounts
and exact capture profile that produced it. A composition run points to that
session and never substitutes its own identity for transport or equipment. This
keeps future vehicle and sensor changes separate from model configuration and
from the published evidence.
A module version is implemented by an immutable container image. Installation
of that image on a worker and execution of a recorded job on a worker are
separate relationships, so Worker 006 is not encoded inside the module identity.
The replay layer remains a capability of the module version and can therefore be
derived through the module-to-image relationship without coupling UI controls to
a Docker runtime instance.
One saved composition is opened by one Rerun viewer and one shared recording
clock. Camera, LiDAR, depth and future sensor views are panes inside that viewer's
blueprint. Every pane owns its layer controls; the overlay follows the pane bounds
when a separator moves. A second Rerun viewer is reserved for a genuinely
independent recording or clock, because duplicating a viewer per pane would also
duplicate the recording transport, memory and synchronization work.
Pane construction is projected from module capabilities. DDRNet and EoMT create
only the camera pane with their independent segmentation layer. RF-DETR creates
the camera pane with object frames. TRAVEL TGS creates only the spatial pane with
source points, local SLAM and TGS. Object distance composes the camera detections
and spatial LiDAR panes it needs. Combining modules takes the ordered union of
these pane layers, so a configuration cannot invent an unrelated camera or point
cloud viewport.
Operator SLAM display settings belong to the published LAB result identity. They
are mutable presentation state, stored atomically in the Mission Core runtime,
and are loaded again after browser storage is cleared. They do not modify the
immutable calculation result, module configuration or recorded evidence.
Identity and lifecycle remain owned by the existing stores. The ontology does
not replace their contracts and does not contain executable commands, mutable
container instances, paths or resource grants. Image digests and worker
identities remain facts supplied by their owning registries. The ontology
projects their exact identities into one shared relationship model.
Version 1 uses additive migration for existing single-module results. A recorded
composition run may reference an already published exact module result; this is
recorded as reuse, not a new inference. Failed attempts remain audit evidence and
do not satisfy the ready-state query.
There is no runtime dependency or write path to NODE.DC Platform Ontology. The
Platform repository supplied the package architecture only. Synchronization is
`none` until a meaning becomes stable across products and receives a separate
owner-approved migration.
@@ -0,0 +1,113 @@
# Observatory: граница с Legacy и постраничный каталог
Session: `observatory-catalog-cleanup-tpit2O`, 2026-09-03.
Изменение поверх `eff60e4`; прежние незакоммиченные viewer/source-reuse правки сохранены.
## Решение и результат
По уточнению владельца Обсерватория показывает результаты переносимых профилей,
а не весь архив исследований, связанный с выбранной исходной записью.
Legacy «Лабораторные контуры» и их данные остаются нетронутыми. Разделение —
типизированная проекция, не удаление LAB, не миграция исходников и не список
запрещённых имён E24/E25/E26.
В текущем каталоге RAVNOVES004TREE теперь показывает один опубликованный M4.9T5
от3 сентября. Старый M49 без replay capability и исторический canonical LAB V1
не попадают в Обсерваторию. У RAVNOVES00 вместо14 исторических карточек —0.
Нерассчитанные portable profiles по-прежнему доступны для расчёта. Существующие
версии полноценных portable результатов не скрываются по возрасту или несовпадению
с текущей установленной версией профиля.
## Реализация
- `core/observatory/catalog.ts`: принимает только `portable-result-review` через
существующий строгий `observatoryRecordedRunBinding`: source/result/definition
identity и publication provenance. Неверная portable identity вызывает ошибку,
не переименовывается в Legacy и не скрывается как успешная очистка.
- Старые canonical/experimental adapters и их endpoints не удалены. Их наличие
в коде не означает включение в новый продуктовый список. Общий session catalog
продолжает возвращать исторические записи своему прежнему потребителю.
- Backend opt-in `pagination=cursor-v1` добавляет versioned page schema и
`next_cursor` из существующей keyset pagination `SessionStore`. Без opt-in
форма ответа остаётся ровно прежней: `items`. `scope`, `lab_contract` и
правила публикации/скрытия проекций не менялись.
- `core/observation/sessionCatalogPage.ts`: отдельный строгий page adapter.
Observatory дочитывает страницы по100 metadata entries, проверяет уникальность
записей, повторные/небезопасные cursors, версию/размер ответа, HTTP errors и
отмену. При ошибке отменяется второй обход; существующий hook сохраняет
последний успешный каталог и показывает ошибку обновления.
- Предел обхода —256 страниц на scope (по умолчанию до25600 строк). Достижение
предела оставляет явный partial-window флаг; отсутствие записи в таком срезе
не подтверждает удаление. Исторические provenance не накапливаются при обходе.
Список хранит только метаданные, не видео, облака или result artifacts.
- Текущий поиск `Select` работает по всем загруженным источникам. Отдельного
server-side текстового поиска нет. Это keyset traversal, не транзакционный
снимок всего каталога: новая запись во время обхода появляется при следующем
Refresh; удалённый cursor вызывает ошибку/повтор, не выдуманную полноту.
- Из карточек убран внутренний `result_kind`, пояснение над списком описывает
операторскую задачу. Новых controls, визуальных паттернов, CSS или per-LAB
branches нет; скилл Mission Core UI сохранил канонические controls и разделение
`core → workspace`.
## Проверки
-24 focused frontend/architecture tests PASS; полный последовательный frontend
suite747 PASS; full typecheck PASS. Backend41 tests PASS; Ruff/mypy PASS.
- Fixture500 источников и102 LAB entries проверяет связь portable результата
со source499 после первой сотни legacy rows. Отдельно: точная полная страница
без ложного limit flag, дубли/cursor cycle, abort, небезопасный cursor,
неизвестная версия, превышение страницы и явный предел обхода.
- Backend102 sources +102 LABs: старый ответ неизменён; страницы100+2, без
потери/дубликатов; scope-invalid cursor404, неизвестная pagination422.
- На действующем8000 production adapter с `limit=1` сделал20 GET:3 источника,
17 исходных LAB rows,1 admitted result,0 unresolved, оба has-more false.
UTC13:24:52.49113:24:52.921; monotonic `performance.now()`
130.567750559.498167ms в одном Node-процессе. Это metadata proof, не FPS.
- Browser: normal/expanded composition, поиск и выбор RAVNOVES00/004TREE,
обе нерассчитанные версии у00, один текущий M49 у004TREE, Refresh и Escape
dropdown проверены. Вне dropdown Escape не изменил modeless окно; закрытие
полного viewer и memory lifecycle в этом проходе не проверялись.
- Legacy API до и после установки совпал побайтно. Worker queue сохранила
10failed/2succeeded,0 live leases и1 v3 grant. Новых model jobs —0.
## Установка и эксплуатация
Сборка `app-9nK8VtbK.js` обслуживается единственным Core на8000, PID1425 после
контролируемого перезапуска idle-сервиса;8765 пуст. Предыдущая frontend-сборка
сохранена в private evidence `previous-dist`, а не удалена. Backend declarations,
порты и LaunchAgent не изменены.
Первый build с искусственным V8 heap limit1024MiB завершился heap-limit OOM
до установки; прежний UI оставался рабочим. Последовательный build с2048MiB
прошёл за10.84с, maximum RSS2253422592 bytes. Это ограничение build-процесса,
не доказательство нехватки всей RAM Mac. Swap2133.69→2301.06MiB после сборки,
pressure2; временные build/test процессы завершены. Heavy replay не запускался,
прежний browser gate не обходился. Little Snitch, чужие приложения, Docker
limits, GPU и модельные контейнеры не менялись. Оставлена одна лёгкая вкладка
Обсерватории для владельца, без viewer/inference.
## Остаток маршрута
Выполнена часть2D: product/Legacy separation и постраничный metadata catalog.
Это не закрытие всего этапа2. Далее остаются2A — versioned recorded-analysis и
cold incremental input;2B — синхронный replay/ручной ракурс/ресурсный lifecycle;
2C — последовательные LAB V1×004TREE и оба профиля×RAVNOVES00;2D — оставшиеся
recovery/negative cases. Standalone полного графа3 и реальный борт4 отдельно.
Ops-карточка в этом инкременте не обновлялась.
## Evidence
Private root: `.runtime/observatory-catalog-cleanup-tpit2O`. API metadata,
screenshots и runtime output не добавлены в Git.
| Артефакт | SHA-256 |
| --- | --- |
| `legacy-before.json`, `legacy-after.json` | `4b5a19d83569c598e9936f1a65ef445d65eccc4bdc2731823bbbdebbf746cabc` |
| `live-pagination-proof.json` | `1a5c65b04b34f2f797c927f3e29094b399f9c51c717786a1975344e6cc975dcf` |
| `frontend-tests.log` | `674745c5a008cdb39a5d5a0b360ddff304da855dca2ba899ca2bd341a415dee0` |
| `backend-tests.log` | `d8b26fab3621b44e79ae5555ccd2de5447fb1efac9c7c2737b97bf8563e7e2e1` |
| `build-2g.log` | `f1b7d14266cee959bc69b271cffa17a4d1483c2af7330301fbc7261a01519a7f` |
| `worker-readiness-after.json` | `26db0da5261bae8b1b85cb06c93f633dcaa9d732a543c6fca24326a6831a7758` |
| `normal.png` | `c45fe93e56a1edaf6580bcbef5848d09a34e5bdc84903103567a58e47191d5db` |
| `rav00-results.png` | `63cfbbbc6e89b2bb17ddd68c83dddb89a235e8d9eb54709955a70139dd2c76f2` |
| `rav004-results.png` | `28520a65a459b6736697cfd82527df9ba2eb3ebacfd1888a70f914b1e4f43809` |
@@ -0,0 +1,118 @@
# Observatory: возврат к основному лабораторному сценарию
Дата: 2026-09-03. Session: `observatory-mainline-FZrkOa`.
Checkout: `codex/m5-1-observatory`, инкремент поверх `eff60e4`.
Handoff snapshot: UTC `2026-09-03T12:22:38.948Z`, Node `process.hrtime.bigint()`
`2222283739491791` ns. Catalog snapshot имеет отдельные UTC/monotonic в evidence;
часы разных механизмов не вычитаются друг из друга. Для тестов/build сохранено
измерение elapsed через `/usr/bin/time -l`, не per-stage trace приложения.
## Решение и граница
Владелец потребовал вернуться к основному плану и не превращать общую занятость
RAM Mac в бессрочный блокер. Активные процессы могут потреблять нужную память;
после завершения временные ресурсы освобождаются. Это правило не отменяет
зафиксированный дефект закрытого viewer и не означает, что лабы уже приняты.
Только локальный Core8000, без Worker/model runs, внешнего deployment, изменений
Little Snitch/пользовательских служб/Docker limits, удаления записей/cache.
UI skill сохранил один renderer/clock, прежние controls и последовательные gates.
## Объективный статус четырёх этапов
| Этап | Что доказано | Что остаётся |
| --- | --- | --- |
| 1. Основа | Admission, очередь/claim, защита от дублей, exact cache/version/selector | Не повторять уже выполненный ремонт |
| 2. Полные лабы | M49×004TREE рассчитан, sealed/published, документ/cache пережили restart; общий saved replay реализован | Versioned recorded-analysis/input, visual acceptance, LAB V1 и вторая запись, recovery и большой каталог |
| 3. Полный Docker-профиль | Существует инженерный граф DDRNet/RF-DETR/LiDAR/distance/motion/TGS/costmap/policy-shadow | Самостоятельный image/cold start, полный run и сравнение конфигураций |
| 4. Борт | Цель определена | Реальное целевое оборудование и отдельная квалификация; сейчас не переносим |
**Лабы целиком не закрыты.** M4.9T5 сейчас CPU TGS; LAB V1 — последовательная
EoMT+DDRNet segmentation, а не готовый полный профиль рига. Старые FPS и
красивые overlays не доказывают целый вычислительный граф в одном Docker.
## Сверка незакрытого кода
- `core/observatory/catalog.ts:fetchObservatoryCatalog` запрашивает два окна
по100 metadata entries (source/laboratory), не продолжает cursor. API
`/api/v1/observation-sessions` cursor уже принимает. Нужно довести постраничные
source/result связи и поиск, не грузить записи целиком и не скрывать результаты.
- `compute/lidar_replay.py:build_lidar_replay_pack_v2` вызывает `_capture_arrays()`
до cache lookup. Это не инкрементальный вход. Source/logical/producer hashes
участвуют в identity, поэтому новый путь требует версионирования и сохранения
старых exact-cache результатов, не механической перестановки строк.
- `viewer/recorded.py:_RecordedBlueprintStream` активирует clone при обновлении
слоёв; сохранение ручного eye не решено. Нужен поддержанный upstream-механизм,
не локальный патч SDK/WASM и не второй renderer. Initial camera frame и
полная visual coverage тоже ещё не приняты.
## Выполненный инкремент
Накопленный primitive-only owner v2 проверен и собран. Дополнительно исправлена
потеря input между native iframe и существующими внешними controls: Escape и
pointer fields копируются в parent-owned events с переводом координат через
bounds iframe. Слежение за native divider снова получает события. Исходный
canvas не получает синтетических действий; native gestures не отменяются.
Все5 listeners удаляются при dispose. Это минимальная поддержка прежней
композиции, а не новый UI control или новый clock.
## Итоговая последовательная проверка
| Проверка | Результат | Elapsed | Maximum RSS bytes |
| --- | --- | ---: | ---: |
| Lifecycle/input + architecture | 16 PASS | — | — |
| Полный TypeScript | PASS | 4.87с | 904871936 |
| Полный frontend suite | 740 PASS | 21.73с | 264798208 |
| Lifecycle/session API/RRD backend | 64 PASS | 3.81с | 190988288 |
| Production build | PASS | 15.14с | 1994817536 |
Первые739 frontend tests и первая сборка сохранены отдельно; после input relay
повторены итоговые gates740. Число16 входит в740, суммы не уникальные тесты.
RSS относится к измерению команды, не всей физической памяти Mac. В backend
сохранилось deprecation warning Starlette/httpx; в build — warning больших chunks.
Serving: `app-DHVEqf5x.js`, runtime `rerun-Dy4Vuq0s.js`, upstream SDK0.36.3 без
патча; WASM `re_viewer_bg-BO4B44yr.wasm` 50428810 bytes. `/`, `/rerun-runtime.html`
и `/api/health` отвечают успешно, PID89747 на127.0.0.1:8000;8765 пуст.
Нового backend restart не было.
## Browser и ресурсы: точная граница доказательства
Каталог нового v2 был открыт обычным путём LAB→Обсерватория до input relay patch;
renderer92266=136MB, shared GPU72641=408MB, Core89747=164MB. Это не heavy replay.
Auto-review отклонил открытие готового M49 при pressure2 из-за риска прежнего
остаточного потребления. Запрошено явное разрешение на короткий просмотр;
нет ответа на момент этой сводки. Обхода не было. Следовательно, normal/expanded/
Escape/divider и open/close именно последней сборки **не приняты в браузере**.
Временная проверочная вкладка закрыта; canonical Core оставлен работающим.
OOM не произошло. До финальной сборки swap1934.69MiB, после2173.69MiB,
pressure2. Нельзя назвать RAM неограниченной или заявить memory-fix PASS по
успешному build. Docker — только три durable telemetry containers примерно
56/292/13MiB. Посторонние приложения и VM limits не менялись.
## Продолжение и приёмка
Продолжать оставшийся2A — versioned полный анализ/incremental input; при
разрешённом просмотре завершить2B. Далее LAB V1×004TREE, оба профиля×RAVNOVES00,
затем recovery/negative cases и пагинация. Подготовка metadata fixtures2D не
зависит от heavy browser gate. Старые данные/результаты остаются immutable.
Отдельный memory gate не подменяет эти задачи. Этап3 следует за принятым
лабораторным циклом; борт, моторы и сетевой realtime PASS сейчас не требуются.
## Evidence manifest
Private directory: `.runtime/observatory-mainline-FZrkOa`.
Исходные записи/скриншоты/секреты в Git не добавлялись.
| Файл | SHA-256 |
| --- | --- |
| `input-bridge-tests.log` | `b007e29a4b828b0982347dee267c0498424048abd7441efa0a1abc6ccdc54592` |
| `input-bridge-typecheck.log` | `7c66ca38e202d5c34eaf2509723281e558aadb8fd8716ca38095d31cac877098` |
| `input-bridge-full-tests.log` | `e8a103bb84f7412706f29661ca32a63c3b9f8a34b4e9d83a1a1bf468a009242e` |
| `input-bridge-build.log` | `a437b38a1d6873707193e742c8719942b0842034adbe6168e5e507771b13226c` |
| `backend-tests.log` | `3ccd6d31b1de48f0506557ab5cb83324121fdd80ca143a2d72a5dc7080bfed56` |
| `01-catalog.txt` | `f226d322bd9749245a6d436806829b015038fa829ccb96979544ccb7d0518066` |
Ops ранее не ответил на direct read; карточка в этом инкременте не менялась.
Актуализированы локальный ExecPlan и Desktop final-status.
@@ -0,0 +1,216 @@
# Mission Core: освобождение ресурсов после просмотра — 2026-09-03
## Решение и граница
По уточнению владельца активная операция использует необходимую ей память.
После завершения/закрытия/отмены/ошибки освобождаются её временные буферы,
визуализатор, каналы, таймеры и серверные сессии. Новые ограничения качества,
разрешения, числа точек или размера активной записи не вводятся. Дисковые
записи, результаты и replay-cache сохраняются. Little Snitch, пользовательские
приложения и настройки Docker VM не менялись.
Это исправление найденного lifecycle записанного просмотра, не приёмка памяти
всех операций Core/Worker. Холодную подготовку, расчёт полного профиля,
публикацию и live receiver нужно проверять отдельно по стадиям и PID.
## Доказанная исходная проблема
На `eff60e4` сохранённый M49 RRD размером 193622178 bytes открывался без compute.
Renderer: 111 MB до → 1104 MB открыто → 567 MB спустя 74 секунды после закрытия.
Reload снижал его до 133 MB. GPU-процесс: 372 → 838 → 825 → 363 MB.
Core оставался около 168170 MB; Docker VM — 2613 MB.
В upstream 0.36.3 `stop()` уже вызывает native destroy/free/deinit, но цикл
`check_for_panic` продолжает планировать таймер. Наличие этого дефекта установлено
по коду; он не доказан как единственный владелец всего остатка памяти. По ADR0045
SDK не патчится. В backend также отсутствовало освобождение blueprint-сессии
при закрытии UI: ограничение 32 владельцами само по себе не завершало lifecycle.
## Реализация
- `components/rerun/`: disposable iframe и фасад публичного upstream API.
Уничтожение контекста с очисткой ссылок, включая подписки и каналы, даже при
ошибке SDK stop. Ни родительская Promise, ни сохранённый callback не должны
держать foreign DOM/SDK object после dispose. Один native clock сохраняется.
- `RerunViewport`: cleanup на unmount/pagehide; новое owner-id при повторном
запуске; восстановление из browser page cache через новый lifecycle.
Активный live path не переведён в iframe.
- `recordedBlueprintLifecycle`: renewal раз в 30с и bounded keepalive release.
- `recorded_blueprint_lifecycle.py`: explicit release, idle TTL 300с,
render/release exclusion, временный запрет запоздалого повторного открытия
released owner, очистка failed render и shutdown. Reaper раз в 30с.
Потеря renewal удаляет только восстанавливаемый ephemeral blueprint.
- `session_api`: точный owner lifecycle endpoint; released update получает 410.
Освобождение работает и после удаления исходника, не требует materialization.
## Проверки кода
- 64 focused backend tests (lifecycle, session API, RRD), Ruff PASS; mypy новых
registry/recorded модулей PASS.
- Финальный typecheck, 734 frontend tests и production build PASS; тяжёлые
проверки строго последовательны. Сохранилось обычное предупреждение Vite о
больших chunks; SDK не обновлялся.
- Fixtures: stop throws, close before load, late load, timeout, повторный dispose,
сохранённый facade/channel/unsubscribe после dispose, копирование event/range,
отмена renewal и keepalive release; expired owner, reset, LRU, shutdown,
release во время render и fence позднего запроса.
- Канонический backend перезапущен один раз, PID 89747, только 8000. После
холодного старта `/api/health`, `/` и `/rerun-runtime.html` отвечают 200.
## Промежуточная browser-проверка: одного iframe недостаточно
До добавления фасада два штатных открытия того же сохранённого M49 дали:
| Фаза | Renderer 89791, MB | GPU 72641, MB | Core 89747, MB |
| --- | ---: | ---: | ---: |
| Каталог до открытия | 124 | 423 | 152 |
| Первый просмотр, playback | 993 | 743 | 164 |
| Сразу после закрытия | 805 | 401 | 164 |
| Через 89 секунд | 810 | 369 | 163 |
| Повторное открытие | 1016 | 719 | 166 |
| Повторное закрытие | 790 | 410 | 166 |
GPU освобождён; устойчивый возврат renderer к baseline **не доказан**.
Нельзя выдать отсутствие удвоения расхода за полное исправление. Поэтому после
этой проверки добавлено размыкание всех ссылок через facade.
В этой промежуточной версии проверены камера/TGS на начальном cursor, play/pause
(39.215 → 49.899с), переключение SOURCE, normal/expanded и Escape из iframe.
После закрытия iframe/review = 0, lifecycle POST = 204. Самая ранняя +1мкс
граница теперь показала изображение камеры и TGS, но исходный неудобный ракурс
и его сброс при blueprint activation не исправлялись этим инкрементом.
Mac pressure = 1 и swap = 2013.75 MiB без роста в обоих циклах. Docker VM
оставалась 2616 MB. GPU-процесс общий; footprint процессов нельзя складывать
как точную оценку физической RAM приложения. Значения MB округляет `footprint`.
## Финальный facade: функциональный PASS, memory acceptance FAIL
После нормализации pressure до1 выполнен один контрольный цикл финальной сборки
`app-C54IKeub.js`, тот же immutable RRD, без вычисления профиля:
| Фаза | Renderer 90898, MB | GPU 72641, MB | Core 89747, MB |
| --- | ---: | ---: | ---: |
| Каталог до открытия | 112 | 365 | 162 |
| Открыто, пауза | 943 | 680 | 164 |
| После короткого playback | 1014 | 715 | 164 |
| После закрытия | 819 | 411 | 164 |
| Через 62с после предыдущего замера | 829 | 375 | 164 |
Facade **не устранил остаточный footprint renderer**. Браузерный memory issue
не закрыт и итог не выдаётся за возврат RAM к baseline. Явные GPU/server cleanup
и прекращение подписок реализованы; какой native/allocator/GC ресурс держит
оставшуюся память, этим опытом не доказано. Не называть весь остаток живой
утечкой без retained-object/native evidence, но и не называть его безвредным кэшем.
Функционально проверены normal/expanded, Escape из iframe, play/pause
39.215→56.757с, изображения камеры и TGS после playback, iframe/review0 после
закрытия, server lifecycle204; browser error logs пусты. На начальном expanded
кадре камера оказалась пустой, после play появилась: устойчивость initial frame
остаётся визуальным ограничением, несмотря на успешный промежуточный first-open.
Проверки bfcache и аварийного старта — fixtures, не реальные fault injection.
В финальном closed-idle snapshot pressure стал2; swap не вырос (2013.75→2005.75
MiB), Docker VM2616 MB. Проверочная вкладка закрыта. Новые тяжёлые циклы
остановлены resource gate `mission-core-product-ui`. Следующий шаг — локализация
удерживаемой native/WASM памяти либо подтверждённый механизм завершения её
владельца; не ещё один такой же open/close и не принудительный GC/reload как
продуктовое «лечение». Холодный compute и остальные операции остаются отдельно.
## Follow-up: карта памяти и primitive-only owner, 2026-09-03
До изменения boundary повторно проверена прежняя сборка `app-C54IKeub.js`
на том же immutable M49, с `vmmap -w` в каждой фазе. Это новая диагностика
типов/адресов памяти, а не доказательство исправления по исчезновению canvas.
| Фаза, UTC | Renderer91040, MB | GPU72641, MB | Core89747, MB |
| --- | ---: | ---: | ---: |
| Каталог, 11:41:10 | 119 | 381 | 161 |
| Открыто, 11:41:24 | 914 | — | — |
| Закрыто, 11:41:58 | 834 | 420 | 162 |
| Через35с, 11:42:33 | 215 | 379 | 162 |
| Повторно открыто | 941 | 717 | 165 |
| Повторно закрыто, 11:44:10 | 812 | 415 | 165 |
| Через91с, 11:45:41 | 824 | 376 | 163 |
В первом цикле крупные writable области `1f160…/1f161…` исчезли: вместо них
`vmmap` показывает невыделенный 8-GiB адресный резерв с0 resident/dirty/swap.
То есть память действительно возвращалась, не просто уходила в swap. Но
повторный цикл не воспроизвёл этот результат: оставалось543MB untagged memory,
в основном writable/swapped, а не executable code. Это исключает объяснение
всего остатка исключительно скомпилированным WASM-кодом, но **не определяет
конкретный retained-object root**. Общий memory gate остаётся FAIL.
`vmmap` предупреждает, что не может разобрать внутреннюю PartitionAlloc zone;
карта VM не заменяет анализ живых объектов. В этой серии сохранён UTC, но
per-phase monotonic timestamp не был записан; интервал35/91с — разница UTC.
Новая реализация boundary v2 оставляет native SDK, каналы, подписки, Error
объекты и startup Promise целиком внутри iframe. Родитель получает JSON-строки
и создаёт собственные plain objects/Promise; getter/clock остаётся native,
дополнительного polling/clock/renderer нет. Это устраняет сам путь передачи
foreign SDK objects, не выдавая его за доказанную единственную причину остатка.
Межоконные ссылки и prototype chains действительно могут удерживать удалённое
окно: [разбор Chrome](https://web.dev/articles/detached-window-memory-leaks).
Исходящие native ошибки также превращаются в текст. Каждый auxiliary channel
закрывается даже при ошибке соседнего. Dispose немедленно отклоняет pending
parent start; позднее завершение SDK start повторно вызывает публичный stop.
Проверено на v2:15 focused tests (включая architecture), focused strict TypeScript
для пяти runtime-модулей PASS. Проверены все используемые аргументы native
clock/control API, copied events/ranges, primitive errors, close-before-load,
late success/failure и независимая очистка каналов. Это synthetic lifecycle
coverage, **не heap/footprint acceptance**.
На завершении предыдущего прохода production build, полный frontend suite/typecheck и новый browser A/B **не
запускались**: после закрытия тестовой вкладки pressure устойчиво2, swap
1941.75→1917.75MiB не растёт. Temporary viewer закрыт, других наших временных
тяжёлых процессов нет. По `mission-core-product-ui` heavy QA приостановлена;
чужие приложения, Little Snitch и Docker VM не менялись. На8000 остаётся
предыдущая рабочая сборка, backend89747 healthy. Следующий шаг после pressure1:
полный typecheck/tests/build последовательно, затем real normal/expanded/Escape
и bounded memory check v2 с повторным открытием, без forced GC/reload.
При повторном FAIL нельзя объявлять boundary исправлением всей memory issue.
Ops instructions read завершился60s timeout; карточка не изменялась.
## Возврат к основному сценарию: v2 собран, browser gate отдельно открыт
По прямому решению владельца продолжены необходимые последовательные проверки,
без повторного расследования всей памяти Mac. Обнаружен и исправлен ещё один
эффект iframe: canvas pointer events не доходили до родительского слежения за
native divider, поэтому внешние controls теряли выравнивание при его перетаскивании.
Новый relay передаёт только primitive input fields, переводит координаты через
iframe bounds, сохраняет Escape и удаляет все5 listeners при dispose. Native
canvas/gestures остаются у upstream; качество, слои и clock не меняются.
Итоговые проверки:16 focused tests,740 frontend tests,64 focused backend tests,
полный typecheck и production build PASS. Build15.14с, maximum RSS1994817536 bytes
(около1.86GiB), а не требование выделить приложению столько памяти постоянно.
Новая сборка `app-DHVEqf5x.js` обслуживается на8000 без нового backend restart.
На первом проходе каталога renderer136MB, shared GPU408MB, Core164MB.
**Browser acceptance v2 не выполнена.** Auto-review отдельно запретил тяжёлое
открытие готового M49 при pressure2 с учётом прежнего остаточного footprint.
Запрошено явное разрешение на один короткий просмотр; действие не обходилось,
проверочная вкладка закрыта. Сохранённый viewer/Worker inference не запускались.
Все сборки/тесты завершились без OOM, однако swap в интервале финальной сборки
вырос1934.69→2173.69MiB. Нельзя заявить отсутствие memory pressure или приписать
этот системный прирост исключительно Core. Никакие чужие сервисы не остановлены.
Memory issue остаётся открытым критерием завершения операции. Он не блокирует
оставшиеся input/catalog/contracts этапа2 и не заменяет основной маршрут:
полный расчёт → сохранённый просмотр → матрица профилей/записей → recovery.
Проверки и план: [mainline reconciliation](OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md).
## Evidence
Private numeric evidence: `.runtime/observatory-memory-9Xq3IH` (до правки),
`.runtime/observatory-memory-fix-V6D9D8` (текущая проверка и test/build logs).
Follow-up: `.runtime/observatory-memory-owner-UGy0AS` (VM maps, footprints,
focused v2 tests/types; старый browser build отмечен отдельно).
Текущий проход: `.runtime/observatory-mainline-FZrkOa`; hashes и версии сборки
зафиксированы в mainline reconciliation. Полный browser memory A/B v2 там отсутствует.
В Git нет содержимого записи, heap dump или скриншотов исходных данных.
Ops direct MCP не ответил на чтение instructions/projects; запись отчёта в
MISSIONCOR-72 не выполнена и не заявляется успешной. Локальные документы —
фактический handoff до восстановления Ops.
@@ -0,0 +1,245 @@
# Observatory modular handoff — 2026-09-03
## 1. Objective and architecture stage
Owner approved the transition from monolithic full-profile packaging to reusable
Docker modules and immutable compositions. This pass audits CURRENT, records
TARGET, retires inspected obsolete stopped instances and prepares a self-contained
Desktop handoff. It does not implement the new composer or build/install modules.
Decision: [ADR0051](../../docs/adr/0051-modular-observatory-profiles.md).
Sole current route: the new upper section of
[ExecPlan](../../docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
Earlier routes and numerical evidence remain historical, not current commands.
Ops report created through direct Tasker MCP, MISSION CORE card73,
`a6e5bea8-5441-44ca-957f-1c117b1decf3`, at `2026-09-03T16:07:07.955933+00:00`.
Its12 titled blocks separate completed audit from open modular implementation;
the pre-existing saved-replay card72 was not overwritten.
## 2. Decision question and hypothesis
Can Observatory expose functional provider groups, reuse model/function images
across compositions, keep exact cached LABs and later move the selected composition
to a compatible CUDA onboard host? This is the accepted architecture direction,
not yet an experimentally qualified performance claim. Mac Mini is explicitly
not the onboard target. Avoid per-LAB micro-apps and one image containing every
possible model combination.
The UI uses one canonical Select per group. DDRNet and EoMT are alternatives in
new LABs, never two selected segmenters. A module may provide several capabilities
without being instantiated several times. Existing dual-segment LAB V1 remains
legacy compatibility. Exact completed compositions open their existing LAB;
there is no Calculate action or completion badge for that exact configuration.
## 3. Immutable evidence and bounds
Session: `observatory-modular-handoff-mHGULT`.
Repository: `NODEDC_MISSION_CORE_m5_observatory`, branch
`codex/m5-1-observatory`, HEAD `eff60e4`, with pre-existing dirty changes retained.
Private evidence directory:
`.runtime/observatory-modular-handoff-mHGULT` in that checkout; excluded from Git.
No recording contents, credentials, raw logs or model weights enter this report.
Read-only Worker inventory before: UTC `2026-09-03T15:45:51.756951+00:00`, local
monotonic start1116.805708333, duration2.502617583s. After: UTC
`2026-09-03T15:57:28.620618+00:00`, local monotonic start1815.378697625,
duration0.801071041s. Monotonic values are within the local process/host timeline,
not synchronized Worker timestamps.
| Artifact | SHA256 |
| --- | --- |
| worker-inventory-before.json | a7f91a21797c9814d441629b59b5eab1307b2976ac84ecde7c23daef8632ba20 |
| worker-inventory-after.json | 5463f35a3c2b5134d499c495d93d77f7350438038a7a3010031c6842d417fbef |
| stopped-container-archive.json | b1c6d3fbc6ae3fcd9a5a34d1d24616433f7f643e7c8233eab287e88747ed5188 |
| cleanup-receipt.json | 58af029462c7fc73472ab1aae29be89f90c1412b2d4e35bfe23e3fb37c6b1030 |
| Canonical ordered job identity tuples before/after | 69cae43ed43fac04d6443350e39a5f83f0e64f9c7ed58e6889f7320d2b80cc9f |
| Original Desktop document before handoff prepend/rename | 7a0e57ba24eb95c95cd86aae55e24ae933de73746ecb299ce9542f6e8d2228c9 |
## 4. Method, models, algorithms and identities
Read source package/runner, worker queue/transport/cache, publication/replay and
current Observatory selector; compare installed Docker declarations via the
current agent's Docker socket over the existing SSH target `mission-gpu`.
Inventory excludes Env/credentials. Inspect stopped writable-layer diffs and
mounts before deletion; archive logs privately, not in the normal Git tree.
Current installed LAB V1 package:
`1bc84be07634ff69ac7459a2c39dc8fc9bcee33f72985dfa2c6088bb78976d8e`.
Definition:`269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac`.
Release:`667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b`.
| Fixed step | Pinned image SHA256 | GPU request | Memory limit |
| --- | --- | --- | --- |
| prepare | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 4GiB |
| EoMT | adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174 | one | 24GiB |
| DDRNet | e6c986100613ec804f0e0076ca8695abf43ff88ef9d5d85f6857e0b41db74051 | one | 16GiB |
| assemble | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 8GiB |
Memory limits in this table are container host-RAM limits, not GPU VRAM quotas.
The fixed sequence uses mounted code/model/runtime assets. EoMT and DDRNet have
different Python/Torch environments; retain their separation during packaging.
This is not yet an operator-editable graph or standalone module distribution.
M49 CPU TGS runs inside its specialized agent with source preparation and a
compiled executor subprocess. Preserve executor image
`f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3` and current assets.
Also retain full-graph engineering prototype image `986dbe712699…`
(`ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901`). Its presence is
not proof of a complete accepted recorded LAB or autonomy.
## 5. Worker/runtime topology and resource policy
Current agents, preserved and running before/after:
- `ndc-observatory-installed-lab-worker-agent`, ID
`be85dc40147af3eda18803b913598d733c31f07ff587ededae5a58a0ad76f21e`, image
`bfdc94ae51906cf327901951f3e03e5f9d3a2d9692593f93e16170235adbcf7a`.
- `ndc-observatory-m49-worker-agent`, ID
`d58b3872d2ab399ca3566ab88080da1000988eee22c9c7bab7ccc8f2eac63796`, image
`e545a47a7ea2318fe946d0c3170f9b53b9a640ba7b0b98f226ad73a2e50f7650`.
Latest stopped rollback pair retained:
`a01b0fc675a01f52f7312d669d8b0e4147ab175baeca6396ec58f7cea4bd6029` and
`c51bd8d95a85e218acc417d8972945656a31cb2dba8dc947842152a225f4d538`.
Control declaration directory:
`D:\NDC_MISSIONCORE\runtime\services\observatory-heartbeat-recovery-20260903-OjYeg7\release`.
Control agent image identities and compute-step image identities differ.
After cleanup:11 containers,7 running/4 stopped,80 images,9 named volumes.
Other five running services retained pending consumer/dependency retirement:
Gaussian gateway, Gaussian pipeline, Gaussian terrain executor, perception worker,
Triton. Gateway/pipeline health is unhealthy; perception has recurring restarts.
No causal connection to the new cleanup was inferred from those pre-existing states.
Frigate and Ollama remain stopped, restart=no, as previously requested.
One Worker006/RTX4090 retains exclusive composition ownership and sequential heavy
GPU execution. No model inference or performance benchmark was launched this pass.
The owner-approved EoMT disk floor is250GiB plus estimated working set, instead
of350GiB. Source/tests changed earlier and were checked again here; installed
EoMT remains350GiB until a new sealed image/release. No GPU/RAM limits or clocks
were changed in this pass.
## 6. Implementation and bounded cleanup
Two-phase archive then delete; recheck all exact IDs/names/images/stopped states,
restart policies and layer diffs. Check both current agents running, queue fully
terminal, no active lease or running LAB compute container. Deletions use exact
container IDs, `force=false`, `v=false`. No volume, image, bind directory or
recording deletion; no global prune. The archive-only first attempt failed on
unordered Docker diff results; sorting corrected the comparison before any delete.
Removed13 stopped instances:
| Short ID | Name |
| --- | --- |
| 05222a8e343e | ndc-observatory-installed-lab-worker-agent-pre-source-reuse-05222a8e343e |
| 51027773cd1b | ndc-observatory-m49-worker-agent-pre-source-reuse-51027773cd1b |
| 7a3e07929c82 | ndc-observatory-m49-worker-agent-pre-progress-7a3e07929c82 |
| a8e8353d4eb9 | ndc-observatory-installed-lab-worker-agent-pre-progress-a8e8353d4eb9 |
| 810212622de9 | ndc-observatory-installed-lab-worker-agent-v2-rollback-810212622de9 |
| b30b41d9e2e2 | ndc-observatory-m49-worker-agent-v2-rollback-b30b41d9e2e2 |
| 7921ab34712f | ndc-observatory-m49-worker-agent-legacy-v1-20260901 |
| 623c651a5a71 | ndc-observatory-m49-worker-agent-892d008-retired |
| 1898416f461e | ndc-mission-core-perception-worker-pre-e19-20260731 |
| b61e5b237f80 | mission-core-perception-worker-e21-debug3 |
| 432cae7d87f6 | mission-core-perception-worker-e21-debug2 |
| d928b067f9b9 | mission-core-perception-worker-e21-debug1 |
| b33b06d1061b | mission-core-perception-worker-e16-backup-20260723 |
Private archive contains2078504 log bytes plus redacted instance configuration.
It is not a rootfs/volume backup. Removed Docker IDs cannot be restored; services
can be recreated from retained images/declarations/data if necessary. Removed
writable-layer sizes sum1175552bytes, not tens of GB. No claimed physical disk
recovery was measured from sparse backing-store compaction.
Files changed by this architecture/document pass: ADR0051 added, ADR0050 amended,
current ExecPlan upper route replaced by modular route (old text archived), this
report added, Desktop status renamed with `_` and self-contained0.46 prepended.
Private audit/cleanup scripts are evidence tooling, not a new product runtime.
Pre-existing UI/runtime changes were neither reverted nor treated as newly built.
## 7. Validation and reproduced evidence
- Exact before/after container set difference is the13 approved stopped IDs;
no new containers appeared. Images and volume identity sets unchanged80/9.
- Installed release file SHA256 mappings unchanged; both agents running.
- Queue13 job tuples `(job_id,state,result_id,claim_generation)` unchanged:
11failed/2succeeded,0 live leases.
- Core `/api/health` operational=true at `2026-09-03T15:59:16.426016+00:00`;
verification local monotonic1923.95940075, duration0.026172833s.
PID2421 serves8000; no8765 Mission Core listener. Build `app-w6onjKPq.js`.
Recording cache20 entries/653266743bytes; artifact cache9 pinned objects.
- `pytest -q tests/test_observatory_portable_lab_v1_component_adapters.py -k
'disk or legacy_python39'`:7PASS. Ruff on component and its tests:PASS.
No complete frontend suite/build, heavy viewer QA or model benchmark this pass.
- The complete former Desktop body is preserved below the historical divider;
its SHA is recorded above. Existing architecture and before-full-profile
Desktop documents remain untouched.
- `git diff --check`:PASS. Desktop historical-body SHA reproduced exactly;
both preserved sibling-document SHAs unchanged; old unprefixed current filename
absent and new underscore-prefixed file present.
## 8. Results retained, not newly computed
M49×RAVNOVES004TREE published job:
`observatory-run-b230216709dc4c59bc56c98c7e329bf1`.
Result:`m49-tgs-portable-review-a09d2b4a07d103e4f3693ba746a51be2197214768774eabfded4f109ae80dce4`.
6830 camera anchors,6811 LiDAR,19 UNOBSERVED, cycle1416.763s, schedule39.215757.160s
of808.779s source. This is CPU TGS, not a full ML perception profile or complete
capture coverage. Detailed saved camera/TGS visual and lifecycle acceptance is open.
Latest LAB V1×004TREE job
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` failed on lease loss at EoMT.
It did not complete DDRNet/publication. Reconciliation preserved failure history
after resource-release verification. Current heartbeat fixes are installed;
there is no newly proved successful full LAB V1 run. Disk-floor admission is a
separately discovered issue; its causality for the lease failure is not proved.
## 9. Limitations and rejected approaches
- New Module/Composition contracts, grouped UI and per-node result reuse remain
unimplemented. Source CAS is not node-result cache; current cold input barrier
remains. Existing fixed-stack support is useful but not complete modularity.
- 749frontend/166backend checks belong to the previous increment, not this design.
Historical replay overlays or cached FPS do not establish full-graph inference.
- Images, build caches and input/model caches were not pruned. Before deletion,
enumerate reachable pinned packages, prototype, models, current consumers and
rollback. Image5ad7… is still required by prepare/assemble although one old
agent instance using it was retired. Logical image sizes share layers.
- Active legacy/Gaussian/Triton retirement needs a separate dependency decision;
unhealthy does not itself authorize removing another consumer's data.
- No blanket cache deletion, extra agent per model, per-frame Docker startup,
GPU concurrency, MacMini onboard target, or hidden motor authority.
## 10. Decision
Adopt modular composition as TARGET; preserve the recorded-first product and
verified queue/source/publication/viewer foundation. Retain successful M49 and
legacy releases during migration. Current audit and bounded cleanup are complete;
the new four-stage architecture implementation and broad cleanup are not complete.
## 11. Next stage and forbidden authority
Next chat begins with current Desktop0.46, ADR0051, ExecPlan upper section and this
report; read `git status` and installed identities before changes. Start executable
composition/dependency/identity contracts and retention closure, then reusable
module releases/common runtime, grouped UI/end-to-end saved review, finally CUDA
onboard qualification. Do not rerun old installers with stale predecessor IDs.
No Synology/deploy-canon21, device mutation, autonomy, motor controls, Little
Snitch changes or Docker Desktop resource-limit changes. Core8000 is durable and
remains running. Existing worktree changes, raw records and historical evidence
are user-owned and preserved.
## 12. Acceptance checker
- [x] Accepted architecture distinguished from installed code.
- [x] Single-provider groups; no dual segmentation in new profiles; CUDA not MacMini.
- [x] Worker exact inventory,13-target archive/delete, identity/queue/health checks.
- [x] Desktop rename/context, ADR and current four-stage route prepared.
- [ ] Remaining cache/image/active-legacy retirement after retention closure.
- [ ] New executable composition/module contracts and module releases.
- [ ] Operator composer and exact node-cache implementation.
- [ ] Full saved visual/lifecycle and source×composition acceptance.
- [ ] CUDA onboard qualification and separate control/safety acceptance.
@@ -0,0 +1,160 @@
# Observatory: сохранённые маски, восстановление и реальный LAB V1 canary
Session: `observatory-full-pass-OjYeg7`, 2026-09-03. Поверх `eff60e4`;
предыдущие незакоммиченные изменения сохранены. Этап2 **не закрыт**.
## Цель и архитектурный этап
Один крупный проход по сохранённому просмотру и recovery2D, с реальным запуском
LAB V1 × RAVNOVES004TREE из2C. Не новый LAB-микроинтерфейс, не remote realtime
qualification, не перенос на борт. Legacy остаётся отдельным архивом.
## Вопрос и гипотеза
Можно ли получить опубликованный LAB V1, смотреть его маски в общем viewer и
пережить краткую потерю связи/подтверждения без повторного вычисления? Контрактные
проверки выполнены, но реальный canary до публикации не дошёл. Не переносить
локальный PASS тестов на качество или работоспособность всего профиля.
## Исходные доказательства
Обычная кнопка «Рассчитать» создала одну job
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` в
`2026-09-03T13:40:35.163Z`, generation1. Источник:
`20260828T130511Z_viewer_live`, RAVNOVES004TREE. Definition
`269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac`.
Source bundle: `2412ee2374590a3b0bca8849a2410188325397b1651b7ae2916c48ead664c417`.
Состав: последовательные prepare → EoMT → DDRNet → assemble; не полный граф рига.
## Метод и идентичность
Новый `portable_semantic_replay.py` принимает только sealed
`missioncore.recorded-eomt-ddrnet-review/v2` и связывает source/bundle/clock,
component documents, число кадров, SHA/размеры архивов и taxonomy. EoMT target
labels принадлежат точному preprocessing profile
`ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`, не сырому
порядку Cityscapes. DDRNet использует64 класса опубликованной taxonomy.
Маски800×600 читаются по одной: EoMT tar/gzip streaming, DDRNet ZIP. Имена,
дубликаты, неполнота, raster/class bounds и синхронизация проверяются. PNG остаются
lossless; ни разрешение, ни cadence ради приёмки не изменены. Производитель и
архивные LAB adapters не переписаны. Отдельная renderer identity сохраняет старый
M49 derivative cache. Источник и результат объединяются общим native Rerun
adapter; повторный просмотр идёт с Core, не с Worker, без inference.
## Worker/runtime и ресурсы
Один активный профиль на4090. При установке control layer очередь idle,
11 failed/2 succeeded,0 live leases; два успешных результата не изменялись.
Модели, package/config digests, limits и source-cache mounts сохранены.
Новая установка control-agent layer завершена `2026-09-03T14:23:22.507723Z`.
Plan: `8b16f20e4b7efa14a70344120d063e298e2eda1fd95288904c636806038cab41`.
| Агент | Новый image SHA-256 | Сохранённый predecessor |
|---|---|---|
| M49 | `e545a47a7ea2318fe946d0c3170f9b53b9a640ba7b0b98f226ad73a2e50f7650` | `ndc-observatory-m49-worker-agent-pre-heartbeat-c51bd8d95a85` |
| Installed LAB | `bfdc94ae51906cf327901951f3e03e5f9d3a2d9692593f93e16170235adbcf7a` | `ndc-observatory-installed-lab-worker-agent-pre-heartbeat-a01b0fc675a0` |
Durable declarations и rollback:
`D:\NDC_MISSIONCORE\runtime\services\observatory-heartbeat-recovery-20260903-OjYeg7\release`.
Predecessors stopped/restart=no. Откат только после проверки отсутствия новых
jobs/live owners; никогда не запускать старого и нового владельца одновременно.
Установка — два проверенных Python-файла поверх exact parent image, без скачиваний
или compute package rebuild. Временные offline helpers удалены, данные сохранены.
## Реализация recovery
- Outbox раньше ограничивал первые строки до проверки backoff/max attempts.
Несколько старых failed rows могли навсегда скрыть поздний готовый результат.
Теперь keyset pages по32, курсор по immutable creation/job identity, limit
применяется к реальным попыткам публикации. История не удаляется.
- Worker heartbeat теперь повторяет только типизированные transport failures и
HTTP408/429/500/502/503/504, с тем же generation/token/sequence. HTTP401/403/409,
неверный JSON и изменённая identity не становятся временным успехом.
I/O heartbeat ограничен5s; повторы укладываются в монотонный бюджет последней
аренды. Потерянный ACK не продлевает локальный срок сам по себе.
- При истечении бюджета, позднем ACK или завершении работы во время
неопределённого подтверждения результат не объявляется успешным. Новый владелец
не запускается вместо старого. Бесконечный сетевой outage пока не превращён в
полноценную pause/resume модель — это отдельный незакрытый recovery case.
- Добавлены безопасные heartbeat retry/recovered/lost и executor-failure logs:
job/generation/sequence и классы ошибок, без exception text/credentials.
## Проверки
- **749 frontend tests**, полный typecheck, production build PASS.
- Финальный единый backend проход: **166 tests,0 failures/errors/skips**,
XML в evidence. Outbox/publisher/SQLite recovery, replay/API/masks, heartbeat,
транспорт и installer. Остальные ранние проходы пересекаются с этим набором.
- Ruff, mypy7 изменённых backend modules, `git diff --check` PASS.
- Lost ACK и краткий transport failure: тот же heartbeat sequence, один start,
один результат, без повторного executor. Publication interruption до publish или
после publish до ACK: повторная публикация того же package, не новая job.
- Build12.68s; maximum RSS2071019520 bytes. Тяжёлые локальные проверки шли
последовательно. Docker limits/Little Snitch/посторонние приложения не менялись.
- Core healthy на8000, новый `app-w6onjKPq.js`;8765 пуст. Предыдущий dist сохранён.
Browser: fresh reload, обычное/развёрнутое окно, Refresh, profile selection и
Escape. M49 снизу, LAB V1 в выборе, кнопка только «Рассчитать»; Legacy не вернулся.
**Тяжёлый replay не открывался**: ранее отклонённый browser action не обходили,
отдельное разрешение запрошено. Это не visual/lifecycle PASS.
## Реальный результат и выявленные ограничения
Input transfer завершился примерно за388s; последнее наблюдение — steps1/4,
EoMT ещё не завершён, elapsed1318.28s. В `14:02:39.666Z` job перешла в
`reconciliation-required / claim-lease-expired`. Новый LAB V1 не опубликован.
Последний heartbeat Core принял, после чего renew прекратились; progress некоторое
время продолжал поступать. Точный transport exception прежний агент не сохранил.
Нельзя объявлять доказанной ни GPU-перегрузку, ни конкретную причину разрыва.
Прежний runner удалил временный attempt и одноразовый EoMT container; отдельная
executor error была замаскирована heartbeat-lost. Подтверждены отсутствие exact
job containers/attempt children и idle agent. Через штатный `reconcile_failed`,
без ручного SQL update, job переведена в failed; исходная причина и generation
сохранены в immutable receipt:
`a76131ce20f5def2fe13101d86b93b70911585784dfa839557e82288573cf821`.
SQLite backup и proof сохранены до изменения. Повторный расчёт не запускался.
**Независимо подтверждён disk admission blocker.** В реально установленном EoMT
image `adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174`
действует `350 * 1024**3` bytes floor плюс
`frame_count * 800 * 600 * 7 + input_byte_length`. Source-файл image и репозитория
совпал: `938bbb4d98802e470b2baee0fa3ec3baaa3781199415ec0fa116aaaf8f235661`.
После cleanup свободно397474037760 bytes. Для6830 кадров нужно минимум
398758438400 bytes **ещё без входного видео**. Повтор при этих условиях не пройдёт.
Это проверка текущего условия, не восстановленный stderr предыдущего контейнера.
Floor в350GiB не означает, что модели реально нужно столько рабочего места.
## Решение
Сохранённый semantic adapter и outbox fix установлены на Core, recovery layer —
на Worker. Канонический viewer переиспользован по product-ui skill; отдельного
микроприложения и новых controls нет. Этап2 не закрываем. Существующие partial
full-input подготовка, cache и source identities сохранены;2A не подменяется
этой работой. Физическое управление не включено.
## Непосредственно дальше
Перед повтором LAB V1: пересмотреть disk policy в **новой sealed версии**,
сохранив расчёт рабочего набора и разумный запас; перенести её дешёвую проверку
до долгой проверки asset trees/подготовки. Не менять старый image/config под тем
же digest и не чистить пользовательские данные ради искусственного350GiB floor.
Сохранять bounded failure diagnostics до cleanup. Затем LAB V1 ×004TREE и оба
профиля ×RAVNOVES00 последовательно. Параллельно по смыслу плана остаются2A
incremental cold input и2B saved visual/open-close; локальные тяжёлые процессы
по-прежнему выполняются только последовательно. Standalone3 и будущий борт4 отдельно.
## Acceptance checker и evidence
Закрыто: outbox starvation regression, bounded transient heartbeat protocol,
локальная установка/rollback identities, shared semantic replay contracts.
Не закрыто: реальный успешный LAB V1,2×2 matrix, полная visual/lifecycle
приёмка, long outage recovery и самостоятельный полный Docker.
Private evidence:
`.runtime/observatory-full-pass-OjYeg7` — test logs/XML, old dist, screenshot,
job/reconciliation documents, SQLite backup, installed EoMT policy и agent
plan/receipt с UTC/monotonic. Raw evidence не добавляется в Git.
Direct Ops `tasker_get_agent_instructions` завершился60s timeout; карточка не
изменялась, отчёт не объявляется опубликованным в Ops.
@@ -0,0 +1,129 @@
# Observatory: повторное использование входов на Worker
Session: `observatory-source-reuse-AsldTM`, 2026-09-03.
Установлено 12:53:45.46061612:54:11.038263 UTC; monotonic
2561492366063625640502446818 ns (Worker). Изменения поверх `eff60e4`.
## Решение и результат
Продолжен основной этап2A без повторной диагностики общей памяти Mac. Убраны
два вида повторной работы: скачивание уже закэшированных source members в новую
job/generation и декодирование исходной LiDAR-записи перед обнаружением готового
v2 input pack. Изменение установлено в оба существующих агента Worker006.
Это **кэш исходников для вычисления на Worker**, не перенос пользовательского
кэша результатов: сохранённые визуальные результаты по-прежнему выдаёт Core.
Холодная передача/подготовка новой записи остаётся целиковой; этап2A целиком
не закрыт и recorded-analysis не объявлен реализованным новым режимом.
## Реализация и сохранённые границы
- `worker_source_cache.py`: общий content-addressed кэш по SHA-256 и длине.
Gateway сначала получает и проверяет актуальный claim-bound manifest; кэш
не заменяет claim/generation/source admission. Каждая job сохраняет отдельный
fixed layout и свой manifest. Никаких URL/команд/путей от кэша не принимается.
- `worker_http_transport.py`: ready members восстанавливаются из локального
кэша. Полностью готовая камера не запрашивает архив; частично готовая запрашивает
только недостающие members. Cold epoch сохраняет существующий packed transport.
В progress учитываются реальные готовые members, не вымышленные inference frames.
- Разные агенты имеют разные `/work`; введён явный service config
`MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CACHE_ROOT=/source-cache` и общий named
volume `ndc-observatory-source-cas-v1`. Все четыре ownership labels заданы.
- На одной filesystem — read-only hardlinks после проверки bytes; между mount
points — проверяемая disk copy с буфером до1MiB. Для необязательной cache copy
оставляется2GiB свободного диска; нехватка места означает cache miss, не потерю
качества/кадров. Прежние записи и результаты не удаляются. Повреждённый cache
object не используется и не перезаписывается; свежая загрузка остаётся в job.
Cache не удерживает массивы/сессии в RAM. Все temporary links/copies закрываются.
- `compute/lidar_preparation.py`: перед неизменным producer ищется exact source /
session / current-producer pack. Проверяются raw, metadata и optional clock
origin hashes; strict v2 reader проверяет артефакты, arrays, logical content и
equivalence. Он закрывается в `finally`. На warm hit исходник не декодируется;
потоковое SHA-чтение исходника и полная проверка NPZ остаются. Cold directory
не добавляет отдельного предварительного hash-pass перед прежним builder.
- `compute/lidar_replay.py` не изменён: SHA
`543a1d63889ad513e6307603cf477f937645c1e4df9a63a169424afd9d2471b8`.
Этот producer digest входит в исторические pack identities. Прежние packs
сохраняют свои IDs/manifest bytes; новый adapter не подделывает producer version.
Коллизия report threshold, который v2 не включал в identity, теперь явно
отклоняется при reuse, не возвращает другой отчёт и не перезаписывает данные.
- M49 source adapter использует новый preparation path; release source inventory
включает новый модуль. Алгоритмы, model/config/profile/package identities,
исходные timestamps и правила unknown/obstacle не менялись.
Кэш наполняется при обычной подготовке. Старые рабочие каталоги не обходились
массово и все ранее скачанные записи автоматически не импортированы.
## Проверки
111 focused backend tests PASS за3.87с, command maximum RSS115867648 bytes.
Ruff PASS; mypy пяти изменённых runtime-модулей PASS. UI в этом инкременте не
менялся: повторного frontend build/heavy browser QA не было.
Проверены warm/cold legacy pack IDs, изменённые raw/metadata/origin/session,
повреждённые артефакты, отсутствие metadata, symlink, смена файла во время
validation, threshold mismatch и cleanup. Gateway fixtures используют новый
процесс-клиент и другой work root с общей CAS: новая generation/другой профиль
делают только GET актуального manifest; changed/missing camera member добавляет
ровно один member GET, без полного epoch archive. Старый manifest/отсутствующий
claim отвергаются. FIFO, directory, unsafe digest, partial copy и corrupt CAS
не попадают в вычислительный вход. Admission installer проверен отдельно.
На реальном Worker выполнен небольшой synthetic proof, **не benchmark профиля**:
167936 bytes, SHA `aca7c6f9cce176db34410eeb0cd3e6e5d4ede34f0d0c8da5c97f94e6d3b5ced7`.
M49 agent сохранил bytes, installed-LAB agent восстановил их через общий volume
в свой другой `/work`, exact сравнение прошло. Третий шаг удалил только этот
проверенный synthetic cache object. Temporary work directories удалены каждым
шагом. Model jobs=0. UTC/monotonic каждого шага сохранены отдельно.
## Установка, состояние и восстановление
Read-only plan SHA `f7d1552161970d074a87aa3e9baa75f080d5f835c0373326c873ae481b5c26a7`.
Пять code files установлены offline child layers, исходный producer закреплён
отдельным неизменным SHA. До каждого cutover проверены exact container/create
hashes и свободная queue; настройки CPU/RAM/GPU/network не менялись. Добавлены
только source-cache mount/env и соответствующая code layer.
| Агент | Новый image SHA-256 | Сохранённый predecessor |
| --- | --- | --- |
| `ndc-observatory-m49-worker-agent` | `d252326dba36a1d4e4194f2862078a00090e93439c03f4d6cef97bf8ef43a607` | `ndc-observatory-m49-worker-agent-pre-source-reuse-51027773cd1b` |
| `ndc-observatory-installed-lab-worker-agent` | `b9131e995b14a8e42fbf0e5bf0e017c93ce1af33c0910927ae5e4514d900e683` | `ndc-observatory-installed-lab-worker-agent-pre-source-reuse-05222a8e343e` |
Новые агенты Running, RestartCount0, restart=`unless-stopped`. Предшественники
Stopped/restart=`no`. Durable declarations с полным create body и receipt:
`D:\NDC_MISSIONCORE\runtime\services\observatory-source-reuse-AsldTM\release`.
При rollback сначала проверить idle/reconciliation, не запускать predecessor
одновременно с replacement. Общий source volume не удалять; старый агент просто
не использует его. Прежние package launchers не должны перезаписывать новые
declarations. Фактическая перезагрузка Worker не выполнялась.
Queue после установки:10 failed/2 succeeded,1 v3 grant,0 open live leases —
существующий результат/история сохранены, новых jobs не создавалось. Core PID89747
healthy на8000,8765 пуст. Временные installer containers удалены штатным `--rm`.
Посторонние контейнеры Worker/Mac, Little Snitch и Docker limits не менялись.
## Остаток основного плана
Теперь следует убирать **cold whole-input barrier** версионированным execution
contract: полный recorded-analysis отдельно от realtime-rehearsal, bounded
incremental input и учёт всех предусмотренных результатов без drops ради1×.
Переиспользование cache само по себе этого не доказывает. Затем visual2B,
матрица LAB V1/другая запись2C и recovery/пагинация2D. Полный standalone3 и борт4
остаются отдельными этапами. Нет новых FPS, модельной квалификации или actuation.
## Evidence manifest
Private root: `.runtime/observatory-source-reuse-AsldTM`; raw recordings и secrets
в Git не добавлены. Полные declarations сохранены private, не в продуктовом UI.
| Файл | SHA-256 |
| --- | --- |
| `backend-tests.log` | `39086fa4ec312b3b8d509fda7b211aa37a14673743c9047c574efd3bde9d36df` |
| `worker-plan.json` | `0f7cb148dc8d01d7d68157daf9f8f6187c1fac6e72729107cd9b6930d568689c` |
| `worker-receipt.json` | `d030763b0d3450d5fbc14990530a80b6588d4e76943ba2d3eb190e3f909e2c3e` |
| `worker-cache-retain.json` | `99ec71c328820126664aaf4cef667030c288cf3da2e4e2eacf5dc30100b570f1` |
| `worker-cache-restore.json` | `bad2efd604351151a8451d5ae4785b68e6bb9b0d9400cfefb1d2b73ef784de1a` |
| `worker-cache-cleanup.json` | `22bd551108acc0711f5ea83c994999fbaf9f3ae5dbd68492aaee4d6552bf04e0` |
Ops card в этом инкременте не обновлялась; локальный отчёт/ExecPlan/Desktop
сводка являются текущим handoff, не утверждением об успешной записи в Ops.
@@ -0,0 +1,29 @@
FROM ndc/mission-core-installed-lab-v1-eomt-step:ee0efdd9af72
ARG NODEDC_SHARED_SHA256
ARG NODEDC_EOMT_SHA256
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
COPY run_portable_lab_v1_eomt_component.py /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py
COPY run_portable_camera_source_component.py /opt/nodedc/adapter/run_portable_camera_source_component.py
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py | cut -d' ' -f1)" = "${NODEDC_EOMT_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_camera_source_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/*.py \
&& cd /opt/nodedc/adapter \
&& python3 -B -m py_compile portable_lab_v1_component_adapter.py \
run_portable_lab_v1_eomt_component.py run_portable_camera_source_component.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="camera-source" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3"]
CMD ["/opt/nodedc/adapter/run_portable_camera_source_component.py"]
@@ -0,0 +1,26 @@
FROM ndc/mission-core-installed-lab-v1-ddrnet-step:439127908dba
ARG NODEDC_SHARED_SHA256
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
COPY run_portable_lab_v1_ddrnet_component.py /opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/*.py \
&& cd /opt/nodedc/adapter \
&& conda run --no-capture-output --name goose python -B -m py_compile \
portable_lab_v1_component_adapter.py run_portable_lab_v1_ddrnet_component.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="ddrnet" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["conda","run","--no-capture-output","--name","goose","python"]
CMD ["/opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py"]
@@ -0,0 +1,26 @@
FROM ndc/mission-core-installed-lab-v1-eomt-step:ee0efdd9af72
ARG NODEDC_SHARED_SHA256
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
COPY run_portable_lab_v1_eomt_component.py /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/*.py \
&& cd /opt/nodedc/adapter \
&& python3 -B -m py_compile portable_lab_v1_component_adapter.py \
run_portable_lab_v1_eomt_component.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="eomt" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3"]
CMD ["/opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py"]
@@ -0,0 +1,20 @@
FROM ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY run_ai_module_object_distance.py /opt/nodedc/adapter/run_ai_module_object_distance.py
RUN test "$(sha256sum /opt/nodedc/adapter/run_ai_module_object_distance.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/run_ai_module_object_distance.py \
&& python3 -B -m py_compile /opt/nodedc/adapter/run_ai_module_object_distance.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="object-distance" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3", "-B", "/opt/nodedc/adapter/run_ai_module_object_distance.py"]
@@ -0,0 +1,20 @@
FROM ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY run_ai_module_rf_detr.py /opt/nodedc/adapter/run_ai_module_rf_detr.py
RUN test "$(sha256sum /opt/nodedc/adapter/run_ai_module_rf_detr.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/run_ai_module_rf_detr.py \
&& python3 -B -m py_compile /opt/nodedc/adapter/run_ai_module_rf_detr.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="rf-detr" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3", "-B", "/opt/nodedc/adapter/run_ai_module_rf_detr.py"]
@@ -15,7 +15,7 @@ RUN case "${NODEDC_SOURCE_TREE_SHA256}" in *[!0-9a-f]*|'') exit 64 ;; esac \
&& test "${#NODEDC_SOURCE_TREE_SHA256}" -eq 64 \
&& find /opt/nodedc/mission-core/src/k1link -type d -exec chmod 0555 {} + \
&& find /opt/nodedc/mission-core/src/k1link -type f -exec chmod 0444 {} + \
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(steps.main)"
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as legacy_steps; import k1link.observatory.modular_installed_package_steps as modular_steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(legacy_steps.main); assert callable(modular_steps.main)"
LABEL com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
@@ -0,0 +1,281 @@
"""Idle-only two-file control-agent update; sealed compute packages are unchanged.
Offline child images, exact parent/source/create fences, immutable install
receipts, and stopped predecessors retained for explicit rollback. No model
execution, new volume, new resource limit, or queue mutation occurs here.
"""
from __future__ import annotations
import argparse
import copy
import io
import json
import tarfile
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlencode
from migrate_claim_transport_v3 import READINESS, Engine, canonical, require_idle, save, sha
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link/observatory"
BEFORE = {
"worker_agent.py": "91a65fcde45fa65b0894e6927618f35b4d8aa369653d00163df7fd840e6ea5b2",
"worker_http_transport.py": "dfe652d9464d97cba37c4136be8d0fd865f3e93fe75380e471da882b56c517f1",
}
TARGETS = {
"ndc-observatory-m49-worker-agent": (
"d252326dba36a1d4e4194f2862078a00090e93439c03f4d6cef97bf8ef43a607"
),
"ndc-observatory-installed-lab-worker-agent": (
"b9131e995b14a8e42fbf0e5bf0e017c93ce1af33c0910927ae5e4514d900e683"
),
}
LABEL = "com.nodedc.recorded-heartbeat-recovery.plan-sha256"
def probe() -> str:
return f"""import hashlib,json,pathlib
from k1link.observatory import worker_agent
root=pathlib.Path(worker_agent.__file__).resolve().parent
assert str(root)=={SOURCE_ROOT!r}
print(json.dumps({{n:hashlib.sha256((root/n).read_bytes()).hexdigest() for n in {list(BEFORE)!r}}}))
"""
def pack(repository: Path, output: Path) -> None:
output.mkdir(parents=False, exist_ok=False)
files = {}
for name in BEFORE:
payload = (repository / "src/k1link/observatory" / name).read_bytes()
compile(payload, name, "exec")
(output / name).write_bytes(payload)
files[name] = sha(payload)
save(output / "payload.json", {"schema_version": 1, "files": files})
def payload_files(root: Path) -> dict[str, bytes]:
manifest = json.loads((root / "payload.json").read_bytes())
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
raise ValueError("invalid heartbeat payload manifest")
if set(manifest["files"]) != set(BEFORE):
raise ValueError("heartbeat file set changed")
result = {}
for name in BEFORE:
path = root / name
if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size < 256_000:
raise ValueError("unsafe heartbeat payload")
value = path.read_bytes()
if sha(value) != manifest["files"][name]:
raise ValueError("heartbeat payload changed")
compile(value, name, "exec")
result[name] = value
return result
def create_hash(row: dict) -> str:
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
def validate_target(name: str, row: dict) -> None:
config, host = row["Config"], row["HostConfig"]
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
raise ValueError("control-agent identity changed")
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
raise ValueError("expected durable read-only agent is not running")
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
raise ValueError("control-agent GPU/network boundary changed")
if config["Labels"].get("com.nodedc.authority") != "observation-only":
raise ValueError("control-agent authority changed")
for item in config["Env"]:
key = item.split("=", 1)[0].upper()
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET")) and not key.endswith(
"_FILE"
):
raise ValueError("inline credential is forbidden")
def plan(engine: Engine, root: Path) -> dict:
files, targets = payload_files(root), []
for name, parent in TARGETS.items():
row = engine.inspect(name)
validate_target(name, row)
if engine.execute_json(name, probe()) != BEFORE:
raise ValueError("imported source differs from reviewed baseline")
require_idle(engine.execute_json(name, READINESS))
targets.append(
{"name": name, "id": row["Id"], "parent": parent, "create_sha256": create_hash(row)}
)
return {
"schema_version": "missioncore.recorded-heartbeat-recovery-install/v1",
"targets": targets,
"files": {name: sha(value) for name, value in files.items()},
"installer_sha256": sha(Path(__file__).read_bytes()),
"helper_sha256": sha(
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
),
"compute_packages_changed": False,
}
def fence(engine: Engine, target: dict) -> dict:
row = engine.inspect(target["name"])
validate_target(target["name"], row)
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
raise ValueError("control agent changed since plan")
require_idle(engine.execute_json(target["name"], READINESS))
return row
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
created = engine.request(
"POST",
"/containers/create",
{
"Image": "sha256:" + target["parent"],
"Entrypoint": ["/bin/true"],
"Cmd": [],
"HostConfig": {
"NetworkMode": "none",
"CapDrop": ["ALL"],
"PidsLimit": 32,
"SecurityOpt": ["no-new-privileges"],
},
},
)["Id"]
try:
engine.request("POST", f"/containers/{created}/start")
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
raise ValueError("offline layer initialization failed")
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
for name, payload in files.items():
item = tarfile.TarInfo(name)
item.size, item.mode, item.mtime = len(payload), 0o444, int(time.time())
archive.addfile(item, io.BytesIO(payload))
engine.request(
"PUT",
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
buffer.getvalue(),
)
changes = engine.request("GET", f"/containers/{created}/changes")
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
parents = {str(parent) for name in allowed for parent in Path(name).parents}
if not changes or any(
row["Path"] not in allowed | parents or row["Kind"] not in (0, 1) for row in changes
):
raise ValueError("unrelated changes in offline heartbeat layer")
if not allowed.issubset({row["Path"] for row in changes}):
raise ValueError("heartbeat layer omitted a file")
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
config = copy.deepcopy(parent["Config"])
config.setdefault("Labels", {})[LABEL] = plan_sha
image = engine.request(
"POST",
"/commit?"
+ urlencode(
{
"container": created,
"repo": target["name"] + "-heartbeat-recovery",
"tag": "v1",
}
),
config,
)["Id"]
after = engine.request("GET", f"/images/{image}/json")
if after["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
raise ValueError("parent image layers changed")
return image
finally:
engine.request("DELETE", f"/containers/{created}")
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
started, mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
proposal = plan(engine, root)
if sha(canonical(proposal)) != expected:
raise ValueError("heartbeat install plan changed")
evidence.mkdir(parents=False, exist_ok=False)
save(evidence / "plan.json", proposal)
files, results = payload_files(root), []
for target in proposal["targets"]:
name = target["name"]
fence(engine, target)
image = build(engine, target, files, expected)
before = fence(engine, target)
body = copy.deepcopy(before["Config"])
body["Image"] = image
body["Labels"][LABEL] = expected
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
backup = name + "-pre-heartbeat-" + before["Id"][:12]
save(
evidence / (name + "-declaration.json"),
{
"name": name,
"create_body": body,
"rollback_name": backup,
"rollback_container_id": before["Id"],
"parent": target["parent"],
},
)
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
engine.request(
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
)
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
"Id"
]
engine.request("POST", f"/containers/{created}/start")
time.sleep(3)
after = engine.inspect(name)
if not after["State"]["Running"] or after["RestartCount"] != 0:
raise ValueError("replacement requires reconciliation; predecessor retained")
if engine.execute_json(name, probe()) != proposal["files"]:
raise ValueError("replacement imported another payload")
results.append(
{
"name": name,
"id": created,
"image": image,
"rollback": backup,
"readiness": engine.execute_json(name, READINESS),
}
)
save(evidence / (name + "-acceptance.json"), results[-1])
receipt = {
"plan_sha256": expected,
"agents": results,
"compute_packages_changed": False,
"started_at_utc": started,
"finished_at_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": mono,
"finished_monotonic_ns": time.monotonic_ns(),
}
save(evidence / "receipt.json", receipt)
return receipt
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path)
parser.add_argument("--pack", type=Path)
parser.add_argument("--payload", type=Path)
parser.add_argument("--apply-plan-sha256")
parser.add_argument("--evidence", type=Path)
args = parser.parse_args()
if args.pack:
pack(args.repository, args.pack)
return
engine = Engine()
if args.apply_plan_sha256:
document = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
else:
document = plan(engine, args.payload)
document = {"plan": document, "plan_sha256": sha(canonical(document))}
print(json.dumps(document, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,340 @@
"""Exact offline control-agent update: shared source cache and LiDAR reuse.
Plan is read-only, apply requires its hash and an idle queue. Models, package
definitions, legacy LiDAR producer and existing work/results stay unchanged.
Stopped predecessors and full create declarations are retained for rollback.
"""
from __future__ import annotations
import argparse
import copy
import io
import json
import tarfile
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlencode
from migrate_claim_transport_v3 import READINESS, Engine, canonical, require_idle, save, sha
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link"
PRODUCER = "compute/lidar_replay.py"
PRODUCER_SHA = "543a1d63889ad513e6307603cf477f937645c1e4df9a63a169424afd9d2471b8"
BEFORE = {
"compute/lidar_preparation.py": None,
"observatory/m49_portable_source.py": (
"aff9baf5a11732c3f5d4bf53a5a3306df8ac020efbb0dc235202f7193b43c043"
),
"observatory/worker_http_transport.py": (
"363daa574139ee062f0d4141c6ce8ca9c3f25883e1fee6660dd771e15ec71088"
),
"observatory/worker_service.py": (
"a742d1de10e9c76e531f5be78935d6c34c8195d4ecc8c24cf25b987ec162ba1a"
),
"observatory/worker_source_cache.py": None,
}
TARGETS = {
"ndc-observatory-m49-worker-agent": (
"927c3c4f5b00ae6c084d1f5a8bc77f7b262cfc5e83cf4be1c48f615c12c06e80"
),
"ndc-observatory-installed-lab-worker-agent": (
"052af3ccd10e11b162c163943f5427af2dc95b09b93481dd85e954e494ba1107"
),
}
VOLUME = "ndc-observatory-source-cas-v1"
CACHE_PATH = "/source-cache"
CACHE_ENV = "MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CACHE_ROOT"
LABELS = {
"com.nodedc.product": "mission-core",
"com.nodedc.stack": "observatory",
"com.nodedc.role": "source-cache",
"com.nodedc.managed-by": "recorded-source-reuse-v1",
}
def probe() -> str:
names = [PRODUCER, *BEFORE]
return f"""import hashlib,json,pathlib
from k1link.observatory import worker_http_transport
root=pathlib.Path(worker_http_transport.__file__).resolve().parents[1]
assert str(root)=={SOURCE_ROOT!r}
print(json.dumps({{n:hashlib.sha256((root/n).read_bytes()).hexdigest()
if (root/n).is_file() else None for n in {names!r}}}))
"""
def pack(repository: Path, output: Path) -> None:
if sha((repository / "src/k1link" / PRODUCER).read_bytes()) != PRODUCER_SHA:
raise ValueError("legacy LiDAR producer changed")
output.mkdir(parents=False, exist_ok=False)
files = {}
for name in BEFORE:
payload = (repository / "src/k1link" / name).read_bytes()
compile(payload, name, "exec")
target = output / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
files[name] = sha(payload)
save(output / "payload.json", {"schema_version": 1, "files": files})
def payload_files(root: Path) -> dict[str, bytes]:
manifest = json.loads((root / "payload.json").read_bytes())
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
raise ValueError("invalid source-reuse payload manifest")
if set(manifest["files"]) != set(BEFORE):
raise ValueError("source-reuse file set changed")
result = {}
for name in BEFORE:
path = root / name
if path.is_symlink() or not path.is_file() or path.stat().st_size > 256_000:
raise ValueError("unsafe source-reuse payload")
value = path.read_bytes()
if sha(value) != manifest["files"][name]:
raise ValueError("source-reuse payload changed")
compile(value, name, "exec")
result[name] = value
return result
def create_hash(row: dict) -> str:
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
def validate_target(name: str, row: dict) -> None:
config, host = row["Config"], row["HostConfig"]
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
raise ValueError("control-agent identity changed")
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
raise ValueError("control agent must be running/read-only")
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
raise ValueError("control-agent network/GPU boundary changed")
if config["Labels"].get("com.nodedc.authority") != "observation-only":
raise ValueError("control-agent authority changed")
if any(mount["Destination"] == CACHE_PATH for mount in row["Mounts"]):
raise ValueError("source-cache mount is already occupied")
for entry in config["Env"]:
key = entry.split("=", 1)[0]
if key == CACHE_ENV:
raise ValueError("source-cache configuration already exists")
if any(
word in key.upper() for word in ("TOKEN", "PASSWORD", "SECRET")
) and not key.endswith("_FILE"):
raise ValueError("inline secret is forbidden in saved declarations")
def volume_state(engine: Engine) -> dict:
response = engine.request(
"GET", "/volumes?" + urlencode({"filters": json.dumps({"name": [VOLUME]})})
)
matches = [row for row in response.get("Volumes", []) or [] if row["Name"] == VOLUME]
if not matches:
return {"exists": False}
row = matches[0]
if row["Driver"] != "local" or row.get("Labels") != LABELS:
raise ValueError("existing source-cache volume has another owner")
return {"exists": True, "name": VOLUME, "driver": "local", "labels": LABELS}
def plan(engine: Engine, root: Path) -> dict:
files = payload_files(root)
targets = []
for name, parent in TARGETS.items():
row = engine.inspect(name)
validate_target(name, row)
if engine.execute_json(name, probe()) != {PRODUCER: PRODUCER_SHA, **BEFORE}:
raise ValueError("imported source differs from reviewed baseline")
require_idle(engine.execute_json(name, READINESS))
targets.append(
{"name": name, "id": row["Id"], "parent": parent, "create_sha256": create_hash(row)}
)
return {
"schema_version": "missioncore.recorded-source-reuse-install-plan/v1",
"targets": targets,
"files": {name: sha(value) for name, value in files.items()},
"producer_sha256": PRODUCER_SHA,
"shared_cache": {"name": VOLUME, "target": CACHE_PATH, "before": volume_state(engine)},
"installer_sha256": sha(Path(__file__).read_bytes()),
"engine_helper_sha256": sha(
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
),
"compute_packages_changed": False,
}
def fence(engine: Engine, target: dict) -> dict:
row = engine.inspect(target["name"])
validate_target(target["name"], row)
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
raise ValueError("control agent changed since plan")
require_idle(engine.execute_json(target["name"], READINESS))
return row
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
created = engine.request(
"POST",
"/containers/create",
{
"Image": "sha256:" + target["parent"],
"Entrypoint": ["/bin/true"],
"Cmd": [],
"HostConfig": {
"NetworkMode": "none",
"CapDrop": ["ALL"],
"PidsLimit": 32,
"SecurityOpt": ["no-new-privileges"],
},
},
)["Id"]
try:
engine.request("POST", f"/containers/{created}/start")
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
raise ValueError("offline layer initialization failed")
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
for name, payload in files.items():
item = tarfile.TarInfo(name)
item.size, item.mode, item.mtime = len(payload), 0o444, int(time.time())
archive.addfile(item, io.BytesIO(payload))
engine.request(
"PUT",
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
buffer.getvalue(),
)
changes = engine.request("GET", f"/containers/{created}/changes")
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
parents = {str(parent) for name in allowed for parent in Path(name).parents}
if not changes or any(
row["Path"] not in allowed | parents or row["Kind"] not in (0, 1) for row in changes
):
raise ValueError("unrelated changes in offline source-reuse layer")
if not allowed.issubset({row["Path"] for row in changes}):
raise ValueError("source-reuse layer omitted a file")
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
config = copy.deepcopy(parent["Config"])
config.setdefault("Labels", {})["com.nodedc.source-reuse.plan-sha256"] = plan_sha
image = engine.request(
"POST",
"/commit?"
+ urlencode(
{
"container": created,
"repo": target["name"] + "-source-reuse",
"tag": "v1",
}
),
config,
)["Id"]
after = engine.request("GET", f"/images/{image}/json")
if after["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
raise ValueError("parent image layers changed")
return image
finally:
engine.request("DELETE", f"/containers/{created}")
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
started_at, started_mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
proposal = plan(engine, root)
if sha(canonical(proposal)) != expected:
raise ValueError("source-reuse install plan changed")
evidence.mkdir(parents=False, exist_ok=False)
save(evidence / "plan.json", proposal)
files = payload_files(root)
engine.request("POST", "/volumes/create", {"Name": VOLUME, "Driver": "local", "Labels": LABELS})
volume_state(engine)
results = []
for target in proposal["targets"]:
name = target["name"]
fence(engine, target)
image = build(engine, target, files, expected)
before = fence(engine, target)
body = copy.deepcopy(before["Config"])
body["Image"] = image
body["Env"].append(CACHE_ENV + "=" + CACHE_PATH)
body["Labels"]["com.nodedc.source-reuse.plan-sha256"] = expected
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
body["HostConfig"].setdefault("Mounts", []).append(
{
"Type": "volume",
"Source": VOLUME,
"Target": CACHE_PATH,
"ReadOnly": False,
}
)
backup = name + "-pre-source-reuse-" + before["Id"][:12]
save(
evidence / (name + "-declaration.json"),
{
"name": name,
"create_body": body,
"rollback_name": backup,
"rollback_container_id": before["Id"],
"parent": target["parent"],
},
)
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
engine.request(
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
)
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
"Id"
]
engine.request("POST", f"/containers/{created}/start")
# Never auto-delete a replacement: it may already own operator work.
time.sleep(3)
after = engine.inspect(name)
if not after["State"]["Running"] or after["RestartCount"] != 0:
raise ValueError("replacement requires reconciliation; predecessor retained")
if engine.execute_json(name, probe()) != {PRODUCER: PRODUCER_SHA, **proposal["files"]}:
raise ValueError("replacement imported another payload")
results.append(
{
"name": name,
"id": created,
"image": image,
"rollback": backup,
"readiness": engine.execute_json(name, READINESS),
}
)
save(evidence / (name + "-acceptance.json"), results[-1])
receipt = {
"plan_sha256": expected,
"agents": results,
"shared_cache": VOLUME,
"compute_packages_changed": False,
"started_at_utc": started_at,
"finished_at_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": started_mono,
"finished_monotonic_ns": time.monotonic_ns(),
}
save(evidence / "receipt.json", receipt)
return receipt
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path)
parser.add_argument("--pack", type=Path)
parser.add_argument("--payload", type=Path)
parser.add_argument("--apply-plan-sha256")
parser.add_argument("--evidence", type=Path)
args = parser.parse_args()
if args.pack:
pack(args.repository, args.pack)
return
engine = Engine()
if args.apply_plan_sha256:
result = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
else:
proposal = plan(engine, args.payload)
result = {"plan": proposal, "plan_sha256": sha(canonical(proposal))}
print(json.dumps(result, sort_keys=True))
if __name__ == "__main__":
main()
@@ -71,7 +71,7 @@ _SAFE_COMPONENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
Component = Literal["eomt", "ddrnet"]
Component = Literal["camera-source", "eomt", "ddrnet"]
AssetKind = Literal["file", "tree"]
AssetVerification = Literal["sha256", "identity-sha256"]
CommandRunner = Callable[[Sequence[str], Optional[Mapping[str, str]]], None] # noqa: UP045
@@ -168,7 +168,7 @@ class RuntimeLayout:
component: Component,
expectations: Sequence[AssetExpectation],
) -> RuntimeLayout:
if component not in ("eomt", "ddrnet"):
if component not in ("camera-source", "eomt", "ddrnet"):
raise ComponentAdapterError("installed package component is invalid")
prepared = Path(PACKAGE_STEP_INPUT_ROOT) / "prepare"
return cls(
@@ -176,7 +176,7 @@ class RuntimeLayout:
camera_job_root=prepared / "camera-job",
output_root=Path(PACKAGE_OUTPUT_ROOT),
effective_ddrnet_config=prepared / "effective-ddrnet-config.json",
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "eomt",
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "camera-source",
asset_paths={item.asset_id: Path(item.path) for item in expectations},
)
@@ -289,7 +289,7 @@ def load_component_request(
source = _source_binding(document["source"])
paths = _paths(document["paths"], component)
effective_value = document["effective_ddrnet_config_sha256"]
if component == "eomt":
if component != "ddrnet":
if effective_value is not None:
raise ComponentAdapterError("EoMT request contains a DDRNet config")
effective_sha256: str | None = None
@@ -440,6 +440,61 @@ def validate_tree_asset(
return _real_directory(layout.asset_paths[asset_id], f"asset {asset_id}")
def validate_prepared_camera_root(
root: Path,
request: ComponentRequest,
) -> dict[str, Path]:
"""Verify the model-neutral camera-source output before any AI consumes it."""
resolved = _real_directory(root, "prepared camera root")
expected_children = {
"camera-source.json",
"decode-repair.json",
"source-frames",
"source-frames.json",
"timeline.jsonl",
}
if {path.name for path in resolved.iterdir()} != expected_children:
raise ComponentAdapterError("prepared camera artifact set changed")
receipt = load_canonical_json(
resolved / "camera-source.json",
label="prepared camera receipt",
maximum=1024 * 1024,
confinement_root=resolved,
)
source = request.source
if receipt.get("schema_version") != "missioncore.observatory-prepared-camera/v1" or receipt.get(
"source"
) != {
"camera_job_id": source.camera_job_id,
"input_sha256": source.camera_input_sha256,
"frame_count": source.frame_count,
}:
raise ComponentAdapterError("prepared camera identity changed")
rows = receipt.get("artifacts")
expected = {
"decode-repair": "decode-repair.json",
"source-frames": "source-frames.json",
"timeline": "timeline.jsonl",
}
if not isinstance(rows, list) or len(rows) != len(expected):
raise ComponentAdapterError("prepared camera receipt changed")
result: dict[str, Path] = {}
for row in rows:
if not isinstance(row, dict) or set(row) != {"role", "path", "byte_length", "sha256"}:
raise ComponentAdapterError("prepared camera artifact changed")
role = row.get("role")
name = expected.get(role) if isinstance(role, str) else None
if name != row.get("path"):
raise ComponentAdapterError("prepared camera artifact role changed")
path = _real_file(resolved / name, "prepared camera artifact")
if path.parent != resolved or (
row.get("byte_length") != path.stat().st_size or row.get("sha256") != sha256_file(path)
):
raise ComponentAdapterError("prepared camera artifact identity changed")
result[role] = path
return result
def validate_tree_receipt(
request: ComponentRequest,
root: Path,
@@ -833,12 +888,12 @@ def _paths(value: object, component: Component) -> dict[str, str | None]:
f"{prepared}/effective-ddrnet-config.json" if component == "ddrnet" else None
),
"eomt_result_root": (
f"{PACKAGE_STEP_INPUT_ROOT}/eomt" if component == "ddrnet" else None
f"{PACKAGE_STEP_INPUT_ROOT}/camera-source" if component == "ddrnet" else None
),
"decoded_frames_root": (
f"{PACKAGE_OUTPUT_ROOT}/source-frames"
if component == "eomt"
else f"{PACKAGE_STEP_INPUT_ROOT}/eomt/source-frames"
if component == "camera-source"
else f"{PACKAGE_STEP_INPUT_ROOT}/camera-source/source-frames"
),
}
_exact_keys(document, set(legacy), "component paths")
@@ -0,0 +1,63 @@
"""Small synthetic shared-cache proof; never claims work or loads a model."""
from __future__ import annotations
import argparse
import hashlib
import json
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from k1link.observatory.worker_service import ObservatoryWorkerServiceConfiguration
from k1link.observatory.worker_source_cache import WorkerSourceCache
PAYLOAD = b"missioncore-source-reuse-proof-AsldTM-v1\n" * 4096
SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("retain", "restore", "cleanup"))
args = parser.parse_args()
configuration = ObservatoryWorkerServiceConfiguration.from_environment()
assert configuration.source_cache_root == Path("/source-cache")
started, mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
cache = WorkerSourceCache(configuration.source_cache_root)
with tempfile.TemporaryDirectory(
prefix=".source-reuse-proof-", dir=configuration.work_root
) as root:
source = Path(root) / "synthetic.bin"
if args.mode == "retain":
source.write_bytes(PAYLOAD)
assert cache.retain(source, sha256=SHA256, byte_length=len(PAYLOAD))
else:
assert cache.restore(source, sha256=SHA256, byte_length=len(PAYLOAD))
assert source.read_bytes() == PAYLOAD
if args.mode == "cleanup":
cached = cache.root / SHA256
assert cached.read_bytes() == PAYLOAD
cached.unlink() # Only this probe's verified synthetic cache object.
print(
json.dumps(
{
"schema_version": "missioncore.worker-source-cache-proof/v1",
"mode": args.mode,
"source_sha256": SHA256,
"byte_length": len(PAYLOAD),
"exact": True,
"model_jobs": 0,
"temporary_work_removed": True,
"synthetic_cache_removed": args.mode == "cleanup",
"started_at_utc": started,
"finished_at_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": mono,
"finished_monotonic_ns": time.monotonic_ns(),
}
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,546 @@
#!/usr/bin/env python3
"""Associate RF-DETR boxes with current K1 LiDAR and publish metric ranges."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import sys
import time
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import cast
import numpy as np
from k1link.compute.lidar_local_surface_shadow import (
K1LocalSurfaceShadowEstimator,
K1LocalSurfaceShadowInput,
)
from k1link.perception.contracts import (
ClockBasis,
ModalityOutcome,
ModalityStatus,
ObjectProposal2D,
SourceEnvelope,
TimestampBundle,
)
from k1link.perception.geometry import (
GEOMETRY_PROVIDER_ID,
GeometryFrame,
GeometryProfile,
Ravnoves00GeometryAssociationProvider,
)
from k1link.perception.geometry_math import GeometryAssociationProfile, Kb4ProjectionProfile
from k1link.perception.providers import SourcePacket
SCHEMA = "missioncore.observatory-ai-module-object-distance-result/v1"
ROW_SCHEMA = "missioncore.observatory-ai-module-object-distance-frame/v1"
RF_ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
AUTHORITY = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
_PACKAGE_DETECTIONS = Path("/missioncore/input/steps/rf-detr/detections.jsonl")
_PACKAGE_LIDAR_PACK = Path("/missioncore/input/steps/prepare/lidar-pack")
_PACKAGE_BINDING_INDEX = Path("/missioncore/input/steps/prepare/m49-source/sequence-index.ndjson")
_PACKAGE_CALIBRATION = Path("/opt/nodedc/assets/k1-camera-lidar-calibration")
_PACKAGE_OUTPUT = Path("/missioncore/output")
_MAX_CALIBRATION_PACK_BYTES = 128 * 1024 * 1024
_LIDAR_PACK_ID = re.compile(r"^lidar-replay-pack-([a-f0-9]{64})$")
ASSOCIATION = GeometryAssociationProfile(
bbox_inset_fraction=0.03,
depth_cluster_minimum_gap_m=0.45,
depth_cluster_gap_fraction=0.08,
spatial_cluster_radius_m=0.6,
semantic_minimum_occupied_points=2,
semantic_minimum_occupied_voxels=1,
semantic_voxel_size_m=0.35,
conflict_minimum_classified_points=6,
conflict_surface_fraction=0.8,
geometry_local_radius_m=10.0,
geometry_voxel_size_m=0.45,
geometry_minimum_cluster_points=4,
geometry_minimum_cluster_voxels=1,
maximum_geometry_clusters_per_frame=64,
)
class ObjectDistanceModuleError(RuntimeError):
pass
class _CurrentStore:
def __init__(self, profile: GeometryProfile) -> None:
self.profile = profile
self.current: GeometryFrame | None = None
def frame(self, _packet: SourcePacket) -> GeometryFrame | None:
return self.current
@dataclass(frozen=True)
class _PointFrame:
received_monotonic_ns: int
xyz_map: np.ndarray
@dataclass(frozen=True)
class _PoseFrame:
received_monotonic_ns: int
position_map: tuple[float, float, float]
orientation_map_from_lidar: tuple[float, float, float, float]
class _LidarPack:
"""Narrow reader for the already sealed replay-pack arrays."""
def __init__(self, root: Path) -> None:
candidate = root.expanduser().absolute()
if candidate.is_symlink():
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
self.root = candidate.resolve(strict=True)
manifest_path = self.root / "manifest.json"
if manifest_path.is_symlink() or not manifest_path.is_file():
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
manifest = json.loads(manifest_path.read_text())
pack_id = manifest.get("pack_id") if isinstance(manifest, dict) else None
match = _LIDAR_PACK_ID.fullmatch(pack_id) if isinstance(pack_id, str) else None
if (
self.root.is_symlink()
or not self.root.is_dir()
or not isinstance(manifest, dict)
or manifest.get("schema_version") != "missioncore.lidar-replay-pack/v2"
or match is None
or manifest.get("identity_sha256") != match.group(1)
):
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list):
raise ObjectDistanceModuleError("LiDAR replay pack artifacts changed")
row = next(
(
item
for item in artifacts
if isinstance(item, dict) and item.get("kind") == "lidar-arrays"
),
None,
)
if row is None or row.get("path") != "lidar-replay.npz":
raise ObjectDistanceModuleError("LiDAR replay arrays are unavailable")
arrays_path = self.root / "lidar-replay.npz"
if (
arrays_path.is_symlink()
or not arrays_path.is_file()
or arrays_path.resolve(strict=True).parent != self.root
or arrays_path.stat().st_size != row.get("byte_length")
or _sha(arrays_path) != row.get("sha256")
):
raise ObjectDistanceModuleError("LiDAR replay arrays identity changed")
self.arrays_path = arrays_path.resolve(strict=True)
archive = np.load(arrays_path, allow_pickle=False)
try:
self.arrays = {name: np.asarray(archive[name]) for name in archive.files}
finally:
archive.close()
required = {
"point_offsets",
"point_xyz_map",
"point_received_monotonic_ns",
"pose_positions_map",
"pose_quaternions_map_from_lidar",
"pose_received_monotonic_ns",
}
if not required.issubset(self.arrays):
raise ObjectDistanceModuleError("LiDAR replay array set changed")
self.pack_id = pack_id
self.point_frame_count = int(self.arrays["point_received_monotonic_ns"].shape[0])
self.pose_frame_count = int(self.arrays["pose_received_monotonic_ns"].shape[0])
self.point_count = int(self.arrays["point_xyz_map"].shape[0])
def point_frame(self, index: int) -> _PointFrame:
if not 0 <= index < self.point_frame_count:
raise ObjectDistanceModuleError("LiDAR frame index is outside the replay pack")
begin, end = (int(self.arrays["point_offsets"][index + offset]) for offset in (0, 1))
points = np.asarray(self.arrays["point_xyz_map"][begin:end], dtype=np.float64)
if points.ndim != 2 or points.shape[1:] != (3,) or not np.isfinite(points).all():
raise ObjectDistanceModuleError("LiDAR frame points changed")
return _PointFrame(int(self.arrays["point_received_monotonic_ns"][index]), points)
def pose_frame(self, index: int) -> _PoseFrame:
if not 0 <= index < self.pose_frame_count:
raise ObjectDistanceModuleError("pose frame index is outside the replay pack")
position = tuple(float(value) for value in self.arrays["pose_positions_map"][index])
orientation = tuple(
float(value) for value in self.arrays["pose_quaternions_map_from_lidar"][index]
)
return _PoseFrame(
int(self.arrays["pose_received_monotonic_ns"][index]),
cast(tuple[float, float, float], position),
cast(tuple[float, float, float, float], orientation),
)
def close(self) -> None:
self.arrays.clear()
def _canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
def _sha(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _projection(path: Path) -> Kb4ProjectionProfile:
candidate = path.expanduser().resolve(strict=True)
# The admitted E10 source pack contains the three small calibration arrays
# together with the full point cloud. Bound the sealed archive itself while
# retaining strict per-member limits for the arrays read below.
if (
candidate.is_symlink()
or not candidate.is_file()
or candidate.stat().st_size > _MAX_CALIBRATION_PACK_BYTES
):
raise ObjectDistanceModuleError("camera/LiDAR calibration is unavailable")
with zipfile.ZipFile(candidate) as archive:
arrays = {}
for name in ("intrinsic_fx_fy_cx_cy", "distortion_kb4", "t_camera_from_lidar"):
info = archive.getinfo(name + ".npy")
if info.file_size > 4096:
raise ObjectDistanceModuleError("camera/LiDAR calibration exceeds its bound")
with archive.open(info) as stream:
arrays[name] = np.lib.format.read_array(stream, allow_pickle=False)
return Kb4ProjectionProfile(
800,
600,
cast(
tuple[float, float, float, float],
tuple(float(value) for value in arrays["intrinsic_fx_fy_cx_cy"]),
),
cast(
tuple[float, float, float, float],
tuple(float(value) for value in arrays["distortion_kb4"]),
),
np.asarray(arrays["t_camera_from_lidar"], dtype=np.float64),
)
def _detections(path: Path, count: int) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > 8 * 1024 * 1024 or len(rows) >= count:
raise ObjectDistanceModuleError("RF-DETR result exceeds its bound")
row = json.loads(raw)
if not isinstance(row, dict) or row.get("schema_version") != RF_ROW_SCHEMA:
raise ObjectDistanceModuleError("RF-DETR result contract changed")
rows.append(row)
if len(rows) != count:
raise ObjectDistanceModuleError("RF-DETR and LiDAR timelines differ")
return rows
def _integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ObjectDistanceModuleError(f"{label} is invalid")
return value
def _number(value: object, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ObjectDistanceModuleError(f"{label} is invalid")
number = float(value)
if not math.isfinite(number):
raise ObjectDistanceModuleError(f"{label} is invalid")
return number
def _profile(session_id: str, pack: _LidarPack) -> GeometryProfile:
return GeometryProfile(
profile_id="observatory-object-distance-v1",
provider_id=GEOMETRY_PROVIDER_ID,
source_id="recorded-k1",
session_id=session_id,
source_pack_id=pack.pack_id,
source_pack_sha256=_sha(pack.arrays_path),
frame_count=pack.point_frame_count,
point_count=pack.point_count,
local_surface_model_id="k1-local-surface-shadow-v1",
local_surface_sha256="0" * 64,
valid_frame_count=pack.point_frame_count,
width=800,
height=600,
coordinate_frame="map",
association=ASSOCIATION,
profile_sha256=hashlib.sha256(
_canonical(
{
"module": "object-distance",
"association": ASSOCIATION.__dict__
if hasattr(ASSOCIATION, "__dict__")
else str(ASSOCIATION),
}
)
).hexdigest(),
)
def _packet(
*,
session_id: str,
source_id: str,
frame_id: str,
frame_index: int,
session_seconds: float,
spatial_available: bool,
) -> SourcePacket:
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-module-input")
missing = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "no-synchronous-lidar")
spatial = available if spatial_available else missing
nanoseconds = round(session_seconds * 1_000_000_000)
return SourcePacket(
SourceEnvelope(
source_id=source_id,
session_id=session_id,
frame_id=frame_id,
sequence=frame_index,
timestamps=TimestampBundle(
utc_ns=nanoseconds,
monotonic_ns=nanoseconds,
source_ns=nanoseconds,
clock_basis=ClockBasis.RECORDED_HOST,
),
source_age_ns=0,
binding_reason="camera-lidar-past-only-binding",
calibration_id="camera-1-kb4-05f3ad9b",
representation_id="object-distance-current-cloud-v1",
image=available,
registered_point_increment=spatial,
pose=spatial,
),
b"rf-detr-proposals",
b"current-k1-cloud" if spatial_available else None,
b"current-k1-pose" if spatial_available else None,
)
def _binding_rows(path: Path) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
previous = -1.0
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > 64 * 1024 or len(rows) >= 250_000:
raise ObjectDistanceModuleError("LiDAR binding timeline exceeds its bound")
row = json.loads(raw)
if (
not isinstance(row, dict)
or row.get("schema_version") != "missioncore.m49-tgs-portable-source-index-row/v1"
or row.get("timeline_frame_index") != len(rows)
):
raise ObjectDistanceModuleError("LiDAR binding row identity changed")
seconds = _number(row.get("session_seconds"), "LiDAR binding time")
if seconds <= previous or not isinstance(row.get("sample_available"), bool):
raise ObjectDistanceModuleError("LiDAR binding timeline changed")
rows.append(row)
previous = seconds
if not rows:
raise ObjectDistanceModuleError("LiDAR binding timeline is empty")
return rows
def _aligned_camera_seconds(
binding: dict[str, object],
rf_row: dict[str, object],
*,
frame_index: int,
previous_camera_seconds: float,
) -> float:
# Validate both clocks, but join the sealed products by their shared frame
# index. Camera-source repairs MP4 discontinuities; the LiDAR binding keeps
# the original segment clock, so equality between their seconds is invalid.
_number(binding.get("session_seconds"), "LiDAR binding time")
if rf_row.get("frame_index") != frame_index:
raise ObjectDistanceModuleError("RF-DETR and LiDAR frame identities differ")
seconds = _number(rf_row.get("session_seconds"), "camera session time")
if seconds <= previous_camera_seconds:
raise ObjectDistanceModuleError("RF-DETR camera timeline changed")
return seconds
def execute(
*,
detections: Path,
lidar_pack: Path,
binding_index: Path,
calibration: Path,
output: Path,
session_id: str,
) -> dict[str, object]:
index = _binding_rows(binding_index)
rf_rows = _detections(detections, len(index))
projection = _projection(calibration)
output = output.expanduser().absolute()
if output.exists():
if output.is_symlink() or not output.is_dir() or any(output.iterdir()):
raise ObjectDistanceModuleError("output root is unsafe")
else:
output.mkdir(mode=0o700, parents=True, exist_ok=False)
if output.is_symlink() or not output.is_dir():
raise ObjectDistanceModuleError("output root is unsafe")
pack = _LidarPack(lidar_pack)
started = time.monotonic()
ranged = 0
proposal_count = 0
unavailable = 0
result_path = output / "object-distances.jsonl"
try:
store = _CurrentStore(_profile(session_id, pack))
provider = Ravnoves00GeometryAssociationProvider(store=store) # type: ignore[arg-type]
surface = K1LocalSurfaceShadowEstimator()
previous_camera_seconds = -1.0
with result_path.open("xb") as stream:
for frame_index, (binding, rf_row) in enumerate(zip(index, rf_rows, strict=True)):
seconds = _aligned_camera_seconds(
binding,
rf_row,
frame_index=frame_index,
previous_camera_seconds=previous_camera_seconds,
)
previous_camera_seconds = seconds
raw_proposals = rf_row.get("proposals")
if not isinstance(raw_proposals, list):
raise ObjectDistanceModuleError("RF-DETR proposals are unavailable")
proposals = tuple(ObjectProposal2D.from_dict(value) for value in raw_proposals)
proposal_count += len(proposals)
store.current = None
available = binding["sample_available"] is True
if available:
point = pack.point_frame(
_integer(binding["selected_lidar_frame_index"], "LiDAR frame index")
)
pose = pack.pose_frame(
_integer(binding["selected_pose_frame_index"], "pose frame index")
)
age_ms = abs(point.received_monotonic_ns - pose.received_monotonic_ns) / 1e6
surface_frame = surface.process(
K1LocalSurfaceShadowInput(
frame_index=frame_index,
source_frame_index=_integer(
binding["source_frame_index"], "source frame index"
),
session_seconds=seconds,
pose_binding_age_ms=age_ms,
points_map=point.xyz_map,
position_map=np.asarray(pose.position_map, dtype=np.float64),
published_monotonic_ns=point.received_monotonic_ns,
)
)
store.current = GeometryFrame(
frame_index,
point.xyz_map,
surface_frame.point_class,
np.asarray(pose.position_map, dtype=np.float64),
np.asarray(pose.orientation_map_from_lidar, dtype=np.float64),
projection,
surface_frame.valid,
)
elif proposals:
unavailable += len(proposals)
source_id = proposals[0].source_id if proposals else "recorded-k1"
frame_id = proposals[0].frame_id if proposals else f"frame-{frame_index + 1:06d}"
observations = tuple(
item
for item in provider.associate(
_packet(
session_id=session_id,
source_id=source_id,
frame_id=frame_id,
frame_index=frame_index,
session_seconds=seconds,
spatial_available=available,
),
proposals,
)
if item.proposal_ids
)
ranged += sum(item.metric_geometry is not None for item in observations)
stream.write(
_canonical(
{
"schema_version": ROW_SCHEMA,
"frame_index": frame_index,
"session_seconds": seconds,
"observations": [item.to_dict() for item in observations],
}
)
+ b"\n"
)
result = {
"schema_version": SCHEMA,
"module_id": "object-distance",
"source_session_id": session_id,
"frame_count": len(index),
"proposal_count": proposal_count,
"ranged_proposal_count": ranged,
"unavailable_proposal_count": unavailable,
"object_distances_sha256": _sha(result_path),
"elapsed_seconds": time.monotonic() - started,
"range_estimator": "median-camera-z-of-owned-current-points/v1",
"authority": AUTHORITY,
}
(output / "result.json").write_bytes(_canonical(result))
return result
finally:
pack.close()
def _package_session(path: Path) -> str:
document = json.loads(path.read_text())
source = document.get("source") if isinstance(document, dict) else None
session_id = source.get("session_id") if isinstance(source, dict) else None
if (
not isinstance(document, dict)
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
or not isinstance(session_id, str)
):
raise ObjectDistanceModuleError("prepared source identity changed")
return session_id
def main(argv: list[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if arguments == ["--package-step", "object-distance"]:
execute(
detections=_PACKAGE_DETECTIONS,
lidar_pack=_PACKAGE_LIDAR_PACK,
binding_index=_PACKAGE_BINDING_INDEX,
calibration=_PACKAGE_CALIBRATION,
output=_PACKAGE_OUTPUT,
session_id=_package_session(_PACKAGE_SOURCE),
)
return 0
parser = argparse.ArgumentParser()
parser.add_argument("--detections", type=Path, required=True)
parser.add_argument("--lidar-pack", type=Path, required=True)
parser.add_argument("--binding-index", type=Path, required=True)
parser.add_argument("--calibration", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--session-id", required=True)
execute(**vars(parser.parse_args(arguments)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Recorded-camera RF-DETR module with a sealed, path-local output contract."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
from k1link.perception.contracts import (
ClockBasis,
ModalityOutcome,
ModalityStatus,
SourceEnvelope,
TimestampBundle,
)
from k1link.perception.detector import NativeRfDetrShadowDetectorProvider
from k1link.perception.providers import SourcePacket
from k1link.perception.rf_detr_native_object_detector import (
RF_DETR_NATIVE_ENGINE_SHA256,
TritonNativeRfDetrHttpInferenceBackend,
)
from k1link.perception.yolox_object_detector import load_valid_fov_mask
SCHEMA = "missioncore.observatory-ai-module-rf-detr-result/v1"
ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
AUTHORITY = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
VALID_FOV_SHA256 = "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
_PACKAGE_FRAMES = Path("/missioncore/input/steps/camera-source/source-frames")
_PACKAGE_TIMELINE = Path("/missioncore/input/steps/camera-source/timeline.jsonl")
_PACKAGE_VALID_FOV = Path("/opt/nodedc/assets/valid-fov-mask")
_PACKAGE_ENGINE = Path("/models/rf_detr_large_native_kb4/1/model.plan")
_PACKAGE_OUTPUT = Path("/missioncore/output")
class RfDetrModuleError(RuntimeError):
pass
def _canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
def _sha(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _file(path: Path, expected: str, label: str) -> Path:
candidate = path.expanduser().absolute()
if candidate.is_symlink():
raise RfDetrModuleError(f"{label} identity changed")
candidate = candidate.resolve(strict=True)
if not candidate.is_file() or _sha(candidate) != expected:
raise RfDetrModuleError(f"{label} identity changed")
return candidate
def _empty(path: Path) -> Path:
candidate = path.expanduser().absolute()
if candidate.exists():
if candidate.is_symlink() or not candidate.is_dir() or any(candidate.iterdir()):
raise RfDetrModuleError("output root is unsafe")
else:
candidate.mkdir(mode=0o700, parents=True, exist_ok=False)
if candidate.is_symlink() or not candidate.is_dir():
raise RfDetrModuleError("output root is unsafe")
return candidate
def _rows(path: Path, *, maximum: int) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
with path.open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > 64 * 1024 or len(result) >= maximum:
raise RfDetrModuleError("camera timeline exceeds the module bound")
row = json.loads(raw)
if not isinstance(row, dict):
raise RfDetrModuleError("camera timeline row is invalid")
result.append(row)
if not result:
raise RfDetrModuleError("camera timeline is empty")
return result
def _wait_triton(process: subprocess.Popen[bytes]) -> None:
import http.client
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if process.poll() is not None:
raise RfDetrModuleError("RF-DETR inference runtime stopped during startup")
try:
connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=1)
connection.request("GET", "/v2/models/rf_detr_large_native_kb4/ready")
response = connection.getresponse()
response.read()
connection.close()
if response.status == 200:
return
except OSError:
pass
time.sleep(0.1)
raise RfDetrModuleError("RF-DETR inference runtime did not become ready")
def _packet(
session_id: str, source_id: str, frame_index: int, session_seconds: float, image: np.ndarray
) -> SourcePacket:
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-camera-frame")
unavailable = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "module-input-not-requested")
nanoseconds = round(session_seconds * 1_000_000_000)
envelope = SourceEnvelope(
source_id=source_id,
session_id=session_id,
frame_id=f"frame-{frame_index + 1:06d}",
sequence=frame_index,
timestamps=TimestampBundle(
utc_ns=nanoseconds,
monotonic_ns=nanoseconds,
source_ns=nanoseconds,
clock_basis=ClockBasis.RECORDED_HOST,
),
source_age_ns=0,
binding_reason="recorded-camera-timeline",
calibration_id="camera-1-kb4-05f3ad9b",
representation_id="rf-detr-native-kb4-v1",
image=available,
registered_point_increment=unavailable,
pose=unavailable,
)
return SourcePacket(envelope, image, None, None)
def execute(
*,
frames: Path,
timeline: Path,
valid_fov: Path,
engine: Path,
output: Path,
session_id: str,
source_id: str,
) -> dict[str, object]:
frames = frames.expanduser().resolve(strict=True)
if frames.is_symlink() or not frames.is_dir():
raise RfDetrModuleError("prepared camera frames are unavailable")
timeline = timeline.expanduser().resolve(strict=True)
valid_fov = _file(valid_fov, VALID_FOV_SHA256, "valid-FOV mask")
_file(engine, RF_DETR_NATIVE_ENGINE_SHA256, "RF-DETR TensorRT engine")
rows = _rows(timeline, maximum=100_000)
names = tuple(f"frame-{index + 1:06d}.png" for index in range(len(rows)))
if tuple(sorted(path.name for path in frames.iterdir())) != names:
raise RfDetrModuleError("prepared camera frame set changed")
output = _empty(output)
log = (output / "triton.log").open("wb")
process = subprocess.Popen(
[
"tritonserver",
"--model-repository=/models",
"--model-control-mode=explicit",
"--load-model=rf_detr_large_native_kb4",
"--allow-grpc=false",
"--allow-metrics=false",
"--http-address=127.0.0.1",
"--pinned-memory-pool-byte-size=16777216",
"--cuda-memory-pool-byte-size=0:16777216",
],
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
backend: TritonNativeRfDetrHttpInferenceBackend | None = None
started = time.monotonic()
counts = 0
detections_path = output / "detections.jsonl"
try:
_wait_triton(process)
backend = TritonNativeRfDetrHttpInferenceBackend("http://127.0.0.1:8000")
detector = NativeRfDetrShadowDetectorProvider(
mask=load_valid_fov_mask(valid_fov),
backend=backend,
)
detector.warm_up()
with detections_path.open("xb") as stream:
previous = -1.0
for frame_index, (name, row) in enumerate(zip(names, rows, strict=True)):
seconds = row.get("session_seconds")
if (
row.get("frame_index") != frame_index
or isinstance(seconds, bool)
or not isinstance(seconds, (int, float))
or float(seconds) <= previous
):
raise RfDetrModuleError("camera timeline identity changed")
with Image.open(frames / name) as source:
rgb = np.asarray(source.convert("RGB"), dtype=np.uint8)
if rgb.shape != (600, 800, 3):
raise RfDetrModuleError("camera raster changed")
proposals = detector.detect(
_packet(
session_id,
source_id,
frame_index,
float(seconds),
np.ascontiguousarray(rgb[:, :, ::-1]),
)
)
counts += len(proposals)
stream.write(
_canonical(
{
"schema_version": ROW_SCHEMA,
"frame_index": frame_index,
"session_seconds": float(seconds),
"proposals": [proposal.to_dict() for proposal in proposals],
}
)
+ b"\n"
)
previous = float(seconds)
snapshot = detector.snapshot()
result = {
"schema_version": SCHEMA,
"module_id": "rf-detr",
"source": {"session_id": session_id, "source_id": source_id},
"frame_count": len(rows),
"proposal_count": counts,
"zero_proposal_frame_count": snapshot.zero_proposal_frames,
"detections_sha256": _sha(detections_path),
"elapsed_seconds": time.monotonic() - started,
"authority": AUTHORITY,
}
(output / "result.json").write_bytes(_canonical(result))
return result
finally:
if backend is not None:
backend.close()
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
log.close()
def _package_source(path: Path) -> tuple[str, str]:
document = json.loads(path.read_text())
if (
not isinstance(document, dict)
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
or not isinstance(document.get("source"), dict)
or not isinstance(document.get("camera_compute_job"), dict)
):
raise RfDetrModuleError("prepared camera source contract changed")
source = document["source"]
camera = document["camera_compute_job"]
session_id = source.get("session_id")
source_id = camera.get("source_id")
if not isinstance(session_id, str) or not isinstance(source_id, str):
raise RfDetrModuleError("prepared camera source identity changed")
return session_id, source_id
def main(argv: list[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if arguments == ["--package-step", "rf-detr"]:
session_id, source_id = _package_source(_PACKAGE_SOURCE)
execute(
frames=_PACKAGE_FRAMES,
timeline=_PACKAGE_TIMELINE,
valid_fov=_PACKAGE_VALID_FOV,
engine=_PACKAGE_ENGINE,
output=_PACKAGE_OUTPUT,
session_id=session_id,
source_id=source_id,
)
return 0
parser = argparse.ArgumentParser()
parser.add_argument("--frames", type=Path, required=True)
parser.add_argument("--timeline", type=Path, required=True)
parser.add_argument("--valid-fov", type=Path, required=True)
parser.add_argument("--engine", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--session-id", required=True)
parser.add_argument("--source-id", required=True)
options = parser.parse_args(arguments)
execute(**vars(options))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Decode one sealed K1 camera epoch without loading any AI model."""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
from portable_lab_v1_component_adapter import (
AssetExpectation,
ComponentAdapterError,
RuntimeLayout,
load_component_request,
resolve_runtime_layout,
run_command,
sha256_file,
validate_camera_compute_job,
validate_tree_asset,
validate_tree_receipt,
)
from run_portable_lab_v1_eomt_component import (
FFMPEG_TREE_BYTE_LENGTH,
FFMPEG_TREE_IDENTITY_SHA256,
FFMPEG_TREE_SOURCE_IMAGE_SHA256,
_decode_camera_epoch,
_source_frame_manifest_document,
_write_canonical_json,
)
FFMPEG_ASSET = AssetExpectation(
"eomt-ffmpeg-runtime",
"/opt/nodedc/assets/ffmpeg-runtime",
"tree",
"identity-sha256",
FFMPEG_TREE_IDENTITY_SHA256,
FFMPEG_TREE_BYTE_LENGTH,
)
def execute(*, request_path: Path, layout: RuntimeLayout) -> None:
request = load_component_request(
request_path, component="camera-source", expectations=(FFMPEG_ASSET,)
)
output = layout.output_root
if output.is_symlink() or not output.is_dir() or any(output.iterdir()):
raise ComponentAdapterError("camera-source output must be an empty real directory")
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
ffmpeg_root = validate_tree_asset(request, layout, FFMPEG_ASSET.asset_id)
validate_tree_receipt(
request,
ffmpeg_root,
FFMPEG_ASSET.asset_id,
expected_metadata={
"source_image_sha256": FFMPEG_TREE_SOURCE_IMAGE_SHA256,
"source_path": "/usr/lib/ffmpeg/7.0",
},
additional_metadata_keys=frozenset({"binaries"}),
verify_payload=True,
)
workspace = output / ".camera-source-work"
workspace.mkdir(mode=0o700)
try:
frames, timeline, repair = _decode_camera_epoch(
request=request,
input_document=input_document,
camera_job_root=layout.camera_job_root,
output_root=output,
work_root=workspace,
ffmpeg_root=ffmpeg_root,
command_runner=run_command,
)
_write_canonical_json(
output / "source-frames.json", _source_frame_manifest_document(frames, request)
)
_write_canonical_json(output / "decode-repair.json", repair)
os.replace(timeline, output / "timeline.jsonl")
_write_canonical_json(
output / "camera-source.json",
{
"schema_version": "missioncore.observatory-prepared-camera/v1",
"source": {
"camera_job_id": request.source.camera_job_id,
"input_sha256": request.source.camera_input_sha256,
"frame_count": request.source.frame_count,
},
"artifacts": [
{
"role": role,
"path": name,
"byte_length": (output / name).stat().st_size,
"sha256": sha256_file(output / name),
}
for role, name in (
("decode-repair", "decode-repair.json"),
("source-frames", "source-frames.json"),
("timeline", "timeline.jsonl"),
)
],
},
)
finally:
shutil.rmtree(workspace, ignore_errors=True)
def main() -> int:
layout = resolve_runtime_layout(
tuple(sys.argv[1:]), component="camera-source", expectations=(FFMPEG_ASSET,)
)
execute(request_path=layout.request, layout=layout)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Run the sealed LAB V1 DDRNet component over EoMT-decoded K1 frames."""
"""Run DDRNet over independently prepared, immutable K1 camera frames."""
from __future__ import annotations
@@ -38,6 +38,7 @@ from portable_lab_v1_component_adapter import (
validate_file_asset,
validate_fixed_result,
validate_grayscale_png_payload,
validate_prepared_camera_root,
)
DDRNET_RESULT_SCHEMA: Final = "missioncore.lab-v1-goose-vegetation-run/v1"
@@ -136,7 +137,7 @@ def execute_ddrnet_component(
layout.effective_ddrnet_config,
request,
)
eomt_root, source_frames, decode_repair = _validate_eomt_input(
prepared_root, source_frames, decode_repair = _validate_prepared_input(
layout.eomt_result_root,
request,
)
@@ -148,7 +149,7 @@ def execute_ddrnet_component(
# tmpfs makes a full 6,830-frame K1 run fail even though the immutable
# input is valid. Verify every manifest digest through an O_NOFOLLOW
# descriptor, then let the sealed runner read that same read-only tree.
frames_root = _verify_source_frames(eomt_root, source_frames)
frames_root = _verify_source_frames(prepared_root, source_frames)
mapping_copy = workspace / "goose_label_mapping.csv"
shutil.copyfile(assets["ddrnet-goose-mapping"], mapping_copy)
os.chmod(mapping_copy, 0o400)
@@ -182,7 +183,7 @@ def execute_ddrnet_component(
"0",
)
command_runner(argv, _ddrnet_environment())
shutil.copyfile(eomt_root / "decode-repair.json", staging / "decode-repair.json")
shutil.copyfile(prepared_root / "decode-repair.json", staging / "decode-repair.json")
if (
load_json(
staging / "decode-repair.json",
@@ -269,13 +270,29 @@ def _validate_effective_config(
return config
def _validate_eomt_input(
def _validate_prepared_input(
root: Path,
request: ComponentRequest,
) -> tuple[Path, tuple[SourceFrameRow, ...], dict[str, object]]:
if root.is_symlink():
raise ComponentAdapterError("EoMT result root is a symbolic link")
raise ComponentAdapterError("prepared camera root is a symbolic link")
resolved = root.resolve(strict=True)
if not (resolved / "result.json").exists():
validate_prepared_camera_root(resolved, request)
manifest_path = _confined_regular_file(
resolved / "source-frames.json", resolved, "prepared source frame manifest"
)
source_frames = _source_frame_manifest(manifest_path, resolved, request)
repair = load_json(
resolved / "decode-repair.json",
label="camera decode repair",
maximum=1024 * 1024,
confinement_root=resolved,
)
_validate_decode_repair(repair, request.source.frame_count)
_confined_regular_file(resolved / "timeline.jsonl", resolved, "prepared camera timeline")
return resolved, source_frames, repair
# Migration compatibility for already sealed dual-model releases only.
result = validate_fixed_result(
resolved,
schema_version=EOMT_RESULT_SCHEMA,
@@ -37,6 +37,7 @@ from portable_lab_v1_component_adapter import (
validate_fixed_result,
validate_grayscale_png_payload,
validate_identity_manifest,
validate_prepared_camera_root,
validate_tree_asset,
validate_tree_receipt,
)
@@ -50,11 +51,10 @@ MODEL_REVISION: Final = "8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"
MODEL_ID: Final = "tue-mps/cityscapes_semantic_eomt_large_1024"
MODEL_ARCHITECTURE: Final = "EomtForUniversalSegmentation"
PHYSICAL_CAMERA_SOURCE_ID: Final = "sensor.camera.right"
# Keep a large post-run floor while admitting the full 6,830-frame K1 record on
# Worker 006. The independent ``reserve`` below already accounts for the
# complete worst-case working set, so adding the historical 360 GiB floor made
# the real job miss admission by about 1.4 GB despite 408 GB being free.
DISK_FLOOR_BYTES: Final = 350 * 1024**3
# Owner-approved post-run disk floor (2026-09-03); not a RAM/VRAM reservation.
# The independent ``reserve`` below remains additional to this free-space floor.
# Deploy only through a new sealed image/release, never patch an installed digest.
DISK_FLOOR_BYTES: Final = 250 * 1024**3
FFMPEG_TREE_SOURCE_IMAGE_SHA256: Final = (
"8a364092b03561b9c08ac00730206e363a53d07ea0304f7d543b403b65432b5e"
)
@@ -163,26 +163,49 @@ def execute_eomt_component(
component="eomt",
expectations=EOMT_ASSETS,
)
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
roots = _validate_release_assets(request, layout)
output = _empty_output_root(layout.output_root)
reserve = request.source.frame_count * 800 * 600 * 7 + request.source.input_byte_length
free_before = available_bytes(output)
if disk_floor_bytes < 0 or free_before < disk_floor_bytes + reserve:
if (
isinstance(disk_floor_bytes, bool)
or not isinstance(disk_floor_bytes, int)
or disk_floor_bytes < 0
or free_before < disk_floor_bytes + reserve
):
raise ComponentAdapterError("EoMT output does not satisfy its disk reserve")
# Fail before hashing the complete recording and several GiB of model assets.
# The typed request supplies a bounded estimate; input/asset validation is
# still mandatory before decoding or model execution.
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
roots = _validate_release_assets(request, layout)
workspace = _prepare_workspace(output / ".eomt-work" if work_root is None else work_root)
try:
total_started = time.perf_counter()
extract_started = time.perf_counter()
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
request=request,
input_document=input_document,
camera_job_root=layout.camera_job_root,
output_root=output,
work_root=workspace,
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
command_runner=command_runner,
)
if (layout.eomt_result_root / "camera-source.json").is_file():
prepared = validate_prepared_camera_root(layout.eomt_result_root, request)
frames_root = layout.eomt_result_root / "source-frames"
timeline_path = prepared["timeline"]
decode_repair = load_json(
prepared["decode-repair"],
label="prepared camera decode repair",
maximum=1024 * 1024,
confinement_root=layout.eomt_result_root,
)
_validate_source_frame_manifest(prepared["source-frames"], frames_root, request)
else:
# Historical fixed-layout tests and already sealed releases retain
# their old in-component decode path. New modular packages always
# mount camera-source and never make one model prepare another.
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
request=request,
input_document=input_document,
camera_job_root=layout.camera_job_root,
output_root=output,
work_root=workspace,
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
command_runner=command_runner,
)
extract_seconds = _elapsed(extract_started)
free_post_extract = available_bytes(output)
if free_post_extract < disk_floor_bytes:
@@ -68,6 +68,7 @@ M49_RELEASE_SOURCES: Final = (
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_binary.sh"),
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_evidence.py"),
Path("src/k1link/compute/lidar_replay.py"),
Path("src/k1link/compute/lidar_preparation.py"),
Path("src/k1link/observatory/m49_portable_executor.py"),
Path("src/k1link/observatory/m49_portable_result.py"),
Path("src/k1link/observatory/m49_portable_source.py"),
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
"""Reuse exact v2 inputs before decoding the source, without changing v2 identity.
The legacy producer hashes its own file into every pack. Keep that producer
unchanged: this adapter only selects and verifies an existing pack, or calls
the original builder. A cache hit is not a streaming/cold-start qualification.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import stat
from pathlib import Path
from typing import Any
from . import lidar_replay
from .lidar_contract import K1_LIDAR_PACK_V2_PROFILE
from .lidar_replay import (
DEFAULT_POSE_COVERAGE_THRESHOLD_MS,
LIDAR_MANIFEST_NAME,
LIDAR_REPLAY_PACK_SCHEMA,
LidarReplayError,
LidarReplayPackV2,
)
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_MAX_MANIFEST_BYTES = 128 * 1024
_HASH_CHUNK_BYTES = 1024 * 1024
type _FileStamp = tuple[int, int, int, int, int]
def prepare_lidar_replay_pack_v2(
capture_path: Path,
output_root: Path,
*,
session_id: str | None = None,
pose_coverage_threshold_ms: float = DEFAULT_POSE_COVERAGE_THRESHOLD_MS,
) -> Path:
"""Return the exact validated input, skipping source decode on a cache hit.
Only current-producer packs with exact raw/metadata/clock-origin digests may
be reused. Their ordinary strict reader still checks artifacts, arrays,
logical content and equivalence. Corruption fails without replacing evidence.
No source or pack array survives this call.
"""
source = capture_path.expanduser().resolve(strict=True)
if source.name != "mqtt.raw.k1mqtt" or not source.is_file():
raise LidarReplayError("LiDAR replay source must be mqtt.raw.k1mqtt")
metadata = source.with_name("mqtt.metadata.jsonl")
if not metadata.is_file():
raise LidarReplayError("exact host timing requires mqtt.metadata.jsonl")
if (
not math.isfinite(pose_coverage_threshold_ms)
or not 0 < pose_coverage_threshold_ms <= 10_000
):
raise LidarReplayError("pose coverage threshold is invalid")
resolved_session = session_id or source.parents[2].name
if _SESSION_ID.fullmatch(resolved_session) is None:
raise LidarReplayError("LiDAR replay session id is unsafe")
parent = output_root.expanduser().absolute()
if parent.is_symlink():
raise LidarReplayError("LiDAR preparation cache cannot be a symlink")
producer = Path(lidar_replay.__file__).resolve(strict=True)
producer_sha256, _ = _hash_regular_file(producer)
candidates = _candidates(parent, resolved_session, producer_sha256)
# A cold directory does not add another whole-source hash pass.
if not candidates:
return lidar_replay.build_lidar_replay_pack_v2(
source,
parent,
session_id=resolved_session,
pose_coverage_threshold_ms=pose_coverage_threshold_ms,
)
evidence, stamps = _source_evidence(source, metadata)
matches = [
(root, identity)
for root, identity in candidates
if identity.get("source_evidence") == evidence
]
if len(matches) > 1:
raise LidarReplayError("LiDAR preparation cache has ambiguous source identity")
if not matches:
result = lidar_replay.build_lidar_replay_pack_v2(
source,
parent,
session_id=resolved_session,
pose_coverage_threshold_ms=pose_coverage_threshold_ms,
)
_check_source_stamps(source, stamps)
return result
root, identity = matches[0]
pack = LidarReplayPackV2(root)
try:
if pack.identity != identity:
raise LidarReplayError("LiDAR preparation cache changed during validation")
pose_binding = pack.quality.get("pose_binding")
if (
not isinstance(pose_binding, dict)
or pose_binding.get("threshold_ms") != pose_coverage_threshold_ms
):
# v2 did not include this report parameter in its identity. Never
# silently return another report or overwrite the existing pack.
raise LidarReplayError("LiDAR cached pose coverage threshold differs")
_check_source_stamps(source, stamps)
return root
finally:
pack.close()
def _candidates(
parent: Path,
session_id: str,
producer_sha256: str,
) -> list[tuple[Path, dict[str, Any]]]:
if not parent.exists():
return []
result: list[tuple[Path, dict[str, Any]]] = []
for root in parent.iterdir():
if _PACK_ID.fullmatch(root.name) is None:
continue
if root.is_symlink() or not root.is_dir():
raise LidarReplayError("LiDAR preparation cache entry is unsafe")
manifest_path = root / LIDAR_MANIFEST_NAME
if manifest_path.is_symlink():
raise LidarReplayError("LiDAR preparation manifest cannot be a symlink")
try:
with manifest_path.open("rb") as stream:
payload = stream.read(_MAX_MANIFEST_BYTES + 1)
if len(payload) > _MAX_MANIFEST_BYTES:
raise ValueError("manifest too large")
manifest = json.loads(payload)
except (OSError, UnicodeDecodeError, ValueError) as exc:
raise LidarReplayError("LiDAR preparation manifest is invalid") from exc
identity = manifest.get("identity") if isinstance(manifest, dict) else None
if not isinstance(identity, dict):
raise LidarReplayError("LiDAR preparation identity is invalid")
# Unrelated profiles/producers are preserved, never eagerly decoded.
if (
identity.get("session_id") != session_id
or identity.get("producer_sha256") != producer_sha256
):
continue
encoded = json.dumps(
identity, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
).encode()
digest = hashlib.sha256(encoded).hexdigest()
if (
manifest.get("schema_version") != LIDAR_REPLAY_PACK_SCHEMA
or identity.get("schema_version") != LIDAR_REPLAY_PACK_SCHEMA
or manifest.get("pack_id") != root.name
or root.name != f"lidar-replay-pack-{digest}"
or manifest.get("identity_sha256") != digest
or identity.get("lidar_evidence_profile") != K1_LIDAR_PACK_V2_PROFILE.to_dict()
):
raise LidarReplayError("LiDAR preparation identity changed")
result.append((root.resolve(strict=True), identity))
return result
def _stamp(value: os.stat_result) -> _FileStamp:
return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns
def _hash_regular_file(path: Path) -> tuple[str, _FileStamp]:
before = path.lstat()
if not stat.S_ISREG(before.st_mode):
raise LidarReplayError("LiDAR source evidence must be a regular file")
digest = hashlib.sha256()
with path.open("rb") as stream:
if _stamp(os.fstat(stream.fileno())) != _stamp(before):
raise LidarReplayError("LiDAR source evidence changed before hashing")
while chunk := stream.read(_HASH_CHUNK_BYTES):
digest.update(chunk)
if _stamp(os.fstat(stream.fileno())) != _stamp(before):
raise LidarReplayError("LiDAR source evidence changed during hashing")
if _stamp(path.lstat()) != _stamp(before):
raise LidarReplayError("LiDAR source evidence changed after hashing")
return digest.hexdigest(), _stamp(before)
def _source_evidence(
source: Path,
metadata: Path,
) -> tuple[dict[str, object], dict[Path, _FileStamp]]:
paths = {"raw": source, "metadata": metadata}
origin = source.with_name("mqtt.timeline.origin.json")
if origin.exists():
paths["clock_origin"] = origin
evidence: dict[str, object] = {}
stamps: dict[Path, _FileStamp] = {}
for role, path in paths.items():
digest, stamp = _hash_regular_file(path)
evidence[role] = {"sha256": digest, "byte_length": stamp[2]}
stamps[path] = stamp
return evidence, stamps
def _check_source_stamps(source: Path, stamps: dict[Path, _FileStamp]) -> None:
origin = source.with_name("mqtt.timeline.origin.json")
if origin.exists() != (origin in stamps):
raise LidarReplayError("LiDAR source clock origin changed during preparation")
for path, expected in stamps.items():
if not stat.S_ISREG(path.lstat().st_mode) or _stamp(path.lstat()) != expected:
raise LidarReplayError("LiDAR source evidence changed during preparation")
@@ -36,6 +36,10 @@ APPLICATION_ID: Final = "nodedc_mission_core_recorded"
SESSION_TIMELINE: Final = "session_time"
RENDERER_VERSION: Final = "upstream-rerun-0.36.3-encoded-optimized-v6"
REPLAY_RENDERER_VERSION: Final = "upstream-rerun-0.36.3-canonical-replay-v1"
CANONICAL_REPLAY_RESULT_ID: Final = re.compile(
r"^(?:lab-v1-vegetation-shadow|m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance)|ai-composition)-[a-f0-9]{64}$"
)
MAX_SOURCE_BYTES: Final = 768 * 1024 * 1024
MAX_OVERLAY_BYTES: Final = 256 * 1024 * 1024
MAX_REPLAY_BYTES: Final = 1024 * 1024 * 1024
@@ -138,9 +142,7 @@ class CanonicalLabReplayArtifact:
_render_lock = threading.Lock()
_memory_cache: dict[tuple[str, str, str], CanonicalLabOverlayArtifact] = {}
_replay_lock = threading.Lock()
_replay_memory_cache: dict[
tuple[str, str, str, str], CanonicalLabReplayArtifact
] = {}
_replay_memory_cache: dict[tuple[str, str, str, str], CanonicalLabReplayArtifact] = {}
def canonical_recording_id(path: Path) -> str:
@@ -283,9 +285,7 @@ def canonical_lab_replay(
or not _is_sha256(base_generation_sha256)
or _sha256(base) != base_generation_sha256
or not _artifact_is_regular(overlay)
or re.fullmatch(
r"(?:lab-v1-vegetation-shadow|m49-tgs-portable-review)-[a-f0-9]{64}", result_id
) is None
or CANONICAL_REPLAY_RESULT_ID.fullmatch(result_id) is None
or not recording_id
or len(recording_id) > 128
):
@@ -447,12 +447,11 @@ def _verified_camera_source(root: Path, route: dict[str, Any], jobs_root: Path)
or not isinstance(files, list)
):
raise CanonicalLabOverlayError("camera job source contract changed")
epoch_prefix = PurePosixPath(
"input/camera/sensor.camera.right"
) / f"epoch-{source.get('codec_epoch')}"
epoch_prefix = (
PurePosixPath("input/camera/sensor.camera.right") / f"epoch-{source.get('codec_epoch')}"
)
required = [epoch_prefix / "init.mp4"] + [
epoch_prefix / "segments" / f"{index}.m4s"
for index in range(1, frame_count + 1)
epoch_prefix / "segments" / f"{index}.m4s" for index in range(1, frame_count + 1)
]
descriptors = {
item.get("path"): item
@@ -609,10 +608,7 @@ def _render_overlay(
"/perception/camera/image",
rr.VideoFrameReference(nanoseconds=int(video_references[index])),
)
masks = {
layer_id: _read_mask(archive, index)
for layer_id, archive in archives.items()
}
masks = {layer_id: _read_mask(archive, index) for layer_id, archive in archives.items()}
for layer_id, mask in masks.items():
recording.log(
f"/perception/camera/segmentation/{layer_id}",
@@ -731,15 +727,17 @@ def _video_reference_timestamps(
if (
len(video_timestamps) < int(len(frame_times) * 0.9)
or np.any(np.diff(video_timestamps) < 0)
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1]))
> 2_000_000_000
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1])) > 2_000_000_000
):
raise CanonicalLabOverlayError("video proxy timeline changed")
indices = np.searchsorted(
video_timestamps,
relative_frame_times,
side="right",
) - 1
indices = (
np.searchsorted(
video_timestamps,
relative_frame_times,
side="right",
)
- 1
)
return video_timestamps[np.clip(indices, 0, len(video_timestamps) - 1)]
@@ -770,9 +768,7 @@ def _semantic_palette(classes: list[object]) -> tuple[int, ...]:
or not isinstance(color, list)
or len(color) != 3
or any(
not isinstance(channel, int)
or isinstance(channel, bool)
or not 0 <= channel <= 255
not isinstance(channel, int) or isinstance(channel, bool) or not 0 <= channel <= 255
for channel in color
)
):
@@ -820,9 +816,7 @@ def semantic_component_boxes(
mask, class_id, minimum_pixels=minimum_pixels
)[:12]:
score = min(0.99, 0.5 + pixels / 20_000)
candidates.append(
(score, [left, top, right, bottom], f"{label} · {score:.0%}")
)
candidates.append((score, [left, top, right, bottom], f"{label} · {score:.0%}"))
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
selected = candidates[:32]
return [row[1] for row in selected], [row[2] for row in selected]
@@ -1001,6 +995,8 @@ def _sha256(path: Path) -> str:
def _is_sha256(value: object) -> bool:
return isinstance(value, str) and len(value) == 64 and all(
character in "0123456789abcdef" for character in value
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
+282
View File
@@ -0,0 +1,282 @@
"""Append-only bindings from one operator composition submission to its jobs."""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from k1link.observatory.modular_composition import CompositionSpec, canonical_bytes
RUN_SCHEMA: Final = "missioncore.observatory-ai-composition-run/v1"
RUN_PROJECTION_SCHEMA: Final = "missioncore.observatory-ai-composition-run-projection/v1"
_RUN = re.compile(r"ai-composition-[a-f0-9]{64}\Z")
_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,159}\Z")
class CompositionRunError(ValueError):
"""A composition-run binding is invalid or changed after admission."""
@dataclass(frozen=True, slots=True)
class CompositionRun:
run_id: str
source_session_id: str
composition_sha256: str
module_ids: tuple[str, ...]
setup_ids: tuple[str, ...]
job_ids: tuple[str, ...]
created_at_utc: str
def __post_init__(self) -> None:
if _RUN.fullmatch(self.run_id) is None:
raise CompositionRunError("invalid composition run id")
if any(
_ID.fullmatch(value) is None
for value in (
self.source_session_id,
*self.module_ids,
*self.setup_ids,
*self.job_ids,
)
):
raise CompositionRunError("invalid composition run identity")
if (
not re.fullmatch(r"[a-f0-9]{64}", self.composition_sha256)
or not self.module_ids
or len(self.setup_ids) != len(self.job_ids)
or len(set(self.setup_ids)) != len(self.setup_ids)
or len(set(self.job_ids)) != len(self.job_ids)
):
raise CompositionRunError("invalid composition run members")
if not self.created_at_utc.endswith("Z"):
raise CompositionRunError("composition run timestamp must be UTC")
def as_dict(self) -> dict[str, object]:
return {
"schema_version": RUN_SCHEMA,
"run_id": self.run_id,
"source_session_id": self.source_session_id,
"composition_sha256": self.composition_sha256,
"module_ids": list(self.module_ids),
"setup_ids": list(self.setup_ids),
"job_ids": list(self.job_ids),
"created_at_utc": self.created_at_utc,
}
class CompositionRunStore:
def __init__(self, root: Path) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or root.resolve() != root:
raise CompositionRunError("composition run store must be a real directory")
self.root = root
def save(
self,
*,
source_session_id: str,
composition: CompositionSpec,
setup_ids: tuple[str, ...],
job_ids: tuple[str, ...],
idempotency_key: str,
created_at_utc: str,
) -> CompositionRun:
digest = hashlib.sha256(
canonical_bytes(
{
"source_session_id": source_session_id,
"composition_sha256": composition.sha256,
"idempotency_key": idempotency_key,
}
)
).hexdigest()
run = CompositionRun(
run_id=f"ai-composition-{digest}",
source_session_id=source_session_id,
composition_sha256=composition.sha256,
module_ids=tuple(
node.module.module_id
for node in composition.nodes
if node.module.group != "preparation"
),
setup_ids=setup_ids,
job_ids=job_ids,
created_at_utc=created_at_utc,
)
destination = self.root / f"{run.run_id}.json"
payload = canonical_bytes(run.as_dict())
if destination.exists():
if destination.is_symlink() or destination.read_bytes() != payload:
raise CompositionRunError("composition run identity changed")
return run
descriptor, temporary = tempfile.mkstemp(prefix=".run-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o444)
path.rename(destination)
finally:
path.unlink(missing_ok=True)
return run
def get(self, run_id: str) -> CompositionRun:
if _RUN.fullmatch(run_id) is None:
raise CompositionRunError("invalid composition run id")
path = self.root / f"{run_id}.json"
if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024:
raise CompositionRunError("composition run is unavailable")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CompositionRunError("composition run is unreadable") from exc
return _decode(value)
def list(
self,
*,
source_session_id: str | None = None,
include_hidden: bool = True,
) -> tuple[CompositionRun, ...]:
values: list[CompositionRun] = []
for path in self.root.glob("ai-composition-*.json"):
if _RUN.fullmatch(path.stem) is None:
continue
run = self.get(path.stem)
if (source_session_id is None or run.source_session_id == source_session_id) and (
include_hidden or self.is_visible(run.run_id)
):
values.append(run)
return tuple(sorted(values, key=lambda row: (row.created_at_utc, row.run_id), reverse=True))
def display_name(self, run_id: str) -> str | None:
projection = self._projection(run_id)
value = projection.get("display_name")
return cast(str, value) if isinstance(value, str) else None
def is_visible(self, run_id: str) -> bool:
return self._projection(run_id).get("visible") is not False
def rename_projection(self, run_id: str, display_name: str) -> str:
self.get(run_id)
normalized = display_name.strip()
if not normalized or len(normalized) > 160:
raise CompositionRunError("invalid composition run display name")
projection = self._projection(run_id)
self._write_projection(
run_id, display_name=normalized, visible=projection.get("visible") is not False
)
return normalized
def delete_projection(self, run_id: str) -> None:
self.get(run_id)
projection = self._projection(run_id)
self._write_projection(
run_id,
display_name=cast(str | None, projection.get("display_name")),
visible=False,
)
def _projection(self, run_id: str) -> dict[str, object]:
if _RUN.fullmatch(run_id) is None:
raise CompositionRunError("invalid composition run id")
path = self.root / f"{run_id}.projection.json"
if not path.exists():
return {"display_name": None, "visible": True}
if path.is_symlink() or not path.is_file() or path.stat().st_size > 4 * 1024:
raise CompositionRunError("composition run projection is unavailable")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CompositionRunError("composition run projection is unreadable") from exc
expected = {"schema_version", "run_id", "display_name", "visible"}
if (
not isinstance(value, dict)
or set(value) != expected
or value.get("schema_version") != RUN_PROJECTION_SCHEMA
or value.get("run_id") != run_id
or not isinstance(value.get("visible"), bool)
or (
value.get("display_name") is not None
and (
not isinstance(value.get("display_name"), str)
or not cast(str, value["display_name"]).strip()
or cast(str, value["display_name"]).strip() != value["display_name"]
or len(cast(str, value["display_name"])) > 160
)
)
):
raise CompositionRunError("invalid composition run projection")
return cast(dict[str, object], value)
def _write_projection(
self,
run_id: str,
*,
display_name: str | None,
visible: bool,
) -> None:
destination = self.root / f"{run_id}.projection.json"
payload = canonical_bytes(
{
"schema_version": RUN_PROJECTION_SCHEMA,
"run_id": run_id,
"display_name": display_name,
"visible": visible,
}
)
descriptor, temporary = tempfile.mkstemp(prefix=".projection-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o600)
path.replace(destination)
finally:
path.unlink(missing_ok=True)
def _decode(value: object) -> CompositionRun:
keys = {
"schema_version",
"run_id",
"source_session_id",
"composition_sha256",
"module_ids",
"setup_ids",
"job_ids",
"created_at_utc",
}
if (
not isinstance(value, dict)
or set(value) != keys
or value.get("schema_version") != RUN_SCHEMA
):
raise CompositionRunError("invalid composition run document")
row = cast(dict[str, object], value)
arrays = (row["module_ids"], row["setup_ids"], row["job_ids"])
if any(
not isinstance(items, list) or any(not isinstance(item, str) for item in items)
for items in arrays
):
raise CompositionRunError("invalid composition run member arrays")
return CompositionRun(
run_id=cast(str, row["run_id"]),
source_session_id=cast(str, row["source_session_id"]),
composition_sha256=cast(str, row["composition_sha256"]),
module_ids=tuple(cast(list[str], row["module_ids"])),
setup_ids=tuple(cast(list[str], row["setup_ids"])),
job_ids=tuple(cast(list[str], row["job_ids"])),
created_at_utc=cast(str, row["created_at_utc"]),
)
+265
View File
@@ -0,0 +1,265 @@
"""Strict local Observatory ontology shared by planning, publication and replay UI."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from k1link.observatory.modular_composition import CompositionSpec
ONTOLOGY_SCHEMA: Final = "missioncore.observatory-domain-ontology/v1"
_ID = re.compile(r"[a-z][a-z0-9._-]{1,95}\Z")
_FIELD_ID = re.compile(r"[a-z][a-z0-9_]{1,63}\Z")
_CARDINALITIES: Final = {
"one-to-one",
"one-to-many",
"many-to-one",
"many-to-many",
"one-to-zero-or-one",
"many-to-zero-or-one",
}
class ObservatoryOntologyError(ValueError):
"""The local ontology cannot answer a product query exactly."""
@dataclass(frozen=True, slots=True)
class ViewerLayer:
layer_id: str
pane_id: str
label: str
control: str
order: int
def as_dict(self) -> dict[str, object]:
return {
"layer_id": self.layer_id,
"pane_id": self.pane_id,
"label": self.label,
"control": self.control,
"order": self.order,
}
@dataclass(frozen=True, slots=True)
class ModuleProjection:
module_id: str
configuration_label: str
layer_ids: tuple[str, ...]
class ObservatoryDomainOntology:
"""Versioned named-query projection; no graph service or Platform dependency."""
def __init__(
self,
*,
document: dict[str, object],
layers: tuple[ViewerLayer, ...],
modules: tuple[ModuleProjection, ...],
) -> None:
self.document = document
self.layers = layers
self._layers = {layer.layer_id: layer for layer in layers}
self._modules = {module.module_id: module for module in modules}
self._module_order = {module.module_id: order for order, module in enumerate(modules)}
if len(self._layers) != len(layers) or len(self._modules) != len(modules):
raise ObservatoryOntologyError("ontology identities must be unique")
for module in modules:
if any(layer not in self._layers for layer in module.layer_ids):
raise ObservatoryOntologyError("module references an unknown viewer layer")
@classmethod
def from_file(cls, path: Path) -> ObservatoryDomainOntology:
candidate = path.expanduser().absolute()
if (
candidate.is_symlink()
or not candidate.is_file()
or candidate.stat().st_size > 1024 * 1024
):
raise ObservatoryOntologyError("ontology must be a bounded regular file")
try:
value = json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ObservatoryOntologyError("ontology is unreadable") from exc
if not isinstance(value, dict) or value.get("schema_version") != ONTOLOGY_SCHEMA:
raise ObservatoryOntologyError("unsupported Observatory ontology")
required = {
"schema_version",
"ontology_id",
"version",
"owner",
"lifecycle",
"entities",
"relations",
"panes",
"layers",
"module_projections",
"named_queries",
"platform_sync",
}
if set(value) != required:
raise ObservatoryOntologyError("unexpected Observatory ontology fields")
entity_ids = _validate_entities(value["entities"])
_validate_relations(value["relations"], entity_ids)
pane_ids = _validate_panes(value["panes"])
layers = tuple(_layer(item) for item in _array(value["layers"], "layers"))
if len({layer.layer_id for layer in layers}) != len(layers):
raise ObservatoryOntologyError("ontology layer identities must be unique")
if any(layer.pane_id not in pane_ids for layer in layers):
raise ObservatoryOntologyError("viewer layer references an unknown pane")
modules = tuple(_module(item) for item in _array(value["module_projections"], "modules"))
named = value["named_queries"]
if named != [
"recording.capture-context",
"composition.configuration-label",
"composition.member-results",
"composition.viewer-layers",
"composition.ready-state",
"lab.view-profile",
]:
raise ObservatoryOntologyError("required named queries are absent")
return cls(document=cast(dict[str, object], value), layers=layers, modules=modules)
def project_module_ids(self, module_ids: tuple[str, ...]) -> dict[str, object]:
selected = []
layer_ids: set[str] = set()
for module_id in module_ids:
projection = self._modules.get(module_id)
if projection is None:
raise ObservatoryOntologyError(f"module {module_id} has no ontology projection")
selected.append(projection)
layer_ids.update(projection.layer_ids)
selected.sort(key=lambda row: self._module_order[row.module_id])
layers = sorted(
(self._layers[layer_id] for layer_id in layer_ids),
key=lambda row: (row.pane_id, row.order),
)
return {
"schema_version": "missioncore.observatory-presentation-projection/v1",
"modules": [
{"module_id": row.module_id, "label": row.configuration_label} for row in selected
],
"configuration_label": " · ".join(row.configuration_label for row in selected),
"viewer_layers": [row.as_dict() for row in layers],
}
def project_composition(self, composition: CompositionSpec) -> dict[str, object]:
return self.project_module_ids(
tuple(
node.module.module_id
for node in composition.nodes
if node.module.group != "preparation"
)
)
def _array(value: object, label: str) -> list[object]:
if not isinstance(value, list):
raise ObservatoryOntologyError(f"ontology {label} must be an array")
return value
def _record(value: object, keys: set[str], label: str) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ObservatoryOntologyError(f"invalid ontology {label}")
return cast(dict[str, object], value)
def _identifier(value: object, label: str) -> str:
if not isinstance(value, str) or not _ID.fullmatch(value):
raise ObservatoryOntologyError(f"invalid {label}")
return value
def _nonempty_text(value: object, label: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ObservatoryOntologyError(f"invalid {label}")
return value
def _validate_entities(value: object) -> set[str]:
identities: set[str] = set()
for item in _array(value, "entities"):
row = _record(item, {"id", "identity", "owner", "lifecycle"}, "entity")
entity_id = _identifier(row["id"], "entity id")
if entity_id in identities:
raise ObservatoryOntologyError("ontology entity identities must be unique")
identities.add(entity_id)
identity_field = row["identity"]
if not isinstance(identity_field, str) or not _FIELD_ID.fullmatch(identity_field):
raise ObservatoryOntologyError("invalid entity identity field")
_nonempty_text(row["owner"], "entity owner")
_nonempty_text(row["lifecycle"], "entity lifecycle")
if not identities:
raise ObservatoryOntologyError("ontology entities must not be empty")
return identities
def _validate_relations(value: object, entity_ids: set[str]) -> None:
identities: set[str] = set()
for item in _array(value, "relations"):
row = _record(item, {"id", "from", "to", "cardinality"}, "relation")
relation_id = _identifier(row["id"], "relation id")
if relation_id in identities:
raise ObservatoryOntologyError("ontology relation identities must be unique")
identities.add(relation_id)
source = _identifier(row["from"], "relation source")
target = _identifier(row["to"], "relation target")
if source not in entity_ids or target not in entity_ids:
raise ObservatoryOntologyError("relation references an unknown entity")
if row["cardinality"] not in _CARDINALITIES:
raise ObservatoryOntologyError("invalid relation cardinality")
def _validate_panes(value: object) -> set[str]:
identities: set[str] = set()
for item in _array(value, "panes"):
row = _record(item, {"pane_id", "label", "order"}, "pane")
pane_id = _identifier(row["pane_id"], "pane id")
if pane_id in identities:
raise ObservatoryOntologyError("ontology pane identities must be unique")
identities.add(pane_id)
_nonempty_text(row["label"], "pane label")
if not isinstance(row["order"], int) or isinstance(row["order"], bool):
raise ObservatoryOntologyError("invalid pane order")
return identities
def _layer(value: object) -> ViewerLayer:
row = _record(value, {"layer_id", "pane_id", "label", "control", "order"}, "layer")
if (
not isinstance(row["label"], str)
or not row["label"]
or row["control"] not in {"toggle", "toggle-with-settings"}
or not isinstance(row["order"], int)
or isinstance(row["order"], bool)
):
raise ObservatoryOntologyError("invalid viewer layer presentation")
return ViewerLayer(
_identifier(row["layer_id"], "layer id"),
_identifier(row["pane_id"], "pane id"),
row["label"],
cast(str, row["control"]),
row["order"],
)
def _module(value: object) -> ModuleProjection:
row = _record(value, {"module_id", "configuration_label", "layers"}, "module projection")
if not isinstance(row["configuration_label"], str) or not row["configuration_label"]:
raise ObservatoryOntologyError("invalid module configuration label")
layers = tuple(
_identifier(item, "viewer layer id") for item in _array(row["layers"], "module layers")
)
if len(layers) != len(set(layers)):
raise ObservatoryOntologyError("module viewer layers must be unique")
return ModuleProjection(
_identifier(row["module_id"], "module id"),
row["configuration_label"],
layers,
)
@@ -160,8 +160,10 @@ class InstalledLabDockerLaunch:
"com.nodedc.definition-sha256",
"com.nodedc.job-id",
"com.nodedc.managed-by",
"com.nodedc.module-id",
"com.nodedc.package-sha256",
"com.nodedc.product",
"com.nodedc.role",
"com.nodedc.stack",
}
if set(self.labels) != required_labels:
@@ -170,7 +172,9 @@ class InstalledLabDockerLaunch:
self.labels["com.nodedc.authority"] != "observation-only"
or self.labels["com.nodedc.component"] != self.container.container_id
or self.labels["com.nodedc.managed-by"] != "mission-core-worker"
or self.labels["com.nodedc.module-id"] != self.container.container_id
or self.labels["com.nodedc.product"] != "mission-core"
or self.labels["com.nodedc.role"] != "ai-module"
or self.labels["com.nodedc.stack"] != "observatory"
):
raise InstalledLabPackageRunnerError("Docker launch labels changed")
@@ -251,14 +255,10 @@ class DockerEngineInstalledLabLauncher:
or len(image_sha256s) != len(set(image_sha256s))
or any(_SHA256.fullmatch(value) is None for value in image_sha256s)
):
raise InstalledLabPackageRunnerError(
"installed LAB image inventory is invalid"
)
raise InstalledLabPackageRunnerError("installed LAB image inventory is invalid")
if self.transport_factory is None:
_require_local_socket(self.socket_path)
transport: httpx.BaseTransport = httpx.HTTPTransport(
uds=str(self.socket_path)
)
transport: httpx.BaseTransport = httpx.HTTPTransport(uds=str(self.socket_path))
else:
transport = self.transport_factory()
with httpx.Client(
@@ -282,7 +282,7 @@ class DockerEngineInstalledLabLauncher:
def _create(self, client: httpx.Client, launch: InstalledLabDockerLaunch) -> str:
component = launch.container.container_id[:32]
name = f"ndc-observatory-{component}-{launch.name_token}"
name = f"ndc-mission-core-ai-module-{component}-{launch.name_token}"
response = self._response(
client,
"POST",
@@ -455,7 +455,9 @@ class InstalledLabPackageProfileRunner:
output_root.mkdir(mode=0o700)
steps_root = job_root / "steps"
steps_root.mkdir(mode=0o700)
plan_path = job_root / "run-plan.json"
plan_root = job_root / "plan"
plan_root.mkdir(mode=0o700)
plan_path = plan_root / "run-plan.json"
_write_exclusive(
plan_path,
canonical_json(
@@ -530,11 +532,11 @@ class InstalledLabPackageProfileRunner:
),
InstalledLabDockerMount(
_translate_work_path(
plan_path,
plan_path.parent,
controller_root=self.controller_work_root,
engine_root=self.engine_work_root,
),
INSTALLED_LAB_PLAN_PATH,
str(PurePosixPath(INSTALLED_LAB_PLAN_PATH).parent),
True,
),
InstalledLabDockerMount(
@@ -586,8 +588,10 @@ class InstalledLabPackageProfileRunner:
"com.nodedc.definition-sha256": plan.definition_sha256,
"com.nodedc.job-id": plan.job_id,
"com.nodedc.managed-by": "mission-core-worker",
"com.nodedc.module-id": container.container_id,
"com.nodedc.package-sha256": self.package.package_sha256,
"com.nodedc.product": "mission-core",
"com.nodedc.role": "ai-module",
"com.nodedc.stack": "observatory",
},
name_token=name_token,
@@ -33,7 +33,7 @@ INSTALLED_LAB_PACKAGE_REGISTRY_SCHEMA: Final = (
INSTALLED_LAB_CONTAINER_IO_SCHEMA: Final = "missioncore.observatory-installed-lab-container-io/v2"
INSTALLED_LAB_SOURCE_ROOT: Final = "/missioncore/input/source"
INSTALLED_LAB_PLAN_PATH: Final = "/missioncore/input/run-plan.json"
INSTALLED_LAB_PLAN_PATH: Final = "/missioncore/plan/run-plan.json"
INSTALLED_LAB_STEP_INPUT_ROOT: Final = "/missioncore/input/steps"
INSTALLED_LAB_RESULT_ROOT: Final = "/missioncore/output"
INSTALLED_LAB_WORK_ROOT: Final = "/missioncore/work"
+183
View File
@@ -0,0 +1,183 @@
"""Durable operator display profiles for Observatory LAB results."""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
PROFILE_SCHEMA: Final = "missioncore.observatory-lab-view-profile/v1"
_RESULT_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}\Z")
_COLOR_MODES: Final = {"intensity", "height", "distance", "rgb", "class"}
_PALETTES: Final = {"turbo", "viridis", "plasma", "grayscale"}
class LabViewProfileError(ValueError):
"""A LAB display profile is invalid or unavailable."""
@dataclass(frozen=True, slots=True)
class LabSceneProfile:
point_size: float
accumulation_seconds: float
color_mode: str
palette: str
show_grid: bool
show_labels: bool
show_camera_frustums: bool
def __post_init__(self) -> None:
if (
isinstance(self.point_size, bool)
or not math.isfinite(self.point_size)
or self.point_size < 0.1
or isinstance(self.accumulation_seconds, bool)
or not math.isfinite(self.accumulation_seconds)
or self.accumulation_seconds < 0
or self.color_mode not in _COLOR_MODES
or self.palette not in _PALETTES
or any(
not isinstance(value, bool)
for value in (
self.show_grid,
self.show_labels,
self.show_camera_frustums,
)
)
):
raise LabViewProfileError("invalid LAB scene profile")
def as_dict(self) -> dict[str, object]:
return {
"point_size": self.point_size,
"accumulation_seconds": self.accumulation_seconds,
"color_mode": self.color_mode,
"palette": self.palette,
"show_grid": self.show_grid,
"show_labels": self.show_labels,
"show_camera_frustums": self.show_camera_frustums,
}
@dataclass(frozen=True, slots=True)
class LabViewProfile:
result_id: str
scene_settings: LabSceneProfile
updated_at_utc: str
def __post_init__(self) -> None:
if _RESULT_ID.fullmatch(self.result_id) is None or not self.updated_at_utc.endswith("Z"):
raise LabViewProfileError("invalid LAB view profile identity")
def as_dict(self) -> dict[str, object]:
return {
"schema_version": PROFILE_SCHEMA,
"result_id": self.result_id,
"scene_settings": self.scene_settings.as_dict(),
"updated_at_utc": self.updated_at_utc,
}
class LabViewProfileStore:
"""Mutable, atomic display state keyed by immutable LAB result identity."""
def __init__(self, root: Path) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or root.resolve() != root:
raise LabViewProfileError("LAB view profile store must be a real directory")
self.root = root
def _path(self, result_id: str) -> Path:
if _RESULT_ID.fullmatch(result_id) is None:
raise LabViewProfileError("invalid LAB result identity")
digest = hashlib.sha256(result_id.encode("utf-8")).hexdigest()
return self.root / f"{digest}.json"
def get(self, result_id: str) -> LabViewProfile | None:
path = self._path(result_id)
if not path.exists():
return None
if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024:
raise LabViewProfileError("LAB view profile is unavailable")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise LabViewProfileError("LAB view profile is unreadable") from exc
return _decode(value, expected_result_id=result_id)
def save(self, profile: LabViewProfile) -> LabViewProfile:
destination = self._path(profile.result_id)
payload = (
json.dumps(
profile.as_dict(),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
+ b"\n"
)
descriptor, temporary = tempfile.mkstemp(prefix=".view-profile-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o600)
path.replace(destination)
return profile
finally:
path.unlink(missing_ok=True)
def _decode(value: object, *, expected_result_id: str) -> LabViewProfile:
keys = {"schema_version", "result_id", "scene_settings", "updated_at_utc"}
if (
not isinstance(value, dict)
or set(value) != keys
or value.get("schema_version") != PROFILE_SCHEMA
):
raise LabViewProfileError("invalid LAB view profile document")
row = cast(dict[str, object], value)
if row["result_id"] != expected_result_id or not isinstance(row["updated_at_utc"], str):
raise LabViewProfileError("LAB view profile identity changed")
settings = row["scene_settings"]
setting_keys = {
"point_size",
"accumulation_seconds",
"color_mode",
"palette",
"show_grid",
"show_labels",
"show_camera_frustums",
}
if not isinstance(settings, dict) or set(settings) != setting_keys:
raise LabViewProfileError("invalid LAB scene profile document")
scene = cast(dict[str, object], settings)
if (
not isinstance(scene["point_size"], (int, float))
or not isinstance(scene["accumulation_seconds"], (int, float))
or not isinstance(scene["color_mode"], str)
or not isinstance(scene["palette"], str)
):
raise LabViewProfileError("invalid LAB scene profile values")
return LabViewProfile(
result_id=expected_result_id,
scene_settings=LabSceneProfile(
point_size=float(scene["point_size"]),
accumulation_seconds=float(scene["accumulation_seconds"]),
color_mode=scene["color_mode"],
palette=scene["palette"],
show_grid=scene["show_grid"], # type: ignore[arg-type]
show_labels=scene["show_labels"], # type: ignore[arg-type]
show_camera_frustums=scene["show_camera_frustums"], # type: ignore[arg-type]
),
updated_at_utc=row["updated_at_utc"],
)
@@ -32,7 +32,8 @@ from typing import TYPE_CHECKING, Final, cast
import numpy as np
import numpy.typing as npt
from k1link.compute.lidar_replay import LidarReplayPackV2, build_lidar_replay_pack_v2
from k1link.compute.lidar_preparation import prepare_lidar_replay_pack_v2
from k1link.compute.lidar_replay import LidarReplayPackV2
from k1link.observatory.portable_result_contract import canonical_json
from k1link.observatory.recorded_progress import report_recorded_progress
from k1link.observatory.source_admission import (
@@ -225,7 +226,7 @@ def materialize_m49_portable_source_from_worker_stage(
if parent.is_symlink() or not parent.is_dir():
raise M49PortableSourceError("portable M4.9 output parent is unsafe")
try:
lidar_pack_root = build_lidar_replay_pack_v2(
lidar_pack_root = prepare_lidar_replay_pack_v2(
root / "mqtt.raw.k1mqtt",
parent / "lidar-replay-packs",
session_id=job.source_session_id,
@@ -538,7 +539,9 @@ def _materialize_stage(
f"\t{anchor.session_seconds:.9f}\t-1\t0"
)
report_recorded_progress(
"source-preparation", anchor.timeline_frame_index + 1, len(anchors),
"source-preparation",
anchor.timeline_frame_index + 1,
len(anchors),
)
continue
@@ -600,7 +603,9 @@ def _materialize_stage(
)
available_slot += 1
report_recorded_progress(
"source-preparation", anchor.timeline_frame_index + 1, len(anchors),
"source-preparation",
anchor.timeline_frame_index + 1,
len(anchors),
)
if available_slot < 1:
@@ -0,0 +1,457 @@
"""Immutable, data-only AI compositions shared by Core and the installed Worker.
Selections contain no executable instructions. Installed module identities own
their containers; the graph connects typed capabilities, never historical LABs.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from k1link.observatory.portable_run_definitions import canonical_sha256
COMPOSITION_SCHEMA: Final = "missioncore.observatory-ai-composition/v1"
MODULE_SCHEMA: Final = "missioncore.observatory-ai-module/v1"
NODE_INPUT_SCHEMA: Final = "missioncore.observatory-ai-node-input/v1"
GROUPS: Final = ("segmentation", "detection", "geometry", "range", "motion", "policy")
_ID = re.compile(r"[a-z][a-z0-9.-]{1,95}\Z")
_SHA = re.compile(r"[a-f0-9]{64}\Z")
class CompositionError(ValueError):
"""A selection, dependency or immutable identity is not admitted."""
def canonical_bytes(value: object) -> bytes:
try:
return json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise CompositionError("composition must contain finite JSON data") from exc
def require_digest(value: object) -> str:
if not isinstance(value, str) or not _SHA.fullmatch(value):
raise CompositionError("invalid immutable digest")
return value
def _identifier(value: object) -> str:
if not isinstance(value, str) or not _ID.fullmatch(value):
raise CompositionError("invalid module or capability identifier")
return value
def _object(value: object, keys: set[str]) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise CompositionError("unexpected composition fields")
return cast(dict[str, object], value)
@dataclass(frozen=True, slots=True)
class ModuleSpec:
"""An installed version; all semantic and execution identities are sealed.
Source ports use the source.* namespace. Preparation modules are installed
infrastructure and are added only when a selected module consumes a port.
"""
module_id: str
label: str
group: str
image_sha256: str
implementation_sha256: str
model_sha256: str | None
contract_sha256: str
requires: tuple[str, ...]
provides: tuple[str, ...]
optional_inputs: tuple[str, ...] = ()
parameter_choices_json: bytes = b"{}"
defaults_json: bytes = b"{}"
state_policy: str = "stateless"
def __post_init__(self) -> None:
_identifier(self.module_id)
if self.group not in (*GROUPS, "preparation"):
raise CompositionError("unknown functional group")
if not self.label or len(self.label) > 120:
raise CompositionError("invalid module label")
for digest in (self.image_sha256, self.implementation_sha256, self.contract_sha256):
require_digest(digest)
if self.model_sha256 is not None:
require_digest(self.model_sha256)
for ports in (self.requires, self.provides, self.optional_inputs):
if tuple(sorted(set(ports))) != ports:
raise CompositionError("module ports must be unique and sorted")
for port in ports:
_identifier(port)
if not self.provides or any(port.startswith("source.") for port in self.provides):
raise CompositionError("a module cannot provide raw source authority")
if set(self.requires) & set(self.optional_inputs):
raise CompositionError("required and optional ports overlap")
if self.state_policy not in ("stateless", "causal-reset-at-source-start"):
raise CompositionError("unqualified temporal state policy")
choices = json.loads(self.parameter_choices_json)
defaults = json.loads(self.defaults_json)
if not isinstance(choices, dict) or not isinstance(defaults, dict):
raise CompositionError("module parameters must be objects")
if set(defaults) != set(choices):
raise CompositionError("every parameter needs an explicit default")
for key, values in choices.items():
_identifier(key)
if not isinstance(values, list) or not values:
raise CompositionError("parameter choices must be finite nonempty lists")
self.resolve_parameters(defaults)
def resolve_parameters(self, supplied: object) -> dict[str, object]:
choices = cast(dict[str, list[object]], json.loads(self.parameter_choices_json))
if not isinstance(supplied, dict) or set(supplied) - set(choices):
raise CompositionError("parameter is not installed for this module")
resolved: dict[str, object] = {
**cast(dict[str, object], json.loads(self.defaults_json)),
**supplied,
}
for key, value in resolved.items():
# JSON identities distinguish true from 1 and reject nonfinite floats.
if canonical_bytes(value) not in [canonical_bytes(item) for item in choices[key]]:
raise CompositionError(f"unsupported value for {key}")
return resolved
def identity_document(self) -> dict[str, object]:
return {
"schema_version": MODULE_SCHEMA,
"module_id": self.module_id,
"group": self.group,
"image_sha256": self.image_sha256,
"implementation_sha256": self.implementation_sha256,
"model_sha256": self.model_sha256,
"contract_sha256": self.contract_sha256,
"requires": list(self.requires),
"provides": list(self.provides),
"optional_inputs": list(self.optional_inputs),
"parameter_choices": json.loads(self.parameter_choices_json),
"defaults": json.loads(self.defaults_json),
"state_policy": self.state_policy,
}
@property
def sha256(self) -> str:
return canonical_sha256(self.identity_document())
@property
def docker_name(self) -> str:
return f"ndc-mission-core-ai-module-{self.module_id}"
def docker_labels(self) -> dict[str, str]:
return {
"com.nodedc.product": "mission-core",
"com.nodedc.stack": "observatory",
"com.nodedc.role": "ai-module",
"com.nodedc.managed-by": "mission-core-worker",
"com.nodedc.module-id": self.module_id,
"com.nodedc.module-sha256": self.sha256,
}
@dataclass(frozen=True, slots=True)
class CompositionNode:
module: ModuleSpec
parameters_json: bytes
# Capability -> provider module ID, or source.* for authoritative source.
inputs: tuple[tuple[str, str], ...]
def as_dict(self) -> dict[str, object]:
return {
"module_id": self.module.module_id,
"module_sha256": self.module.sha256,
"parameters": json.loads(self.parameters_json),
"inputs": dict(self.inputs),
}
@dataclass(frozen=True, slots=True)
class CompositionSpec:
"""Source-independent graph, in deterministic topological execution order."""
nodes: tuple[CompositionNode, ...]
@property
def source_capabilities(self) -> tuple[str, ...]:
return tuple(
sorted(
{
provider
for node in self.nodes
for _, provider in node.inputs
if provider.startswith("source.")
}
)
)
@property
def outputs(self) -> tuple[str, ...]:
return tuple(
sorted(
{
port
for node in self.nodes
if node.module.group != "preparation"
for port in node.module.provides
}
)
)
def as_dict(self) -> dict[str, object]:
return {
"schema_version": COMPOSITION_SCHEMA,
"nodes": [node.as_dict() for node in self.nodes],
"source_capabilities": list(self.source_capabilities),
"outputs": list(self.outputs),
"execution": {"max_parallel_nodes": 1, "mode": "recorded-observation-only"},
}
@property
def sha256(self) -> str:
return canonical_sha256(self.as_dict())
def selection_document(self) -> dict[str, object]:
return {
"schema_version": COMPOSITION_SCHEMA,
"selections": [
{
"group": node.module.group,
"module_id": node.module.module_id,
"module_sha256": node.module.sha256,
"parameters": json.loads(node.parameters_json),
}
for node in self.nodes
if node.module.group != "preparation"
],
}
class ModuleRegistry:
def __init__(self, modules: tuple[ModuleSpec, ...]) -> None:
self.modules = modules
self._by_id = {module.module_id: module for module in modules}
if len(self._by_id) != len(modules):
raise CompositionError("duplicate installed module ID")
@classmethod
def from_file(cls, path: Path) -> ModuleRegistry:
candidate = path.expanduser().absolute()
if (
candidate.is_symlink()
or not candidate.is_file()
or candidate.stat().st_size > 1024 * 1024
):
raise CompositionError("AI module registry must be a bounded regular file")
try:
root = _object(json.loads(candidate.read_text()), {"schema_version", "modules"})
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CompositionError("AI module registry is unreadable") from exc
if root["schema_version"] != "missioncore.observatory-ai-module-registry/v1":
raise CompositionError("unsupported AI module registry schema")
if not isinstance(root["modules"], list):
raise CompositionError("AI module registry rows must be an array")
return cls(tuple(_module_from_dict(value) for value in root["modules"]))
def catalog(self) -> dict[str, object]:
return {
"schema_version": "missioncore.observatory-ai-module-catalog/v1",
"groups": [
{
"group": group,
"modules": [
{
"module_id": module.module_id,
"module_sha256": module.sha256,
"label": module.label,
"docker_name": module.docker_name,
"requires": list(module.requires),
"provides": list(module.provides),
"parameter_choices": json.loads(module.parameter_choices_json),
"defaults": json.loads(module.defaults_json),
}
for module in self.modules
if module.group == group
],
}
for group in GROUPS
],
}
def compose(self, document: object) -> CompositionSpec:
root = _object(document, {"schema_version", "selections"})
if root["schema_version"] != COMPOSITION_SCHEMA:
raise CompositionError("unsupported composition schema")
selections = root["selections"]
if not isinstance(selections, list) or not 1 <= len(selections) <= len(GROUPS):
raise CompositionError("select at least one AI module")
selected: dict[str, tuple[ModuleSpec, bytes]] = {}
groups: set[str] = set()
for raw in selections:
row = _object(raw, {"group", "module_id", "module_sha256", "parameters"})
module = self._by_id.get(_identifier(row["module_id"]))
if module is None or module.sha256 != row["module_sha256"]:
raise CompositionError("module version is not installed")
if module.group != row["group"] or module.group not in GROUPS:
raise CompositionError("module belongs to another functional group")
if module.group in groups:
raise CompositionError(f"select only one provider for {module.group}")
groups.add(module.group)
selected[module.module_id] = (
module,
canonical_bytes(module.resolve_parameters(row["parameters"])),
)
def producers() -> dict[str, str]:
result: dict[str, str] = {}
for module, _ in selected.values():
for port in module.provides:
if port in result:
raise CompositionError(f"ambiguous provider for {port}")
result[port] = module.module_id
return result
# Only source preparation is implicit. Missing analytical dependencies
# must be selected by the operator, never silently added to the LAB.
while True:
supplied = producers()
missing = sorted(
{
port
for module, _ in selected.values()
for port in module.requires
if not port.startswith("source.") and port not in supplied
}
)
if not missing:
break
added = False
for port in missing:
candidates = [
module
for module in self.modules
if module.group == "preparation" and port in module.provides
]
if len(candidates) != 1:
raise CompositionError(f"select a module providing {port}")
module = candidates[0]
if module.module_id not in selected:
selected[module.module_id] = (
module,
canonical_bytes(module.resolve_parameters({})),
)
added = True
if not added:
raise CompositionError("unresolved module dependencies")
supplied = producers()
pending: dict[str, CompositionNode] = {}
for module, parameters in selected.values():
ports = (
*module.requires,
*(port for port in module.optional_inputs if port in supplied),
)
inputs = tuple(
sorted(
(port, port if port.startswith("source.") else supplied[port]) for port in ports
)
)
pending[module.module_id] = CompositionNode(module, parameters, inputs)
ordered: list[CompositionNode] = []
emitted: set[str] = set()
while pending:
ready = sorted(
key
for key, node in pending.items()
if all(
provider.startswith("source.") or provider in emitted
for _, provider in node.inputs
)
)
if not ready:
raise CompositionError("cyclic module dependencies")
for key in ready:
ordered.append(pending.pop(key))
emitted.add(key)
return CompositionSpec(tuple(ordered))
def node_input_identity(
node: CompositionNode,
inputs: dict[str, str],
*,
state_context_sha256: str | None = None,
) -> dict[str, object]:
"""Exact node cache identity, independent of job and unrelated selections.
Each input digest seals bytes AND its I/O envelope (clock, calibration,
preprocessing, cadence, precision, unavailable samples). Stateful nodes
additionally bind their initialization and causal history.
"""
if set(inputs) != {port for port, _ in node.inputs}:
raise CompositionError("node input capabilities disagree with the graph")
for digest in inputs.values():
require_digest(digest)
if node.module.state_policy == "stateless":
if state_context_sha256 is not None:
raise CompositionError("stateless node cannot bind temporal state")
else:
require_digest(state_context_sha256)
return {
"schema_version": NODE_INPUT_SCHEMA,
"module_sha256": node.module.sha256,
"parameters": json.loads(node.parameters_json),
"inputs": dict(sorted(inputs.items())),
"state_policy": node.module.state_policy,
"state_context_sha256": state_context_sha256,
}
def _module_from_dict(value: object) -> ModuleSpec:
row = _object(
value,
{
"module_id",
"label",
"group",
"image_sha256",
"implementation_sha256",
"model_sha256",
"contract_sha256",
"requires",
"provides",
"optional_inputs",
"parameter_choices",
"defaults",
"state_policy",
},
)
arrays: dict[str, tuple[str, ...]] = {}
for name in ("requires", "provides", "optional_inputs"):
raw = row[name]
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
raise CompositionError(f"module {name} must be an array of strings")
arrays[name] = tuple(raw)
return ModuleSpec(
module_id=_identifier(row["module_id"]),
label=row["label"] if isinstance(row["label"], str) else "",
group=row["group"] if isinstance(row["group"], str) else "",
image_sha256=require_digest(row["image_sha256"]),
implementation_sha256=require_digest(row["implementation_sha256"]),
model_sha256=None if row["model_sha256"] is None else require_digest(row["model_sha256"]),
contract_sha256=require_digest(row["contract_sha256"]),
requires=arrays["requires"],
provides=arrays["provides"],
optional_inputs=arrays["optional_inputs"],
parameter_choices_json=canonical_bytes(row["parameter_choices"]),
defaults_json=canonical_bytes(row["defaults"]),
state_policy=row["state_policy"] if isinstance(row["state_policy"], str) else "",
)
@@ -0,0 +1,49 @@
"""Durable immutable composition catalog owned by Core."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from k1link.observatory.modular_composition import (
CompositionError,
CompositionSpec,
ModuleRegistry,
canonical_bytes,
)
class ModularCompositionStore:
def __init__(self, root: Path, registry: ModuleRegistry) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or root.resolve() != root:
raise CompositionError("composition store must be a real directory")
self.root = root
self.registry = registry
def save(self, selection: object) -> tuple[CompositionSpec, bool]:
composition = self.registry.compose(selection)
destination = self.root / f"{composition.sha256}.json"
payload = canonical_bytes(composition.as_dict())
if destination.exists():
if destination.is_symlink() or destination.read_bytes() != payload:
raise CompositionError("stored composition identity is damaged")
return composition, False
descriptor, temporary = tempfile.mkstemp(prefix=".composition-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o444)
try:
path.rename(destination)
except OSError:
if destination.is_symlink() or destination.read_bytes() != payload:
raise CompositionError("composition publication conflict") from None
return composition, True
finally:
path.unlink(missing_ok=True)
@@ -0,0 +1,528 @@
"""Installed-package prepare and result steps for one independent AI module."""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import sys
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Final, cast
from k1link.observatory.installed_lab_packages import (
INSTALLED_LAB_PLAN_PATH,
INSTALLED_LAB_RESULT_ROOT,
INSTALLED_LAB_SOURCE_ROOT,
INSTALLED_LAB_STEP_INPUT_ROOT,
)
from k1link.observatory.m49_portable_source import (
M49_PORTABLE_STAGE_MANIFEST,
materialize_m49_portable_source_from_worker_stage,
)
from k1link.observatory.modular_result import MODULAR_RESULT_KIND, MODULAR_RESULT_SCHEMA
from k1link.observatory.portable_lab_v1_executor import (
build_portable_ddrnet_effective_config,
portable_lab_v1_source_input_from_document,
)
from k1link.observatory.portable_lab_v1_local_runners import (
PORTABLE_LAB_V1_COMPONENT_REQUEST_SCHEMA,
)
from k1link.observatory.portable_lab_v1_worker import (
_SealedPackageJobView,
materialize_recorded_camera_source_from_worker_stage,
)
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
RESULT_DOCUMENT_ROLE,
RESULT_PACKAGE_MANIFEST_NAME,
PortableResultArtifact,
PortableResultPackageManifest,
canonical_json,
)
from k1link.observatory.portable_run_definitions import (
PortableRunDefinition,
PortableRunDefinitionRegistry,
canonical_sha256,
)
from k1link.observatory.portable_worker_runtime import PortableWorkerSourceStage
from k1link.observatory.recorded_jobs import RecordedExecutorIdentity
from k1link.observatory.worker_agent import SealedObservatoryRecordedJob
MODULAR_PACKAGE_CONTRACT_SCHEMA: Final = (
"missioncore.observatory-ai-module-installed-package-contract/v1"
)
_CONTRACT = Path("/opt/nodedc/package/contract.json")
_DEFINITIONS = Path("/opt/nodedc/package/portable-run-definitions.json")
_DDRNET_PROFILE = Path("/opt/nodedc/package/ddrnet-profile.json")
_M49_PROFILE = Path("/opt/nodedc/package/m49-profile.json")
_PREPARE = Path(INSTALLED_LAB_STEP_INPUT_ROOT) / "prepare"
_MAX_DOCUMENT_BYTES: Final = 2 * 1024 * 1024
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
_ARTIFACTS: Final = {
"ddrnet": (
("ddrnet-decode-repair", "decode-repair.json", "application/json"),
("ddrnet-result-document", "result.json", "application/json"),
("ddrnet-semantic-mask-archive", "semantic-masks.zip", "application/zip"),
),
"eomt": (
("eomt-decode-repair", "decode-repair.json", "application/json"),
("eomt-panoptic-frame-metadata", "frames.jsonl", "application/x-ndjson"),
("eomt-gpu-telemetry", "gpu-telemetry.jsonl", "application/x-ndjson"),
("eomt-panoptic-mask-archive", "masks.tar.gz", "application/gzip"),
("eomt-overlay-video", "perception.mp4", "video/mp4"),
("eomt-result-document", "result.json", "application/json"),
("eomt-run-report", "run-report.json", "application/json"),
("eomt-source-frame-manifest", "source-frames.json", "application/json"),
),
"rf-detr": (
("rf-detr-frame-detections", "detections.jsonl", "application/x-ndjson"),
("rf-detr-result-document", "result.json", "application/json"),
),
"object-distance": (
("rf-detr-frame-detections", "detections.jsonl", "application/x-ndjson"),
("rf-detr-result-document", "result.json", "application/json"),
(
"object-distance-frame-observations",
"object-distances.jsonl",
"application/x-ndjson",
),
("object-distance-result-document", "result.json", "application/json"),
),
}
class ModularPackageStepError(RuntimeError):
"""The selected installed module package changed or is incomplete."""
def main(argv: Sequence[str] | None = None) -> int:
arguments = tuple(sys.argv[1:] if argv is None else argv)
if arguments == ("prepare",):
prepare()
return 0
if arguments == ("assemble",):
assemble()
return 0
raise ModularPackageStepError("AI-module package step is not allowlisted")
def prepare() -> None:
output = _empty_directory(Path(INSTALLED_LAB_RESULT_ROOT), "AI-module prepare output")
contract = _load_contract()
runtime_plan = _runtime_plan()
definition = _definition(runtime_plan)
job = _sealed_job(runtime_plan, definition=definition, contract=contract)
module = _object(contract["module"], "AI module")
module_id = _identifier(module["module_id"], "module id")
stage = PortableWorkerSourceStage(
root=_real_directory(Path(INSTALLED_LAB_SOURCE_ROOT), "AI-module source"),
source_bundle_sha256=_digest(runtime_plan["source_bundle_sha256"], "source bundle"),
source_capability_manifest_sha256=_digest(
runtime_plan["source_capability_manifest_sha256"], "source capability"
),
source_adapter_sha256=_digest(runtime_plan["source_adapter_sha256"], "source adapter"),
)
materialized = materialize_recorded_camera_source_from_worker_stage(
worker_stage=stage,
job=job,
definition=definition,
output_parent=output,
)
camera_target = output / "camera-job"
camera_stage_parent = materialized.camera_job_root.parent
os.replace(materialized.camera_job_root, camera_target)
camera_stage_parent.rmdir()
materialized.root.rmdir()
source = materialized.descriptor.as_dict()
_write(output / "source-input.json", source)
if module_id == "object-distance":
spatial_root = output / "spatial-source"
m49 = materialize_m49_portable_source_from_worker_stage(
worker_stage=stage,
job=job,
profile_path=_M49_PROFILE,
output_parent=spatial_root,
)
manifest = _load_object(
m49.root / M49_PORTABLE_STAGE_MANIFEST,
"object-distance spatial source",
)
identity = _object(manifest.get("identity"), "object-distance spatial identity")
lidar = _object(identity.get("lidar_replay"), "object-distance LiDAR identity")
pack_id = _identifier(lidar.get("pack_id"), "object-distance LiDAR pack id")
pack = _real_directory(
spatial_root / "lidar-replay-packs" / pack_id,
"object-distance LiDAR pack",
)
os.replace(pack, output / "lidar-pack")
os.replace(m49.root, output / "m49-source")
shutil.rmtree(spatial_root)
plan_sha = canonical_sha256(
{
"schema_version": "missioncore.observatory-ai-module-plan/v1",
"job_identity_sha256": job.identity_sha256,
"definition_sha256": definition.definition_sha256,
"module_id": module_id,
"module_sha256": _digest(module["module_sha256"], "module identity"),
"source_input_sha256": materialized.descriptor.identity_sha256,
}
)
assets = _object(contract["component_assets"], "component assets")
images = _object(contract["component_images"], "component images")
_write(
output / "camera-source-request.json",
_component_request(
component="camera-source",
image_sha=_digest(images["camera-source"], "camera image"),
source=source,
plan_sha=plan_sha,
definition=definition,
release_sha=_digest(runtime_plan["candidate_sha256"], "runtime candidate"),
assets=_asset_rows(assets["camera-source"], "camera-source"),
ddrnet_config_sha=None,
),
)
ddrnet_config_sha: str | None = None
if module_id == "ddrnet":
profile = _load_object(_DDRNET_PROFILE, "DDRNet profile")
effective = build_portable_ddrnet_effective_config(
profile,
source=materialized.descriptor,
)
_write(output / "effective-ddrnet-config.json", effective)
ddrnet_config_sha = canonical_sha256(effective)
_write(
output / f"{module_id}-request.json",
_component_request(
component=module_id,
image_sha=_digest(images[module_id], "module image"),
source=source,
plan_sha=plan_sha,
definition=definition,
release_sha=_digest(runtime_plan["candidate_sha256"], "runtime candidate"),
assets=_asset_rows(assets[module_id], module_id),
ddrnet_config_sha=ddrnet_config_sha,
),
)
def assemble() -> None:
output = _empty_directory(Path(INSTALLED_LAB_RESULT_ROOT), "AI-module result output")
artifacts_root = output / "artifacts"
artifacts_root.mkdir(mode=0o700)
contract = _load_contract()
runtime_plan = _runtime_plan()
definition = _definition(runtime_plan)
job = _sealed_job(runtime_plan, definition=definition, contract=contract)
source_input = portable_lab_v1_source_input_from_document(
_load_object(_PREPARE / "source-input.json", "AI-module source input")
)
module = _object(contract["module"], "AI module")
module_id = _identifier(module["module_id"], "module id")
module_root = _real_directory(
Path(INSTALLED_LAB_STEP_INPUT_ROOT) / module_id, "AI-module output"
)
component_result = _regular_file(module_root / "result.json", module_root, "module result")
component_result_sha = _sha256(component_result)
artifacts: list[PortableResultArtifact] = []
for role, name, media_type in _ARTIFACTS[module_id]:
artifact_root = (
_real_directory(
Path(INSTALLED_LAB_STEP_INPUT_ROOT) / "rf-detr",
"RF-DETR dependency output",
)
if module_id == "object-distance" and role.startswith("rf-detr-")
else module_root
)
source_path = _regular_file(artifact_root / name, artifact_root, role)
artifact_name = f"{role}{source_path.suffix}"
if name.endswith(".tar.gz"):
artifact_name = f"{role}.tar.gz"
target_name = f"artifacts/{artifact_name}"
target = artifacts_root / artifact_name
_copy(source_path, target)
artifacts.append(_artifact(role, target_name, media_type, target))
source = {
"session_id": job.source_session_id,
"catalog_sha256": job.source_catalog_sha256,
"bundle_sha256": job.source_bundle_sha256,
"capability_manifest_sha256": job.source_capability_manifest_sha256,
"camera_input_sha256": source_input.camera_input_sha256,
"frame_count": source_input.frame_count,
"timeline_start_seconds": source_input.timeline_start_seconds,
"timeline_end_seconds": source_input.timeline_end_seconds,
}
module_view = {
"module_id": module_id,
"label": _text(module["label"], "module label"),
"module_sha256": _digest(module["module_sha256"], "module identity"),
"image_sha256": _digest(module["image_sha256"], "module image"),
"definition_sha256": definition.definition_sha256,
"component_result_sha256": component_result_sha,
}
identity = {
"job_identity_sha256": job.identity_sha256,
"source_bundle_sha256": job.source_bundle_sha256,
"definition_sha256": definition.definition_sha256,
"module_id": module_id,
"component_result_sha256": component_result_sha,
}
identity_sha = canonical_sha256(identity)
result_id = f"ai-layer-{module_id}-{identity_sha}"
result_document = {
"schema_version": MODULAR_RESULT_SCHEMA,
"result_id": result_id,
"result_kind": MODULAR_RESULT_KIND,
"identity": identity,
"identity_sha256": identity_sha,
"source": source,
"module": module_view,
"artifacts": [item.as_dict() for item in sorted(artifacts, key=lambda item: item.role)],
"authority": dict(OBSERVATION_ONLY_AUTHORITY),
}
result_path = artifacts_root / "result.json"
_write(result_path, result_document)
artifacts.append(
_artifact(
RESULT_DOCUMENT_ROLE,
"artifacts/result.json",
"application/json",
result_path,
)
)
manifest = PortableResultPackageManifest.create(
job=_SealedPackageJobView.from_job(job),
definition=definition,
result_id=result_id,
created_at_utc=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
artifacts=artifacts,
)
(output / RESULT_PACKAGE_MANIFEST_NAME).write_bytes(manifest.canonical_bytes)
os.chmod(output / RESULT_PACKAGE_MANIFEST_NAME, 0o400)
def _component_request(
*,
component: str,
image_sha: str,
source: Mapping[str, object],
plan_sha: str,
definition: PortableRunDefinition,
release_sha: str,
assets: list[dict[str, object]],
ddrnet_config_sha: str | None,
) -> dict[str, object]:
prepared = f"{INSTALLED_LAB_STEP_INPUT_ROOT}/prepare"
return {
"schema_version": PORTABLE_LAB_V1_COMPONENT_REQUEST_SCHEMA,
"component": component,
"component_image_sha256": image_sha,
"plan_sha256": plan_sha,
"definition_sha256": definition.definition_sha256,
"release_candidate_sha256": release_sha,
"source": dict(source),
"paths": {
"camera_job_root": f"{prepared}/camera-job",
"request": f"{prepared}/{component}-request.json",
"output_root": INSTALLED_LAB_RESULT_ROOT,
"effective_ddrnet_config": (
f"{prepared}/effective-ddrnet-config.json" if component == "ddrnet" else None
),
"eomt_result_root": (
f"{INSTALLED_LAB_STEP_INPUT_ROOT}/camera-source" if component == "ddrnet" else None
),
"decoded_frames_root": (
f"{INSTALLED_LAB_RESULT_ROOT}/source-frames"
if component == "camera-source"
else f"{INSTALLED_LAB_STEP_INPUT_ROOT}/camera-source/source-frames"
),
},
"effective_ddrnet_config_sha256": ddrnet_config_sha,
"assets": assets,
"authority": dict(OBSERVATION_ONLY_AUTHORITY),
}
def _sealed_job(
runtime_plan: Mapping[str, object],
*,
definition: PortableRunDefinition,
contract: Mapping[str, object],
) -> SealedObservatoryRecordedJob:
executor = _object(contract["executor"], "package executor")
identity = RecordedExecutorIdentity(
release_sha256=_digest(executor["release_sha256"], "executor release"),
image_sha256=_digest(executor["image_sha256"], "executor image"),
model_manifest_sha256=definition.model_manifest_sha256,
resource_profile_sha256=definition.resource_profile.profile_sha256,
)
return SealedObservatoryRecordedJob(
job_id=_text(runtime_plan["job_id"], "job id"),
request_sha256=_digest(runtime_plan["request_sha256"], "request"),
identity_sha256=_digest(runtime_plan["identity_sha256"], "job identity"),
submission_receipt_sha256=_digest(runtime_plan["submission_receipt_sha256"], "receipt"),
source_session_id=_text(runtime_plan["source_session_id"], "source session"),
source_catalog_sha256=_digest(runtime_plan["source_catalog_sha256"], "catalog"),
source_bundle_sha256=_digest(runtime_plan["source_bundle_sha256"], "bundle"),
source_capability_manifest_sha256=_digest(
runtime_plan["source_capability_manifest_sha256"], "capability"
),
source_adapter_id=_identifier(runtime_plan["source_adapter_id"], "adapter id"),
source_adapter_version=_positive_int(
runtime_plan["source_adapter_version"], "adapter version"
),
source_adapter_sha256=_digest(runtime_plan["source_adapter_sha256"], "adapter"),
setup_id=definition.setup_id,
definition_id=definition.definition_id,
definition_version=definition.version,
definition_sha256=definition.definition_sha256,
executor_release_id=_identifier(executor["release_id"], "executor release id"),
executor_identity=identity,
model_release_ids=definition.learned_models,
resource_profile_id=definition.resource_profile.profile_id,
checkpoint_policy=definition.resource_profile.checkpoint_policy,
allowed_checkpoints=definition.resource_profile.allowed_checkpoints,
claim_generation=_positive_int(runtime_plan["claim_generation"], "claim generation"),
claim_claimed_at_utc=None,
claim_expires_at_utc=None,
claim_heartbeat_at_utc=None,
claim_renewal_count=0,
restart_from_zero=False,
)
def _runtime_plan() -> dict[str, object]:
outer = _load_object(Path(INSTALLED_LAB_PLAN_PATH), "installed AI-module run plan")
if (
outer.get("schema_version") != "missioncore.observatory-installed-lab-run-plan/v1"
or outer.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
raise ModularPackageStepError("installed AI-module run plan changed")
return _object(outer.get("runtime_plan"), "portable runtime plan")
def _definition(runtime_plan: Mapping[str, object]) -> PortableRunDefinition:
return PortableRunDefinitionRegistry.from_file(_DEFINITIONS).resolve(
_identifier(runtime_plan["setup_id"], "setup id"),
_digest(runtime_plan["definition_sha256"], "definition"),
)
def _load_contract() -> dict[str, object]:
value = _load_object(_CONTRACT, "AI-module package contract")
if (
value.get("schema_version") != MODULAR_PACKAGE_CONTRACT_SCHEMA
or value.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
raise ModularPackageStepError("AI-module package contract changed")
return value
def _asset_rows(value: object, component: str) -> list[dict[str, object]]:
if not isinstance(value, list):
raise ModularPackageStepError(f"{component} assets are not an array")
rows = [dict(_object(row, f"{component} asset")) for row in value]
ids = tuple(cast(str, row.get("asset_id")) for row in rows)
if ids != tuple(sorted(ids)) or len(ids) != len(set(ids)):
raise ModularPackageStepError(f"{component} assets are not canonical")
return rows
def _artifact(role: str, relative: str, media: str, path: Path) -> PortableResultArtifact:
return PortableResultArtifact(role, relative, media, path.stat().st_size, _sha256(path))
def _copy(source: Path, target: Path) -> None:
with source.open("rb") as reader, target.open("xb") as writer:
shutil.copyfileobj(reader, writer, 1024 * 1024)
os.chmod(target, 0o400)
if target.stat().st_size != source.stat().st_size or _sha256(target) != _sha256(source):
raise ModularPackageStepError("AI-module artifact copy changed")
def _write(path: Path, value: Mapping[str, object]) -> None:
path.write_bytes(canonical_json(value))
os.chmod(path, 0o400)
def _load_object(path: Path, label: str) -> dict[str, object]:
if (
path.is_symlink()
or not path.is_file()
or not 0 < path.stat().st_size <= _MAX_DOCUMENT_BYTES
):
raise ModularPackageStepError(f"{label} is unavailable")
try:
return _object(json.loads(path.read_bytes()), label)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ModularPackageStepError(f"{label} is invalid JSON") from exc
def _regular_file(path: Path, root: Path, label: str) -> Path:
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
raise ModularPackageStepError(f"{label} is unavailable")
return path.resolve()
def _empty_directory(path: Path, label: str) -> Path:
root = _real_directory(path, label)
if any(root.iterdir()):
raise ModularPackageStepError(f"{label} is not empty")
return root
def _real_directory(path: Path, label: str) -> Path:
if path.is_symlink() or not path.is_dir():
raise ModularPackageStepError(f"{label} is unavailable")
return path.resolve(strict=True)
def _object(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise ModularPackageStepError(f"{label} is invalid")
return cast(dict[str, object], value)
def _text(value: object, label: str) -> str:
if not isinstance(value, str) or not value:
raise ModularPackageStepError(f"{label} is invalid")
return value
def _identifier(value: object, label: str) -> str:
text = _text(value, label)
if _IDENTIFIER.fullmatch(text) is None:
raise ModularPackageStepError(f"{label} is invalid")
return text
def _digest(value: object, label: str) -> str:
text = _text(value, label)
if _SHA256.fullmatch(text) is None:
raise ModularPackageStepError(f"{label} is invalid")
return text
def _positive_int(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ModularPackageStepError(f"{label} is invalid")
return value
def _sha256(path: Path) -> str:
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
if __name__ == "__main__":
try:
raise SystemExit(main())
except ModularPackageStepError as exc:
print(f"installed AI-module package rejected: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
@@ -0,0 +1,196 @@
"""Sealed Worker-local node results; source CAS and final Core LABs are separate.
Entries are published by atomic directory rename only after all regular files
are hashed. Readers validate bytes, not existence. A damaged entry is a miss;
original evidence is never overwritten or removed by a reader.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import stat
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import cast
from k1link.observatory.modular_composition import (
CompositionError,
canonical_bytes,
require_digest,
)
from k1link.observatory.portable_run_definitions import canonical_sha256
NODE_RESULT_SCHEMA = "missioncore.observatory-ai-node-result/v1"
_MAX_MANIFEST_BYTES = 4 * 1024 * 1024
_MAX_FILES = 20_000
@dataclass(frozen=True, slots=True)
class SealedNodeResult:
root: Path
input_sha256: str
result_sha256: str
manifest_json: bytes
@property
def manifest(self) -> dict[str, object]:
value = json.loads(self.manifest_json)
if not isinstance(value, dict):
raise CompositionError("node manifest must be an object")
return cast(dict[str, object], value)
def _hash_file(path: Path) -> tuple[int, str]:
mode = path.lstat().st_mode
if not stat.S_ISREG(mode):
raise CompositionError("node result must contain only regular files")
digest = hashlib.sha256()
size = 0
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
size += len(chunk)
return size, digest.hexdigest()
def _files(root: Path) -> list[dict[str, object]]:
entries: list[dict[str, object]] = []
for path in sorted(root.rglob("*")):
mode = path.lstat().st_mode
if stat.S_ISDIR(mode):
continue
size, digest = _hash_file(path)
entries.append(
{"path": path.relative_to(root).as_posix(), "byte_length": size, "sha256": digest}
)
if len(entries) > _MAX_FILES:
raise CompositionError("node result exceeds the file limit")
if not entries:
raise CompositionError("empty node output cannot be sealed")
return entries
class ModularNodeCache:
def __init__(self, root: Path) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or not root.is_dir() or root.resolve() != root:
raise CompositionError("node cache root must be a real directory")
self.root = root
def lookup(self, input_identity: dict[str, object]) -> SealedNodeResult | None:
input_sha256 = canonical_sha256(input_identity)
entry = self.root / input_sha256
try:
if entry.is_symlink() or not entry.is_dir():
return None
manifest_path = entry / "manifest.json"
if (
not stat.S_ISREG(manifest_path.lstat().st_mode)
or manifest_path.stat().st_size > _MAX_MANIFEST_BYTES
):
return None
payload = manifest_path.read_bytes()
manifest = cast(dict[str, object], json.loads(payload))
if set(manifest) != {
"schema_version",
"input_identity",
"input_sha256",
"outputs",
"metadata",
"result_sha256",
}:
return None
identity = {key: value for key, value in manifest.items() if key != "result_sha256"}
if (
manifest["schema_version"] != NODE_RESULT_SCHEMA
or manifest["input_identity"] != input_identity
or manifest["input_sha256"] != input_sha256
or manifest["result_sha256"] != canonical_sha256(identity)
or payload != canonical_bytes(manifest)
):
return None
data = entry / "data"
if data.is_symlink() or not data.is_dir() or _files(data) != manifest["outputs"]:
return None
return SealedNodeResult(data, input_sha256, manifest["result_sha256"], payload)
except (OSError, ValueError, TypeError, KeyError):
return None
def seal(
self,
input_identity: dict[str, object],
output_root: Path,
*,
metadata: dict[str, object],
) -> SealedNodeResult:
existing = self.lookup(input_identity)
if existing is not None:
return existing
input_sha256 = canonical_sha256(input_identity)
require_digest(input_sha256)
destination = self.root / input_sha256
if destination.exists() or destination.is_symlink():
raise CompositionError("damaged node cache entry requires explicit repair")
output_root = output_root.absolute()
if output_root.is_symlink() or output_root.resolve() != output_root:
raise CompositionError("node output root must be a real directory")
entries = _files(output_root)
identity = {
"schema_version": NODE_RESULT_SCHEMA,
"input_identity": input_identity,
"input_sha256": input_sha256,
"outputs": entries,
"metadata": metadata,
}
result_sha256 = canonical_sha256(identity)
payload = canonical_bytes({**identity, "result_sha256": result_sha256})
if len(payload) > _MAX_MANIFEST_BYTES:
raise CompositionError("node manifest exceeds the size limit")
stage = Path(tempfile.mkdtemp(prefix=".node-seal-", dir=self.root))
try:
data = stage / "data"
data.mkdir(mode=0o700)
for entry in entries:
relative = PurePosixPath(cast(str, entry["path"]))
target = data.joinpath(*relative.parts)
target.parent.mkdir(parents=True, exist_ok=True)
# Copy: chmod on a hardlink would mutate the producer's files,
# and a surviving producer could corrupt a supposedly sealed entry.
with (output_root / relative).open("rb") as source, target.open("xb") as out:
shutil.copyfileobj(source, out, 1024 * 1024)
out.flush()
os.fsync(out.fileno())
target.chmod(0o444)
if _files(data) != entries:
raise CompositionError("node output changed while sealing")
with (stage / "manifest.json").open("xb") as out:
out.write(payload)
out.flush()
os.fsync(out.fileno())
(stage / "manifest.json").chmod(0o444)
try:
stage.rename(destination)
except OSError:
# Another process may have atomically won this exact identity.
winner = self.lookup(input_identity)
if winner is None or winner.result_sha256 != result_sha256:
raise CompositionError("node seal conflicts with an existing result") from None
return winner
_fsync_directory(self.root)
return SealedNodeResult(destination / "data", input_sha256, result_sha256, payload)
finally:
if stage.exists():
shutil.rmtree(stage)
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
+252
View File
@@ -0,0 +1,252 @@
"""Result contract for one independently selected Observatory AI module."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Final, cast
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
PortableResultArtifact,
PortableResultPackageIntegrityError,
PortableResultValidationContext,
)
from k1link.observatory.portable_run_definitions import canonical_sha256
MODULAR_RESULT_SCHEMA: Final = "missioncore.recorded-ai-layer-review/v1"
MODULAR_RESULT_KIND: Final = "recorded-ai-layer-review"
MODULAR_RESULT_CONTRACT_SHA256: Final = (
"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305"
)
MODULE_BY_SETUP: Final = {
"ai-segmentation-ddrnet-v1": "ddrnet",
"ai-segmentation-eomt-v1": "eomt",
"ai-detection-rf-detr-v1": "rf-detr",
"ai-range-object-distance-v1": "object-distance",
}
_EXPECTED_ARTIFACT_ROLES: Final = {
"ddrnet": {
"ddrnet-decode-repair",
"ddrnet-result-document",
"ddrnet-semantic-mask-archive",
},
"eomt": {
"eomt-decode-repair",
"eomt-panoptic-frame-metadata",
"eomt-gpu-telemetry",
"eomt-panoptic-mask-archive",
"eomt-overlay-video",
"eomt-result-document",
"eomt-run-report",
"eomt-source-frame-manifest",
},
"rf-detr": {"rf-detr-frame-detections", "rf-detr-result-document"},
"object-distance": {
"object-distance-frame-observations",
"object-distance-result-document",
"rf-detr-frame-detections",
"rf-detr-result-document",
},
}
def validate_modular_result(context: PortableResultValidationContext) -> None:
"""Re-bind a packaged module result to its exact job and artifacts."""
definition = context.definition
module_id = MODULE_BY_SETUP.get(definition.setup_id)
document = context.result_document
if (
module_id is None
or definition.result_contract.contract_sha256 != MODULAR_RESULT_CONTRACT_SHA256
or definition.result_contract.result_schema != MODULAR_RESULT_SCHEMA
or definition.result_contract.result_kind != MODULAR_RESULT_KIND
or set(document)
!= {
"schema_version",
"result_id",
"result_kind",
"identity",
"identity_sha256",
"source",
"module",
"artifacts",
"authority",
}
or document.get("schema_version") != MODULAR_RESULT_SCHEMA
or document.get("result_kind") != MODULAR_RESULT_KIND
or document.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
raise PortableResultPackageIntegrityError("AI-layer result envelope changed")
identity = _object(document.get("identity"), "AI-layer identity")
identity_sha = document.get("identity_sha256")
if (
not isinstance(identity_sha, str)
or canonical_sha256(identity) != identity_sha
or document.get("result_id") != f"ai-layer-{module_id}-{identity_sha}"
or document.get("result_id") != context.job.result_id
):
raise PortableResultPackageIntegrityError("AI-layer result identity changed")
source = _object(document.get("source"), "AI-layer source")
module = _object(document.get("module"), "AI-layer module")
expected_source = {
"session_id": context.job.source_session_id,
"catalog_sha256": context.job.source_catalog_sha256,
"bundle_sha256": context.job.source_bundle_sha256,
"capability_manifest_sha256": context.job.source_capability_manifest_sha256,
}
if (
any(source.get(key) != value for key, value in expected_source.items())
or module.get("module_id") != module_id
or module.get("definition_sha256") != definition.definition_sha256
or identity
!= {
"job_identity_sha256": context.job.identity_sha256,
"source_bundle_sha256": context.job.source_bundle_sha256,
"definition_sha256": definition.definition_sha256,
"module_id": module_id,
"component_result_sha256": module.get("component_result_sha256"),
}
):
raise PortableResultPackageIntegrityError("AI-layer provenance changed")
declared_value = document.get("artifacts")
if not isinstance(declared_value, list):
raise PortableResultPackageIntegrityError("AI-layer artifacts are invalid")
declared = tuple(_artifact(row) for row in declared_value)
if (
tuple(item.role for item in declared) != tuple(sorted(item.role for item in declared))
or {item.role for item in declared} != _EXPECTED_ARTIFACT_ROLES[module_id]
):
raise PortableResultPackageIntegrityError("AI-layer artifacts are not canonical")
packaged = {item.role: item for item in context.manifest.artifacts}
if set(packaged) != {"result-document", *(item.role for item in declared)}:
raise PortableResultPackageIntegrityError("AI-layer package artifacts changed")
for artifact in declared:
path = context.artifact_paths.get(artifact.role)
if (
packaged.get(artifact.role) != artifact
or path is None
or _sha256(path) != artifact.sha256
):
raise PortableResultPackageIntegrityError("AI-layer artifact content changed")
result_role = f"{module_id}-result-document"
component_path = context.artifact_paths.get(result_role)
if component_path is None or _sha256(component_path) != module.get("component_result_sha256"):
raise PortableResultPackageIntegrityError("AI-layer component result changed")
try:
component = json.loads(component_path.read_bytes())
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise PortableResultPackageIntegrityError("AI-layer component result is invalid") from exc
_validate_component(module_id, _object(component, "component result"), source, packaged)
if module_id == "object-distance":
dependency_path = context.artifact_paths.get("rf-detr-result-document")
if dependency_path is None:
raise PortableResultPackageIntegrityError("RF-DETR dependency result is missing")
try:
dependency = _object(
json.loads(dependency_path.read_bytes()), "RF-DETR dependency result"
)
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise PortableResultPackageIntegrityError(
"RF-DETR dependency result is invalid"
) from exc
_validate_rf_detr(dependency, source, packaged)
def _validate_component(
module_id: str,
result: dict[str, object],
source: dict[str, object],
packaged: dict[str, PortableResultArtifact],
) -> None:
frame_count = source.get("frame_count")
camera_input = source.get("camera_input_sha256")
if not isinstance(frame_count, int) or isinstance(frame_count, bool) or frame_count < 1:
raise PortableResultPackageIntegrityError("AI-layer frame count is invalid")
if module_id == "eomt":
rows = result.get("artifacts")
if not isinstance(rows, list):
raise PortableResultPackageIntegrityError("EoMT artifacts are invalid")
archive = next(
(
row
for row in cast(list[object], rows)
if isinstance(row, dict) and row.get("kind") == "panoptic-mask-archive"
),
None,
)
if (
result.get("schema_version") != "missioncore.recorded-perception-result/v2"
or result.get("session_id") != source.get("session_id")
or result.get("input_sha256") != camera_input
or result.get("frames_processed") != frame_count
or not isinstance(archive, dict)
or archive.get("sha256") != packaged["eomt-panoptic-mask-archive"].sha256
):
raise PortableResultPackageIntegrityError("EoMT result binding changed")
return
if module_id == "rf-detr":
_validate_rf_detr(result, source, packaged)
return
if module_id == "object-distance":
if (
result.get("schema_version")
!= "missioncore.observatory-ai-module-object-distance-result/v1"
or result.get("module_id") != "object-distance"
or result.get("source_session_id") != source.get("session_id")
or result.get("frame_count") != frame_count
or result.get("object_distances_sha256")
!= packaged["object-distance-frame-observations"].sha256
or result.get("range_estimator") != "median-camera-z-of-owned-current-points/v1"
):
raise PortableResultPackageIntegrityError("object-distance result binding changed")
return
semantics = _object(result.get("video_semantics"), "DDRNet semantics")
archive = _object(semantics.get("mask_archive"), "DDRNet mask archive")
source_row = _object(result.get("source"), "DDRNet source")
if (
result.get("schema_version") != "missioncore.lab-v1-goose-vegetation-run/v1"
or source_row.get("input_count") != frame_count
or archive.get("sha256") != packaged["ddrnet-semantic-mask-archive"].sha256
or archive.get("frame_count") != frame_count
):
raise PortableResultPackageIntegrityError("DDRNet result binding changed")
def _validate_rf_detr(
result: dict[str, object],
source: dict[str, object],
packaged: dict[str, PortableResultArtifact],
) -> None:
source_row = _object(result.get("source"), "RF-DETR source")
if (
result.get("schema_version") != "missioncore.observatory-ai-module-rf-detr-result/v1"
or result.get("module_id") != "rf-detr"
or source_row.get("session_id") != source.get("session_id")
or result.get("frame_count") != source.get("frame_count")
or result.get("detections_sha256") != packaged["rf-detr-frame-detections"].sha256
):
raise PortableResultPackageIntegrityError("RF-DETR result binding changed")
def _artifact(value: object) -> PortableResultArtifact:
row = _object(value, "AI-layer artifact")
if set(row) != {"role", "relative_path", "media_type", "byte_length", "sha256"}:
raise PortableResultPackageIntegrityError("AI-layer artifact fields changed")
try:
return PortableResultArtifact(**row) # type: ignore[arg-type]
except (TypeError, ValueError) as exc:
raise PortableResultPackageIntegrityError("AI-layer artifact is invalid") from exc
def _object(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise PortableResultPackageIntegrityError(f"{label} is invalid")
return cast(dict[str, object], value)
def _sha256(path: Path) -> str:
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
@@ -52,12 +52,8 @@ from k1link.observatory.source_admission import (
)
from k1link.observatory.worker_agent import SealedObservatoryRecordedJob
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-source/v1"
)
PORTABLE_LAB_V1_PLAN_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-orchestration-plan/v1"
)
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = "missioncore.observatory-portable-lab-v1-source/v1"
PORTABLE_LAB_V1_PLAN_SCHEMA: Final = "missioncore.observatory-portable-lab-v1-orchestration-plan/v1"
PORTABLE_LAB_V1_PLAN_IDENTITY_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-orchestration-plan-identity/v1"
)
@@ -74,9 +70,7 @@ PORTABLE_LAB_V1_RELEASE_IDENTITY_SCHEMA: Final = (
PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-executor-seal/v2"
)
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = (
"missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
)
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
PORTABLE_LAB_V1_DDRNET_EFFECTIVE_CONFIG_SCHEMA: Final = (
"missioncore.lab-v1-goose-vegetation-benchmark/v1"
)
@@ -97,9 +91,7 @@ _DDRNET_CANDIDATE_KEY: Final = "ddrnet"
_DDRNET_CHECKPOINT_SHA256: Final = (
"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"
)
_GOOSE_MAPPING_SHA256: Final = (
"88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
)
_GOOSE_MAPPING_SHA256: Final = "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
@@ -214,9 +206,7 @@ class PortableLabV1SourceInput:
"session_id": self.source_session_id,
"catalog_sha256": self.source_catalog_sha256,
"bundle_sha256": self.source_bundle_sha256,
"capability_manifest_sha256": (
self.source_capability_manifest_sha256
),
"capability_manifest_sha256": (self.source_capability_manifest_sha256),
"adapter_sha256": self.source_adapter_sha256,
},
"camera_compute_job": {
@@ -341,13 +331,8 @@ def materialize_lab_v1_source_input(
)
if hashlib.sha256(source_bundle_bytes).hexdigest() != job.source_bundle_sha256:
raise PortableLabV1SourceError("source bundle digest differs from the sealed job")
if (
hashlib.sha256(capability_bytes).hexdigest()
!= job.source_capability_manifest_sha256
):
raise PortableLabV1SourceError(
"source capability digest differs from the sealed job"
)
if hashlib.sha256(capability_bytes).hexdigest() != job.source_capability_manifest_sha256:
raise PortableLabV1SourceError("source capability digest differs from the sealed job")
_validate_source_documents(
source_bundle=source_bundle,
capability=capability,
@@ -484,9 +469,7 @@ class PortableLabV1ReleaseCandidate:
_digest(self.executor_image_sha256, "release executor image sha256")
ids = tuple(asset.asset_id for asset in self.assets)
if ids != tuple(sorted(ids)) or len(ids) != len(set(ids)):
raise PortableLabV1ReleaseError(
"release assets must be unique and canonically ordered"
)
raise PortableLabV1ReleaseError("release assets must be unique and canonically ordered")
if not self.phases or len(self.phases) != len(set(self.phases)):
raise PortableLabV1ReleaseError("release phases are invalid")
for phase in self.phases:
@@ -561,9 +544,7 @@ class PortableLabV1ReleaseCandidate:
return cls(
release_id=_string(document["release_id"], "release id"),
setup_id=_string(document["setup_id"], "release setup id"),
definition_id=_string(
document["definition_id"], "release definition id"
),
definition_id=_string(document["definition_id"], "release definition id"),
definition_version=_positive_int(
document["definition_version"], "release definition version"
),
@@ -579,9 +560,7 @@ class PortableLabV1ReleaseCandidate:
assets=assets,
phases=phases,
declared_blockers=blockers,
candidate_sha256=_string(
document["candidate_sha256"], "release candidate sha256"
),
candidate_sha256=_string(document["candidate_sha256"], "release candidate sha256"),
repository_root=_real_directory(repository_root, "repository root"),
)
@@ -606,14 +585,10 @@ class PortableLabV1ReleaseCandidate:
definition.setup_id != self.setup_id
or definition.definition_id != self.definition_id
or definition.version != self.definition_version
or definition.executable_contract_sha256
!= self.definition_contract_sha256
or definition.result_contract.contract_sha256
!= self.result_contract_sha256
or definition.executable_contract_sha256 != self.definition_contract_sha256
or definition.result_contract.contract_sha256 != self.result_contract_sha256
):
raise PortableLabV1ReleaseError(
"release candidate belongs to another RunDefinition"
)
raise PortableLabV1ReleaseError("release candidate belongs to another RunDefinition")
def inspect(
self,
@@ -660,9 +635,7 @@ class PortableLabV1ReleaseCandidate:
or self.executor_image_sha256 is None
or len(inspection.matched_assets) != len(self.assets)
):
raise PortableLabV1ReleaseError(
"portable LAB V1 executor candidate is not sealable"
)
raise PortableLabV1ReleaseError("portable LAB V1 executor candidate is not sealable")
identity = {
"schema_version": PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA,
"release_id": self.release_id,
@@ -725,9 +698,8 @@ class PortableLabV1PlanPhase:
def __post_init__(self) -> None:
_pattern(self.phase_id, _IDENTIFIER, "plan phase id")
if (
not self.component_sha256s
or self.component_sha256s != tuple(sorted(self.component_sha256s))
if not self.component_sha256s or self.component_sha256s != tuple(
sorted(self.component_sha256s)
):
raise PortableLabV1PlanError("plan component identities are not canonical")
for digest_value in self.component_sha256s:
@@ -787,10 +759,8 @@ class PortableLabV1OrchestrationPlan:
_digest(value, label)
if (
self.source_input.observatory_job_id != self.observatory_job_id
or self.source_input.observatory_request_sha256
!= self.observatory_request_sha256
or self.source_input.observatory_identity_sha256
!= self.observatory_identity_sha256
or self.source_input.observatory_request_sha256 != self.observatory_request_sha256
or self.source_input.observatory_identity_sha256 != self.observatory_identity_sha256
):
raise PortableLabV1PlanError("plan source belongs to another job")
if self.blockers != tuple(sorted(self.blockers)) or len(self.blockers) != len(
@@ -804,10 +774,7 @@ class PortableLabV1OrchestrationPlan:
"result-v2-assembly",
):
raise PortableLabV1PlanError("combined LAB V1 phase order changed")
if (
canonical_sha256(self.effective_ddrnet_config)
!= self.effective_ddrnet_config_sha256
):
if canonical_sha256(self.effective_ddrnet_config) != self.effective_ddrnet_config_sha256:
raise PortableLabV1PlanError("effective DDRNet config digest changed")
if canonical_sha256(self.identity_document()) != self.plan_sha256:
raise PortableLabV1PlanError("orchestration plan identity changed")
@@ -830,8 +797,7 @@ class PortableLabV1OrchestrationPlan:
raise PortableLabV1PlanError("release inspection belongs to another candidate")
expected_asset_ids = tuple(asset.asset_id for asset in release.assets)
if release_inspection.ready and (
release_inspection.matched_assets != expected_asset_ids
or release_inspection.blockers
release_inspection.matched_assets != expected_asset_ids or release_inspection.blockers
):
raise PortableLabV1PlanError(
"ready release inspection does not admit every exact asset"
@@ -1147,13 +1113,8 @@ class PortableLabV1ResultAssembly:
roles = tuple(item.role for item in self.artifacts)
if roles != tuple(sorted(roles)) or len(roles) != len(set(roles)):
raise PortableLabV1ResultError("assembled artifacts are not canonical")
result_artifact = tuple(
item for item in self.artifacts if item.role == "result-document"
)
if (
len(result_artifact) != 1
or result_artifact[0].sha256 != self.result_document_sha256
):
result_artifact = tuple(item for item in self.artifacts if item.role == "result-document")
if len(result_artifact) != 1 or result_artifact[0].sha256 != self.result_document_sha256:
raise PortableLabV1ResultError("assembled result document is not bound")
@@ -1298,9 +1259,7 @@ def assemble_lab_v1_result_v2(
"session_id": plan.source_input.source_session_id,
"catalog_sha256": plan.source_input.source_catalog_sha256,
"bundle_sha256": plan.source_input.source_bundle_sha256,
"capability_manifest_sha256": (
plan.source_input.source_capability_manifest_sha256
),
"capability_manifest_sha256": (plan.source_input.source_capability_manifest_sha256),
"camera_input_sha256": plan.source_input.camera_input_sha256,
"frame_count": plan.source_input.frame_count,
"timeline_start_seconds": plan.source_input.timeline_start_seconds,
@@ -1311,9 +1270,7 @@ def assemble_lab_v1_result_v2(
"definition_id": definition.definition_id,
"version": definition.version,
"definition_sha256": definition.definition_sha256,
"result_contract_sha256": (
definition.result_contract.contract_sha256
),
"result_contract_sha256": (definition.result_contract.contract_sha256),
"release_candidate_sha256": plan.release_candidate_sha256,
"plan_sha256": plan.plan_sha256,
},
@@ -1343,9 +1300,7 @@ def assemble_lab_v1_result_v2(
)
final = parent / result_id
if final.exists():
raise PortableLabV1ResultError(
"an assembled result with this identity already exists"
)
raise PortableLabV1ResultError("an assembled result with this identity already exists")
_fsync_tree(staging)
os.replace(staging, final)
_fsync_directory(parent)
@@ -1429,8 +1384,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
definition.setup_id != _EXPECTED_SETUP_ID
or definition.definition_id != _EXPECTED_DEFINITION_ID
or definition.result_contract.result_schema != PORTABLE_LAB_V1_RESULT_SCHEMA
or definition.result_contract.contract_sha256
!= _EXPECTED_RESULT_CONTRACT_SHA256
or definition.result_contract.contract_sha256 != _EXPECTED_RESULT_CONTRACT_SHA256
):
raise PortableLabV1ResultError("LAB V1 validator received another definition")
document = dict(context.result_document)
@@ -1444,8 +1398,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
identity = _object(document["identity"], "portable LAB V1 identity")
if (
canonical_sha256(identity) != document["identity_sha256"]
or document["result_id"]
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
):
raise PortableLabV1ResultError("portable LAB V1 result identity changed")
source = _object(document["source"], "portable LAB V1 source")
@@ -1484,8 +1437,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
source.get("session_id") != context.job.source_session_id
or source.get("catalog_sha256") != context.job.source_catalog_sha256
or source.get("bundle_sha256") != context.job.source_bundle_sha256
or source.get("capability_manifest_sha256")
!= context.job.source_capability_manifest_sha256
or source.get("capability_manifest_sha256") != context.job.source_capability_manifest_sha256
or run_definition.get("setup_id") != definition.setup_id
or run_definition.get("definition_id") != definition.definition_id
or run_definition.get("version") != definition.version
@@ -1499,13 +1451,10 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
raise PortableLabV1ResultError("portable LAB V1 artifacts are not an array")
declared = tuple(_artifact_from_document(row) for row in artifact_rows)
declared_roles = tuple(artifact.role for artifact in declared)
if (
declared_roles != tuple(sorted(declared_roles))
or len(declared_roles) != len(set(declared_roles))
if declared_roles != tuple(sorted(declared_roles)) or len(declared_roles) != len(
set(declared_roles)
):
raise PortableLabV1ResultError(
"portable result artifacts are not canonical"
)
raise PortableLabV1ResultError("portable result artifacts are not canonical")
declared_by_role = {artifact.role: artifact for artifact in declared}
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
if set(package_by_role) != {*declared_by_role, "result-document"}:
@@ -1540,15 +1489,13 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
if (
source_input.identity_sha256 != identity.get("source_input_sha256")
or plan.plan_sha256 != run_definition.get("plan_sha256")
or plan.release_candidate_sha256
!= run_definition.get("release_candidate_sha256")
or plan.release_candidate_sha256 != run_definition.get("release_candidate_sha256")
or plan.observatory_job_id != context.job.job_id
or plan.observatory_request_sha256 != context.job.request_sha256
or plan.observatory_identity_sha256 != context.job.identity_sha256
or source.get("camera_input_sha256") != source_input.camera_input_sha256
or source.get("frame_count") != source_input.frame_count
or source.get("timeline_start_seconds")
!= source_input.timeline_start_seconds
or source.get("timeline_start_seconds") != source_input.timeline_start_seconds
or source.get("timeline_end_seconds") != source_input.timeline_end_seconds
):
raise PortableLabV1ResultError("portable source or plan artifact changed")
@@ -1621,9 +1568,7 @@ def portable_lab_v1_source_input_from_document(
"portable camera compute job",
)
return PortableLabV1SourceInput(
observatory_job_id=_string(
observatory_job["job_id"], "portable source Observatory job id"
),
observatory_job_id=_string(observatory_job["job_id"], "portable source Observatory job id"),
observatory_request_sha256=_string(
observatory_job["request_sha256"],
"portable source Observatory request sha256",
@@ -1633,23 +1578,15 @@ def portable_lab_v1_source_input_from_document(
"portable source Observatory identity sha256",
),
source_session_id=_string(source["session_id"], "portable source session id"),
source_catalog_sha256=_string(
source["catalog_sha256"], "portable source catalog sha256"
),
source_bundle_sha256=_string(
source["bundle_sha256"], "portable source bundle sha256"
),
source_catalog_sha256=_string(source["catalog_sha256"], "portable source catalog sha256"),
source_bundle_sha256=_string(source["bundle_sha256"], "portable source bundle sha256"),
source_capability_manifest_sha256=_string(
source["capability_manifest_sha256"],
"portable source capability sha256",
),
source_adapter_sha256=_string(
source["adapter_sha256"], "portable source adapter sha256"
),
source_adapter_sha256=_string(source["adapter_sha256"], "portable source adapter sha256"),
camera_job_id=_string(camera["job_id"], "portable camera job id"),
camera_input_sha256=_string(
camera["input_sha256"], "portable camera input sha256"
),
camera_input_sha256=_string(camera["input_sha256"], "portable camera input sha256"),
camera_source_id=_string(camera["source_id"], "portable camera source id"),
codec_epoch=_positive_int(camera["codec_epoch"], "portable codec epoch"),
input_byte_length=_positive_int(
@@ -1659,15 +1596,11 @@ def portable_lab_v1_source_input_from_document(
timeline_start_seconds=_finite_float(
camera["timeline_start_seconds"], "portable timeline start"
),
timeline_end_seconds=_finite_float(
camera["timeline_end_seconds"], "portable timeline end"
),
timeline_end_seconds=_finite_float(camera["timeline_end_seconds"], "portable timeline end"),
camera_generation_sha256=_string(
camera["generation_sha256"], "portable camera generation sha256"
),
calibration_sha256=_string(
camera["calibration_sha256"], "portable calibration sha256"
),
calibration_sha256=_string(camera["calibration_sha256"], "portable calibration sha256"),
)
@@ -1758,9 +1691,7 @@ def portable_lab_v1_orchestration_plan_from_document(
raise PortableLabV1ResultError("portable plan admission changed")
phases = tuple(_plan_phase_from_document(value) for value in phases_value)
plan = PortableLabV1OrchestrationPlan(
observatory_job_id=_string(
observatory_job["job_id"], "portable plan Observatory job id"
),
observatory_job_id=_string(observatory_job["job_id"], "portable plan Observatory job id"),
observatory_request_sha256=_string(
observatory_job["request_sha256"],
"portable plan Observatory request sha256",
@@ -1770,9 +1701,7 @@ def portable_lab_v1_orchestration_plan_from_document(
"portable plan Observatory identity sha256",
),
setup_id=_string(run_definition["setup_id"], "portable plan setup id"),
definition_id=_string(
run_definition["definition_id"], "portable plan definition id"
),
definition_id=_string(run_definition["definition_id"], "portable plan definition id"),
definition_version=_positive_int(
run_definition["version"], "portable plan definition version"
),
@@ -1817,8 +1746,7 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
input_values = row["input_roles"]
output_values = row["output_roles"]
if not all(
isinstance(values, list)
for values in (component_values, input_values, output_values)
isinstance(values, list) for values in (component_values, input_values, output_values)
):
raise PortableLabV1ResultError("portable plan phase arrays changed")
return PortableLabV1PlanPhase(
@@ -1828,12 +1756,10 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
for item in cast(list[object], component_values)
),
input_roles=tuple(
_string(item, "portable plan input role")
for item in cast(list[object], input_values)
_string(item, "portable plan input role") for item in cast(list[object], input_values)
),
output_roles=tuple(
_string(item, "portable plan output role")
for item in cast(list[object], output_values)
_string(item, "portable plan output role") for item in cast(list[object], output_values)
),
)
@@ -1859,8 +1785,7 @@ def _validate_source_documents(
source_bundle.get("source_session_id") != job.source_session_id
or capability.get("source_session_id") != job.source_session_id
or camera_job.session_id != job.source_session_id
or source_bundle.get("source_catalog_sha256")
!= job.source_catalog_sha256
or source_bundle.get("source_catalog_sha256") != job.source_catalog_sha256
or capability.get("source_catalog_sha256") != job.source_catalog_sha256
or capability.get("source_bundle_sha256") != job.source_bundle_sha256
or capability.get("source_adapter_sha256") != job.source_adapter_sha256
@@ -1897,8 +1822,7 @@ def _validate_source_documents(
raise PortableLabV1SourceError("source camera segment count changed")
if (
video.get("source_id") != requirements.camera_source_id
or video.get("semantic_channel_id")
!= requirements.camera_semantic_channel_id
or video.get("semantic_channel_id") != requirements.camera_semantic_channel_id
or video.get("seekable") is not True
or camera_job.source_id != camera.get("public_source_id")
or camera_job.codec_epoch != epoch.get("ordinal")
@@ -1934,18 +1858,19 @@ def _validate_source_documents(
if not isinstance(files, list):
raise PortableLabV1SourceError("camera compute job file set is invalid")
file_rows = {
PurePosixPath(_string(_object(row, "camera file").get("path"), "camera file path")).name:
_object(row, "camera file")
PurePosixPath(
_string(_object(row, "camera file").get("path"), "camera file path")
).name: _object(row, "camera file")
for row in files
if PurePosixPath(
_string(_object(row, "camera file").get("path"), "camera file path")
).parent.name
in (f"epoch-{camera_job.codec_epoch}", "segments")
}
if (
init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get("sha256")
or init.get("byte_length")
!= _object(file_rows.get("init.mp4"), "camera init file").get("byte_length")
if init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get(
"sha256"
) or init.get("byte_length") != _object(file_rows.get("init.mp4"), "camera init file").get(
"byte_length"
):
raise PortableLabV1SourceError("camera init differs from the admitted source")
for index, row in enumerate(segments, start=1):
@@ -1964,11 +1889,7 @@ def _verify_definition_and_job(
job: SealedObservatoryRecordedJob,
) -> None:
if (
definition.setup_id != _EXPECTED_SETUP_ID
or definition.definition_id != _EXPECTED_DEFINITION_ID
or definition.result_contract.contract_sha256
!= _EXPECTED_RESULT_CONTRACT_SHA256
or job.setup_id != definition.setup_id
job.setup_id != definition.setup_id
or job.definition_id != definition.definition_id
or job.definition_version != definition.version
or job.definition_sha256 != definition.definition_sha256
@@ -1977,14 +1898,14 @@ def _verify_definition_and_job(
or job.source_adapter_sha256 != definition.source_adapter.contract_sha256
or job.model_release_ids != definition.learned_models
or job.resource_profile_id != definition.resource_profile.profile_id
or job.executor_identity.model_manifest_sha256
!= definition.model_manifest_sha256
or job.executor_identity.model_manifest_sha256 != definition.model_manifest_sha256
or job.executor_identity.resource_profile_sha256
!= definition.resource_profile.profile_sha256
or job.checkpoint_policy != definition.resource_profile.checkpoint_policy
or job.allowed_checkpoints != definition.resource_profile.allowed_checkpoints
or definition.authority.as_dict() != OBSERVATION_ONLY_AUTHORITY
):
raise PortableLabV1SourceError("job is not the exact portable LAB V1 definition")
raise PortableLabV1SourceError("job is not the exact portable definition")
def _verify_source_and_job(
@@ -2008,8 +1929,7 @@ def _verify_source_and_job(
or source.source_session_id != job.source_session_id
or source.source_catalog_sha256 != job.source_catalog_sha256
or source.source_bundle_sha256 != job.source_bundle_sha256
or source.source_capability_manifest_sha256
!= job.source_capability_manifest_sha256
or source.source_capability_manifest_sha256 != job.source_capability_manifest_sha256
or source.source_adapter_sha256 != job.source_adapter_sha256
or source.calibration_sha256 != requirements.calibration_identity_sha256
):
@@ -2069,8 +1989,7 @@ def _verify_plan_definition(
or plan.definition_id != definition.definition_id
or plan.definition_version != definition.version
or plan.definition_sha256 != definition.definition_sha256
or plan.result_contract_sha256
!= definition.result_contract.contract_sha256
or plan.result_contract_sha256 != definition.result_contract.contract_sha256
):
raise PortableLabV1PlanError("orchestration plan belongs to another definition")
@@ -2092,8 +2011,7 @@ def _validate_eomt_component(
or document.get("source_id") != plan.source_input.camera_source_id
or document.get("codec_epoch") != plan.source_input.codec_epoch
or document.get("timestamp_basis") != "session-time-seconds"
or document.get("timeline_start_seconds")
!= plan.source_input.timeline_start_seconds
or document.get("timeline_start_seconds") != plan.source_input.timeline_start_seconds
or document.get("timeline_end_seconds") != plan.source_input.timeline_end_seconds
or document.get("frames_processed") != plan.source_input.frame_count
or document.get("ground_truth") is not False
@@ -2186,10 +2104,8 @@ def _validate_ddrnet_component(
or semantics.get("center_crop_xyxy") != [100, 0, 700, 600]
or semantics.get("outside_crop_state") != "undefined"
or semantics.get("base_m4_result_id") is not None
or provenance.get("config_sha256")
!= plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256")
!= components["vegetation-mission-policy-v1"].sha256
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
or provenance.get("provider_map_sha256")
!= components["vegetation-provider-label-map-v1"].sha256
or authority
@@ -2290,8 +2206,7 @@ def _validate_assembly(
if (
canonical_sha256(identity) != document["identity_sha256"]
or document["result_id"] != assembly.result_id
or document["result_id"]
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
):
raise PortableLabV1ResultError("assembled result identity changed")
for artifact in assembly.artifacts:
@@ -2450,10 +2365,8 @@ def _validate_published_eomt_component(
or document.get("source_id") != plan.source_input.camera_source_id
or document.get("codec_epoch") != plan.source_input.codec_epoch
or document.get("timestamp_basis") != "session-time-seconds"
or document.get("timeline_start_seconds")
!= plan.source_input.timeline_start_seconds
or document.get("timeline_end_seconds")
!= plan.source_input.timeline_end_seconds
or document.get("timeline_start_seconds") != plan.source_input.timeline_start_seconds
or document.get("timeline_end_seconds") != plan.source_input.timeline_end_seconds
or document.get("frames_processed") != plan.source_input.frame_count
or document.get("ground_truth") is not False
or semantic.get("id") != model.model_id
@@ -2472,9 +2385,7 @@ def _validate_published_eomt_component(
}
if not isinstance(rows, list) or len(rows) != len(expected_kinds):
raise PortableLabV1ResultError("published EoMT artifact set changed")
package_by_role = {
artifact.role: artifact for artifact in context.manifest.artifacts
}
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
observed_kinds: set[str] = set()
for value in rows:
row = _object(value, "published EoMT artifact")
@@ -2482,13 +2393,9 @@ def _validate_published_eomt_component(
if kind not in expected_kinds or kind in observed_kinds:
raise PortableLabV1ResultError("published EoMT artifact roles changed")
observed_kinds.add(kind)
relative = _safe_relative_path(
_string(row.get("path"), "published EoMT artifact path")
)
relative = _safe_relative_path(_string(row.get("path"), "published EoMT artifact path"))
if len(relative.parts) != 1:
raise PortableLabV1ResultError(
"published EoMT artifact path changed"
)
raise PortableLabV1ResultError("published EoMT artifact path changed")
packaged = package_by_role.get(f"eomt-{kind}")
if (
packaged is None
@@ -2496,9 +2403,7 @@ def _validate_published_eomt_component(
or row.get("byte_length") != packaged.byte_length
or row.get("sha256") != packaged.sha256
):
raise PortableLabV1ResultError(
"published EoMT artifact identity changed"
)
raise PortableLabV1ResultError("published EoMT artifact identity changed")
def _validate_published_ddrnet_component(
@@ -2557,10 +2462,8 @@ def _validate_published_ddrnet_component(
or semantics.get("center_crop_xyxy") != [100, 0, 700, 600]
or semantics.get("outside_crop_state") != "undefined"
or semantics.get("base_m4_result_id") is not None
or provenance.get("config_sha256")
!= plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256")
!= components["vegetation-mission-policy-v1"].sha256
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
or provenance.get("provider_map_sha256")
!= components["vegetation-provider-label-map-v1"].sha256
or package_archive is None
@@ -2595,6 +2498,8 @@ def _validate_published_ddrnet_component(
"lab-v1-ravnoves-video-ddrnet-" + canonical_sha256(identity_value)
):
raise PortableLabV1ResultError("published DDRNet result identity changed")
def _model(definition: PortableRunDefinition, release_id: str): # type: ignore[no-untyped-def]
for model in definition.models:
if model.release_id == release_id:
@@ -2644,9 +2549,8 @@ def _release_binding_matches(
if path.is_symlink() or not path.is_file():
return False
return (
(asset.byte_length is None or path.stat().st_size == asset.byte_length)
and _sha256_file(path) == asset.sha256
)
asset.byte_length is None or path.stat().st_size == asset.byte_length
) and _sha256_file(path) == asset.sha256
except OSError:
return False
@@ -94,9 +94,7 @@ _EXPECTED_RESULT_CONTRACT_SHA256: Final = (
"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a"
)
_PORTABLE_CONFIG_ASSET_ID: Final = "ddrnet-portable-config"
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = (
"lab-v1-worker-installation-receipt"
)
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = "lab-v1-worker-installation-receipt"
_MAX_SOURCE_DOCUMENT_BYTES: Final = 8 * 1024 * 1024
_MAX_MATERIALIZATION_MANIFEST_BYTES: Final = 64 * 1024 * 1024
_MAX_SOURCE_MEMBERS: Final = 100_000
@@ -432,6 +430,27 @@ def materialize_lab_v1_source_from_worker_stage(
"""Build a deterministic camera job from only manifested Worker members."""
_verify_definition(definition)
return materialize_recorded_camera_source_from_worker_stage(
worker_stage=worker_stage,
job=job,
definition=definition,
output_parent=output_parent,
)
def materialize_recorded_camera_source_from_worker_stage(
*,
worker_stage: PortableWorkerSourceStage,
job: SealedObservatoryRecordedJob,
definition: PortableRunDefinition,
output_parent: Path,
) -> PortableLabV1MaterializedSource:
"""Build the shared recorded-camera input for any exact installed definition.
The historical LAB entrypoint above retains its setup-specific admission.
New modular packages use this model-neutral source preparation boundary.
"""
if (
worker_stage.source_bundle_sha256 != job.source_bundle_sha256
or worker_stage.source_capability_manifest_sha256 != job.source_capability_manifest_sha256
@@ -574,8 +593,7 @@ def _verify_candidate_release(
or candidate.definition_id != release.definition_id
or candidate.definition_version != release.definition_version
or candidate.definition_sha256 != definition.definition_sha256
or release.definition_contract_sha256
!= definition.executable_contract_sha256
or release.definition_contract_sha256 != definition.executable_contract_sha256
or candidate.result_contract_sha256 != release.result_contract_sha256
or tuple(phase.phase_id for phase in candidate.phases) != PORTABLE_LAB_V1_RUNTIME_PHASES
or executor is None
@@ -0,0 +1,237 @@
"""Project sealed RF-DETR boxes and optional K1 ranges without inference."""
from __future__ import annotations
import hashlib
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import rerun as rr
from k1link.artifact_gateway import CentralArtifactStore
from k1link.observatory.portable_tgs_replay import PortableReplayError, read_json, verified_file
from k1link.perception.contracts import ObjectProposal2D, ObstacleObservation
RESULT_SCHEMA = "missioncore.recorded-ai-layer-review/v1"
RF_RESULT_SCHEMA = "missioncore.observatory-ai-module-rf-detr-result/v1"
RANGE_RESULT_SCHEMA = "missioncore.observatory-ai-module-object-distance-result/v1"
RF_ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
RANGE_ROW_SCHEMA = "missioncore.observatory-ai-module-object-distance-frame/v1"
RENDERER_VERSION = "portable-rf-detr-k1-range-rerun-0.36.3-v1"
_MAX_FRAMES = 250_000
_MAX_ROW_BYTES = 8 * 1024 * 1024
@dataclass(frozen=True)
class ObjectReplayFrame:
session_seconds: float
proposals: tuple[ObjectProposal2D, ...]
ranges_m: dict[str, float | None]
@dataclass(frozen=True)
class ObjectReplayData:
frames: tuple[ObjectReplayFrame, ...]
end_seconds: float
include_ranges: bool
def _artifact(
members: dict[str, dict[str, Any]],
store: CentralArtifactStore,
role: str,
media_type: str,
) -> Path:
member = members.get(role)
if member is None or member.get("media_type") != media_type:
raise PortableReplayError("object replay artifact is missing")
return verified_file(
store.object_path(member["sha256"]), member["sha256"], member["byte_length"]
)
def _rows(path: Path, *, schema: str, count: int) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
with path.open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > _MAX_ROW_BYTES or len(result) >= count:
raise PortableReplayError("object replay rows exceed their bound")
try:
row = json.loads(raw)
except json.JSONDecodeError as exc:
raise PortableReplayError("object replay row is invalid") from exc
if not isinstance(row, dict) or row.get("schema_version") != schema:
raise PortableReplayError("object replay row contract changed")
result.append(row)
if len(result) != count:
raise PortableReplayError("object replay frame count changed")
return result
def _time(value: object) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise PortableReplayError("object replay time is invalid")
number = float(value)
if not math.isfinite(number):
raise PortableReplayError("object replay time is invalid")
return number
def load_object_data(
view: dict[str, Any],
store: CentralArtifactStore,
*,
source_bundle_sha256: str,
starts: list[float],
end_seconds: float,
) -> ObjectReplayData:
doc = view["result_document"]
source = doc.get("source")
module = doc.get("module")
if not isinstance(source, dict) or not isinstance(module, dict):
raise PortableReplayError("object replay result is malformed")
module_id = module.get("module_id")
if (
doc.get("schema_version") != RESULT_SCHEMA
or doc.get("result_id") != view["result_id"]
or module_id not in {"rf-detr", "object-distance"}
or source.get("session_id") != view["source_session_id"]
or source.get("bundle_sha256") != source_bundle_sha256
or not 0 < len(starts) <= _MAX_FRAMES
or source.get("frame_count") != len(starts)
or source.get("timeline_start_seconds") != starts[0]
or source.get("timeline_end_seconds") != end_seconds
or not np.isfinite([*starts, end_seconds]).all()
or np.any(np.diff([*starts, end_seconds]) <= 0)
):
raise PortableReplayError("object result and source clock disagree")
members = {row["role"]: row for row in view["artifacts"]}
if len(members) != len(view["artifacts"]):
raise PortableReplayError("object artifact roles are duplicated")
rf_path = _artifact(members, store, "rf-detr-result-document", "application/json")
rf = read_json(rf_path)
detection_path = _artifact(members, store, "rf-detr-frame-detections", "application/x-ndjson")
if (
rf.get("schema_version") != RF_RESULT_SCHEMA
or rf.get("module_id") != "rf-detr"
or not isinstance(rf.get("source"), dict)
or rf["source"].get("session_id") != view["source_session_id"]
or rf.get("frame_count") != len(starts)
or rf.get("detections_sha256") != members["rf-detr-frame-detections"]["sha256"]
or (
module_id == "rf-detr"
and module.get("component_result_sha256")
!= members["rf-detr-result-document"]["sha256"]
)
):
raise PortableReplayError("RF-DETR result binding changed")
detection_rows = _rows(detection_path, schema=RF_ROW_SCHEMA, count=len(starts))
range_rows: list[dict[str, Any]] | None = None
if module_id == "object-distance":
range_path = _artifact(
members,
store,
"object-distance-frame-observations",
"application/x-ndjson",
)
ranged = read_json(
_artifact(members, store, "object-distance-result-document", "application/json")
)
if (
ranged.get("schema_version") != RANGE_RESULT_SCHEMA
or ranged.get("module_id") != "object-distance"
or ranged.get("source_session_id") != view["source_session_id"]
or ranged.get("frame_count") != len(starts)
or ranged.get("object_distances_sha256")
!= members["object-distance-frame-observations"]["sha256"]
or module.get("component_result_sha256")
!= members["object-distance-result-document"]["sha256"]
):
raise PortableReplayError("object-distance result binding changed")
range_rows = _rows(range_path, schema=RANGE_ROW_SCHEMA, count=len(starts))
frames: list[ObjectReplayFrame] = []
try:
for index, (timestamp, row) in enumerate(zip(starts, detection_rows, strict=True)):
if row.get("frame_index") != index or _time(row.get("session_seconds")) != timestamp:
raise PortableReplayError("RF-DETR frame clock changed")
raw_proposals = row.get("proposals")
if not isinstance(raw_proposals, list):
raise PortableReplayError("RF-DETR proposals are unavailable")
proposals = tuple(ObjectProposal2D.from_dict(value) for value in raw_proposals)
proposal_ids = {item.proposal_id for item in proposals}
if len(proposal_ids) != len(proposals) or any(
item.region.x_max > 800 or item.region.y_max > 600 for item in proposals
):
raise PortableReplayError("RF-DETR proposal geometry changed")
ranges: dict[str, float | None] = {}
if range_rows is not None:
range_row = range_rows[index]
if (
range_row.get("frame_index") != index
or _time(range_row.get("session_seconds")) != timestamp
or not isinstance(range_row.get("observations"), list)
):
raise PortableReplayError("object-distance frame clock changed")
for raw in range_row["observations"]:
observation = ObstacleObservation.from_dict(raw)
distance = (
None
if observation.metric_geometry is None
else observation.metric_geometry.range_m
)
for proposal_id in observation.proposal_ids:
if proposal_id not in proposal_ids or proposal_id in ranges:
raise PortableReplayError("object-distance proposal binding changed")
ranges[proposal_id] = distance
if set(ranges) != proposal_ids:
raise PortableReplayError("object-distance coverage changed")
frames.append(ObjectReplayFrame(timestamp, proposals, ranges))
except (KeyError, TypeError, ValueError) as exc:
if isinstance(exc, PortableReplayError):
raise
raise PortableReplayError("object replay contract changed") from exc
return ObjectReplayData(tuple(frames), end_seconds, range_rows is not None)
def _color(label: str) -> list[int]:
digest = hashlib.sha256(f"mission-core-object-{label}".encode()).digest()
return [80 + digest[channel] % 160 for channel in range(3)] + [255]
def log_objects(recording: rr.RecordingStream, data: ObjectReplayData) -> None:
for frame in data.frames:
recording.set_time(
"session_time",
duration=np.timedelta64(round(frame.session_seconds * 1e9), "ns"),
)
if not frame.proposals:
recording.log("/perception/camera/detections", rr.Clear(recursive=False))
continue
labels: list[str] = []
for proposal in frame.proposals:
label = proposal.semantic_hint or "объект"
confidence = f"{proposal.objectness * 100:.0f}%"
if data.include_ranges:
distance = frame.ranges_m[proposal.proposal_id]
suffix = "дальность н/д" if distance is None else f"{distance:.1f} м"
labels.append(f"{label} · {confidence} · {suffix}")
else:
labels.append(f"{label} · {confidence}")
recording.log(
"/perception/camera/detections",
rr.Boxes2D(
array=[proposal.region.as_tuple() for proposal in frame.proposals],
array_format=rr.Box2DFormat.XYXY,
labels=labels,
colors=[_color(proposal.semantic_hint or "object") for proposal in frame.proposals],
show_labels=True,
),
)
recording.set_time("session_time", duration=np.timedelta64(round(data.end_seconds * 1e9), "ns"))
recording.log("/perception/camera/detections", rr.Clear(recursive=False))
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterator
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -14,6 +14,7 @@ from .portable_artifact_transport import (
from .portable_result_contract import PortableResultPublisherError
from .portable_result_publisher import PortableObservatoryResultPublisher
from .recorded_jobs import (
ObservatoryRecordedJob,
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueError,
)
@@ -55,20 +56,19 @@ class PortablePublicationReconciler:
now = self.clock()
if now.tzinfo is None:
raise ValueError("publication reconciliation clock must be timezone-aware")
candidates = self.queue.pending_publications()[:limit]
examined = 0
published = 0
failed = 0
deferred = 0
exhausted = 0
for job in candidates:
for job in self._candidates():
examined += 1
attempts = job.publication_attempts
if attempts >= self.maximum_attempts:
exhausted += 1
continue
updated_at = _timestamp(job.updated_at_utc)
retry_at = updated_at + timedelta(
seconds=self.retry_delays_seconds[attempts]
)
retry_at = updated_at + timedelta(seconds=self.retry_delays_seconds[attempts])
if now < retry_at:
deferred += 1
continue
@@ -82,24 +82,37 @@ class PortablePublicationReconciler:
with suppress(ObservatoryRecordedQueueError):
self.queue.mark_publication_failed(job.job_id, message=message)
failed += 1
# Bound actual publication work, not the first rows of the outbox.
# Exhausted/backoff entries remain evidence but cannot indefinitely
# hide later ready results behind the same prefix on every tick.
if published + failed >= limit:
break
return PortablePublicationReconciliation(
examined=len(candidates),
examined=examined,
published=published,
failed=failed,
deferred=deferred,
exhausted=exhausted,
)
def _candidates(self) -> Iterator[ObservatoryRecordedJob]:
after: tuple[str, str] | None = None
while True:
page = self.queue.pending_publications(limit=32, after=after)
if not page:
return
yield from page
last = page[-1]
after = (last.created_at_utc, last.job_id)
if len(page) < 32:
return
def _timestamp(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ObservatoryRecordedQueueError(
"recorded publication timestamp is invalid"
) from exc
raise ObservatoryRecordedQueueError("recorded publication timestamp is invalid") from exc
if parsed.tzinfo is None:
raise ObservatoryRecordedQueueError(
"recorded publication timestamp has no timezone"
)
raise ObservatoryRecordedQueueError("recorded publication timestamp has no timezone")
return parsed
@@ -160,6 +160,19 @@ class PortableRecordedQueueBindingService:
source_service = self._source_service(portable)
return self._bind(recorded, source_service.check(source_session_id))
def prepare_check(
self,
*,
source_session_id: str,
setup_id: str,
definition_sha256: str,
) -> PortableRecordedRunPreparation:
"""Prepare missing camera metadata, then seal the usual non-persistent check."""
portable, recorded = self._resolve_definition(setup_id, definition_sha256)
source_service = self._source_service(portable)
return self._bind(recorded, source_service.prepare_check(source_session_id))
def admit(
self,
*,
+243 -9
View File
@@ -25,7 +25,18 @@ from k1link.laboratory.canonical_rerun_overlay import (
canonical_lab_replay,
canonical_recording_id,
)
from k1link.observatory.portable_object_replay import (
RENDERER_VERSION as OBJECT_RENDERER_VERSION,
)
from k1link.observatory.portable_object_replay import load_object_data, log_objects
from k1link.observatory.portable_result_view import PortableResultViewService
from k1link.observatory.portable_semantic_replay import (
RENDERER_VERSION as SEMANTIC_RENDERER_VERSION,
)
from k1link.observatory.portable_semantic_replay import (
load_semantic_data,
log_semantics,
)
from k1link.observatory.portable_tgs_replay import (
PortableReplayError,
load_tgs_data,
@@ -39,7 +50,11 @@ from k1link.sessions.media import RecordedMediaEpoch, RecordedMediaInspector
RENDERER_VERSION = "portable-tgs-camera-rerun-0.36.3-v1"
_SHA = re.compile(r"^[a-f0-9]{64}$")
_RESULT = re.compile(r"^m49-tgs-portable-review-[a-f0-9]{64}$")
_RESULT = re.compile(
r"^(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}$"
)
_COMPOSITION = re.compile(r"^ai-composition-[a-f0-9]{64}$")
_LOCK = threading.Lock()
@@ -65,7 +80,16 @@ class PortableReplayService:
if _RESULT.fullmatch(result_id) is None or _SHA.fullmatch(base_sha) is None:
raise PortableReplayError("unsupported replay identity")
view = cast(dict[str, Any], self.view.read(result_id))
identity = [RENDERER_VERSION, result_id, base_sha, view["artifact_manifest_id"]]
module = view.get("result_document", {}).get("module", {})
module_id = module.get("module_id") if isinstance(module, dict) else None
renderer = (
OBJECT_RENDERER_VERSION
if module_id in {"rf-detr", "object-distance"}
else SEMANTIC_RENDERER_VERSION
if result_id.startswith(("lab-v1-", "ai-layer-"))
else RENDERER_VERSION
)
identity = [renderer, result_id, base_sha, view["artifact_manifest_id"]]
return hashlib.sha256("\0".join(identity).encode()).hexdigest(), view
def cached(self, result_id: str, base_sha: str) -> CanonicalLabReplayArtifact | None:
@@ -141,15 +165,35 @@ class PortableReplayService:
):
raise PortableReplayError("source catalog changed since calculation")
epoch = self._camera(view["source_session_id"], bundle)
data = load_tgs_data(view, self.view.artifacts, source_bundle_sha256=bundle_sha)
starts = [epoch.timeline_start_seconds] + [
epoch.timeline_start_seconds + part.end_time_seconds for part in epoch.segments[:-1]
]
if len(starts) != len(data.rows) or any(
abs(start - row["session_seconds"]) > 1e-8
for start, row in zip(starts, data.rows, strict=True)
):
raise PortableReplayError("camera and costmap anchor clocks disagree")
tgs = None
semantics = None
objects = None
if result_id.startswith("m49-tgs-portable-review-"):
tgs = load_tgs_data(view, self.view.artifacts, source_bundle_sha256=bundle_sha)
if len(starts) != len(tgs.rows) or any(
abs(start - row["session_seconds"]) > 1e-8
for start, row in zip(starts, tgs.rows, strict=True)
):
raise PortableReplayError("camera and costmap anchor clocks disagree")
elif result_id.startswith(("ai-layer-rf-detr-", "ai-layer-object-distance-")):
objects = load_object_data(
view,
self.view.artifacts,
source_bundle_sha256=bundle_sha,
starts=starts,
end_seconds=epoch.timeline_end_seconds,
)
else:
semantics = load_semantic_data(
view,
self.view.artifacts,
source_bundle_sha256=bundle_sha,
starts=starts,
end_seconds=epoch.timeline_end_seconds,
)
recording_id = canonical_recording_id(base[0])
self.cache.mkdir(mode=0o700, parents=True, exist_ok=True)
if self.cache.is_symlink() or shutil.disk_usage(self.cache).free < 3 * 1024**3:
@@ -162,7 +206,12 @@ class PortableReplayService:
try:
recording.set_sinks(rr.FileSink(output, write_footer=True))
_log_video(recording, proxy, epoch)
log_tgs(recording, data, epoch.timeline_end_seconds)
if tgs is not None:
log_tgs(recording, tgs, epoch.timeline_end_seconds)
if semantics is not None:
log_semantics(recording, semantics)
if objects is not None:
log_objects(recording, objects)
recording.flush(timeout_sec=180)
finally:
recording.disconnect()
@@ -188,6 +237,191 @@ class PortableReplayService:
os.replace(staged, self.cache / f"{key}.json")
return artifact
def _composition_key(
self, run_id: str, result_ids: tuple[str, ...], base_sha: str
) -> tuple[str, tuple[dict[str, Any], ...]]:
if _COMPOSITION.fullmatch(run_id) is None or _SHA.fullmatch(base_sha) is None:
raise PortableReplayError("unsupported composition replay identity")
if not result_ids or len(result_ids) != len(set(result_ids)):
raise PortableReplayError("composition replay members are invalid")
views = tuple(cast(dict[str, Any], self.view.read(result_id)) for result_id in result_ids)
source_ids = {view["source_session_id"] for view in views}
if len(source_ids) != 1:
raise PortableReplayError("composition replay members use different sources")
identity = [
"portable-composition-rerun-0.36.3-v1",
run_id,
base_sha,
*[f"{view['result_id']}:{view['artifact_manifest_id']}" for view in views],
]
return hashlib.sha256("\0".join(identity).encode()).hexdigest(), views
def cached_composition(
self, run_id: str, result_ids: tuple[str, ...], base_sha: str
) -> CanonicalLabReplayArtifact | None:
key, _ = self._composition_key(run_id, result_ids, base_sha)
sidecar = self.cache / f"{key}.json"
if not sidecar.exists():
return None
metadata = read_json(sidecar, 8192)
digest = metadata.get("sha256")
name = metadata.get("file")
if (
metadata.get("key") != key
or not isinstance(digest, str)
or _SHA.fullmatch(digest) is None
or not isinstance(name, str)
or re.fullmatch(r"[a-f0-9]{64}\.replay\.rrd", name) is None
):
raise PortableReplayError("composition replay cache identity changed")
path = self.cache / name
if (
path.is_symlink()
or not path.is_file()
or path.stat().st_size != metadata["byte_length"]
):
raise PortableReplayError("composition replay cache length changed")
if not 4 <= metadata["byte_length"] <= 1024 * 1024 * 1024:
raise PortableReplayError("composition replay cache exceeds bounds")
stamp = stat_identity(path.stat())
fingerprint = (path, digest)
if self._verified.get(fingerprint) != stamp:
with path.open("rb") as stream:
if hashlib.file_digest(stream, "sha256").hexdigest() != digest:
raise PortableReplayError("composition replay cache digest changed")
self._verified[fingerprint] = stamp
while len(self._verified) > 32:
self._verified.popitem(last=False)
return CanonicalLabReplayArtifact(path, metadata["byte_length"], digest)
def prepare_composition(
self, run_id: str, result_ids: tuple[str, ...], base_sha: str
) -> CanonicalLabReplayArtifact:
with _LOCK:
cached = self.cached_composition(run_id, result_ids, base_sha)
if cached is not None:
return cached
key, views = self._composition_key(run_id, result_ids, base_sha)
source_session_id = cast(str, views[0]["source_session_id"])
base = self.recording_source(source_session_id)
if base is None or base[1] != base_sha:
raise PortableReplayError("composition source recording is not ready")
recording_id = canonical_recording_id(base[0])
epoch: RecordedMediaEpoch | None = None
tgs_rows: list[Any] = []
semantic_rows: list[Any] = []
object_rows: list[Any] = []
for view in views:
result_id = cast(str, view["result_id"])
binding = self.view.sessions.get_lab_instance(result_id)
if binding is None or binding.source_session_id != source_session_id:
raise PortableReplayError("composition result binding disappeared")
source = binding.provenance["source"]
bundle_sha = source["bundle_sha256"]
if not isinstance(bundle_sha, str) or _SHA.fullmatch(bundle_sha) is None:
raise PortableReplayError("composition source bundle identity is invalid")
bundle_path = (
self.data_dir / PORTABLE_SOURCE_DOCUMENT_DIRECTORY / f"{bundle_sha}.json"
)
bundle = read_json(bundle_path, 32 * 1024 * 1024)
verified_file(bundle_path, bundle_sha, bundle_path.stat().st_size)
if bundle["source_session_id"] != source_session_id:
raise PortableReplayError("composition member belongs to another session")
_, catalog_sha = self.view.sessions.get_session_with_catalog_snapshot(
source_session_id
)
if (
catalog_sha != source["catalog_sha256"]
or catalog_sha != bundle["source_catalog_sha256"]
):
raise PortableReplayError("composition source catalog changed")
member_epoch = self._camera(source_session_id, bundle)
if epoch is None:
epoch = member_epoch
elif (
epoch.timeline_start_seconds != member_epoch.timeline_start_seconds
or epoch.timeline_end_seconds != member_epoch.timeline_end_seconds
or len(epoch.segments) != len(member_epoch.segments)
):
raise PortableReplayError("composition member clocks disagree")
starts = [member_epoch.timeline_start_seconds] + [
member_epoch.timeline_start_seconds + part.end_time_seconds
for part in member_epoch.segments[:-1]
]
if result_id.startswith("m49-tgs-portable-review-"):
data = load_tgs_data(view, self.view.artifacts, source_bundle_sha256=bundle_sha)
if len(starts) != len(data.rows) or any(
abs(start - row["session_seconds"]) > 1e-8
for start, row in zip(starts, data.rows, strict=True)
):
raise PortableReplayError("composition camera and TGS clocks disagree")
tgs_rows.append(data)
elif result_id.startswith(("ai-layer-rf-detr-", "ai-layer-object-distance-")):
object_rows.append(
load_object_data(
view,
self.view.artifacts,
source_bundle_sha256=bundle_sha,
starts=starts,
end_seconds=member_epoch.timeline_end_seconds,
)
)
else:
semantic_rows.append(
load_semantic_data(
view,
self.view.artifacts,
source_bundle_sha256=bundle_sha,
starts=starts,
end_seconds=member_epoch.timeline_end_seconds,
)
)
if epoch is None:
raise PortableReplayError("composition has no replay clock")
self.cache.mkdir(mode=0o700, parents=True, exist_ok=True)
if self.cache.is_symlink() or shutil.disk_usage(self.cache).free < 3 * 1024**3:
raise PortableReplayError("replay cache has insufficient safe space")
with tempfile.TemporaryDirectory(
prefix=".pack-composition-", dir=self.cache
) as temporary:
root = Path(temporary)
proxy = self._video(epoch, root)
output = root / "overlay.rrd"
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
try:
recording.set_sinks(rr.FileSink(output, write_footer=True))
_log_video(recording, proxy, epoch)
for data in tgs_rows:
log_tgs(recording, data, epoch.timeline_end_seconds)
for data in semantic_rows:
log_semantics(recording, data)
for data in object_rows:
log_objects(recording, data)
recording.flush(timeout_sec=180)
finally:
recording.disconnect()
with output.open("rb") as stream:
digest = hashlib.file_digest(stream, "sha256").hexdigest()
artifact = canonical_lab_replay(
base[0],
base_generation_sha256=base_sha,
overlay=CanonicalLabOverlayArtifact(output, output.stat().st_size, digest),
result_id=run_id,
recording_id=recording_id,
cache_root=self.cache,
)
metadata = {
"key": key,
"file": artifact.path.name,
"sha256": artifact.sha256,
"byte_length": artifact.byte_length,
}
staged = self.cache / f".{key}.json"
staged.write_text(json.dumps(metadata, sort_keys=True), encoding="utf-8")
os.chmod(staged, 0o600)
os.replace(staged, self.cache / f"{key}.json")
return artifact
def _camera(self, session_id: str, bundle: dict[str, Any]) -> RecordedMediaEpoch:
camera = bundle["camera"]
artifact = self.view.sessions.get_recorded_media(session_id, camera["artifact_id"])
@@ -0,0 +1,329 @@
"""Project sealed LAB V1 masks, one native frame at a time, without inference.
No archive LAB, model installation, or Worker is consulted. The source camera
clock and the published component identities are the only evidence inputs.
"""
from __future__ import annotations
import hashlib
import io
import json
import re
import tarfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
import numpy as np
import rerun as rr
from PIL import Image
from k1link.artifact_gateway import CentralArtifactStore
from k1link.laboratory.canonical_rerun_overlay import (
_encoded_semantic_png,
_localized_semantic_label,
_semantic_palette,
)
from k1link.observatory.portable_tgs_replay import PortableReplayError, read_json, verified_file
RESULT_SCHEMA = "missioncore.recorded-eomt-ddrnet-review/v2"
RENDERER_VERSION = "portable-eomt-ddrnet-camera-rerun-0.36.3-v1"
# The target labels belong to this exact admitted EoMT preprocessing profile,
# not to the model's raw Cityscapes label order. A new profile needs an adapter.
EOMT_PROFILE_SHA = "ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875"
EOMT_LABELS = (
"outside_valid_fov",
"person",
"bicycle",
"motorcycle",
"car",
"heavy_vehicle",
"building_structure",
"paved_road",
"sidewalk_curb",
"ground_dirt",
"grass_low_vegetation",
"tree_woody_vegetation",
"sky",
"static_obstacle",
"animal",
"other_background",
)
_PNG_BOUND = 1024 * 1024
_MAX_FRAMES = 250_000
@dataclass(frozen=True)
class SemanticReplayData:
times: tuple[float, ...]
end_seconds: float
city_masks: Path | None
vegetation_masks: Path | None
classes: dict[str, list[dict[str, Any]]]
def load_semantic_data(
view: dict[str, Any],
store: CentralArtifactStore,
*,
source_bundle_sha256: str,
starts: list[float],
end_seconds: float,
) -> SemanticReplayData:
doc = view["result_document"]
source = doc["source"]
if (
doc.get("schema_version")
not in {
RESULT_SCHEMA,
"missioncore.recorded-ai-layer-review/v1",
}
or doc.get("result_id") != view["result_id"]
or source.get("session_id") != view["source_session_id"]
or source.get("bundle_sha256") != source_bundle_sha256
or not 0 < len(starts) <= _MAX_FRAMES
or source.get("frame_count") != len(starts)
or source.get("timeline_start_seconds") != starts[0]
or source.get("timeline_end_seconds") != end_seconds
or not np.isfinite([*starts, end_seconds]).all()
or np.any(np.diff([*starts, end_seconds]) <= 0)
):
raise PortableReplayError("semantic result and source clock disagree")
members = {row["role"]: row for row in view["artifacts"]}
if len(members) != len(view["artifacts"]):
raise PortableReplayError("semantic artifact roles are duplicated")
def artifact(role: str, media: str) -> Path:
member = members.get(role)
if member is None:
raise PortableReplayError("semantic artifact is missing")
if member["media_type"] != media:
raise PortableReplayError("semantic artifact media type changed")
return verified_file(
store.object_path(member["sha256"]),
member["sha256"],
member["byte_length"],
)
schema = doc["schema_version"]
module = doc.get("module")
module_id = module.get("module_id") if isinstance(module, dict) else None
include_eomt = schema == RESULT_SCHEMA or module_id == "eomt"
include_ddrnet = schema == RESULT_SCHEMA or module_id == "ddrnet"
if not include_eomt and not include_ddrnet:
raise PortableReplayError("semantic AI module is unsupported")
city_classes: list[dict[str, Any]] = []
city_masks: Path | None = None
if include_eomt:
city = read_json(artifact("eomt-result-document", "application/json"))
if schema == RESULT_SCHEMA:
bound = doc["components"]["eomt"]
component_bound = (
bound["result_id"] == city["result_id"]
and bound["frames_processed"] == len(starts)
and bound["result_document_sha256"] == members["eomt-result-document"]["sha256"]
)
else:
component_bound = (
doc["module"].get("component_result_sha256")
== members["eomt-result-document"]["sha256"]
)
if not component_bound or (
city["identity"]["configuration"].get("profile_sha256") != EOMT_PROFILE_SHA
or city.get("frames_processed") != len(starts)
or city.get("session_id") != view["source_session_id"]
or city.get("input_sha256") != source["camera_input_sha256"]
or city.get("timestamp_basis") != "session-time-seconds"
):
raise PortableReplayError("EoMT target profile or source changed")
for class_id, label in enumerate(EOMT_LABELS):
color = hashlib.sha256(f"mission-core-segment-{class_id}".encode()).digest()
city_classes.append(
{
"class_id": class_id,
"label": label,
"color_rgb": [64 + color[channel] % 176 for channel in range(3)],
}
)
frames = artifact("eomt-panoptic-frame-metadata", "application/x-ndjson")
with frames.open("rb") as stream:
for index, timestamp in enumerate(starts):
line = stream.readline(_PNG_BOUND + 1)
if not line or len(line) > _PNG_BOUND:
raise PortableReplayError("semantic frame metadata is truncated or oversized")
row = json.loads(line)
value = row.get("session_seconds")
if (
row.get("frame_index") != index
or row.get("sequence") != index + 1
or type(value) not in (int, float)
or not np.isfinite(value)
or abs(value - timestamp) > 1e-8
):
raise PortableReplayError("semantic frame clock disagrees with camera")
for category in row["semantic_classes"]:
class_id = category.get("id")
if (
type(class_id) is not int
or not 0 < class_id < len(EOMT_LABELS)
or category.get("label") != EOMT_LABELS[class_id]
):
raise PortableReplayError("EoMT metadata taxonomy changed")
if stream.read(1):
raise PortableReplayError("semantic metadata contains extra frames")
city_masks = artifact("eomt-panoptic-mask-archive", "application/gzip")
vegetation_classes: list[dict[str, Any]] = []
vegetation_masks: Path | None = None
if include_ddrnet:
vegetation = read_json(artifact("ddrnet-result-document", "application/json"))
if schema == RESULT_SCHEMA:
bound = doc["components"]["ddrnet"]
component_bound = (
bound["result_id"] == vegetation["result_id"]
and bound["frames_processed"] == len(starts)
and bound["result_document_sha256"] == members["ddrnet-result-document"]["sha256"]
)
else:
component_bound = (
doc["module"].get("component_result_sha256")
== members["ddrnet-result-document"]["sha256"]
)
semantic = vegetation["video_semantics"]
taxonomy = semantic["taxonomy"]
vegetation_classes = taxonomy["classes"]
archive = semantic["mask_archive"]
packaged = members.get("ddrnet-semantic-mask-archive")
if (
not component_bound
or packaged is None
or taxonomy.get("schema_version") != "missioncore.lab-v1-vegetation-taxonomy/v1"
or not isinstance(vegetation_classes, list)
or {row["class_id"] for row in vegetation_classes} != set(range(64))
or archive.get("frame_count") != len(starts)
or (archive.get("width"), archive.get("height")) != (800, 600)
or archive.get("encoding") != "uint8-class-id-png"
or any(
archive.get(key) != packaged[key] for key in ("sha256", "byte_length", "media_type")
)
):
raise PortableReplayError("DDRNet mask taxonomy or archive binding changed")
_semantic_palette(cast(list[object], vegetation_classes))
for row in vegetation_classes:
if not isinstance(row.get("label"), str) or not row["label"]:
raise PortableReplayError("DDRNet class label is invalid")
vegetation_masks = artifact("ddrnet-semantic-mask-archive", "application/zip")
return SemanticReplayData(
tuple(starts),
end_seconds,
city_masks,
vegetation_masks,
{
**({"city": city_classes} if include_eomt else {}),
**({"vegetation": vegetation_classes} if include_ddrnet else {}),
},
)
def log_semantics(recording: rr.RecordingStream, data: SemanticReplayData) -> None:
try:
_log_semantics(recording, data)
except (tarfile.TarError, zipfile.BadZipFile, EOFError) as exc:
raise PortableReplayError("semantic mask archive is damaged") from exc
def _log_semantics(recording: rr.RecordingStream, data: SemanticReplayData) -> None:
"""No whole-route decoded tensor or extracted archive; release each frame."""
palettes = {
layer: _semantic_palette(cast(list[object], classes))
for layer, classes in data.classes.items()
}
for layer, classes in data.classes.items():
recording.log(
f"/perception/camera/segmentation/{layer}",
rr.AnnotationContext(
[
rr.ClassDescription(
info=rr.AnnotationInfo(
id=row["class_id"],
label=_localized_semantic_label(row["label"]),
color=[*row["color_rgb"], 255],
)
)
for row in classes
]
),
static=True,
)
def frame(layer: str, index: int, payload: bytes) -> None:
with Image.open(io.BytesIO(payload)) as image:
if image.size != (800, 600) or image.mode not in ("L", "P") or image.format != "PNG":
raise PortableReplayError("semantic mask raster changed")
mask = np.asarray(image, dtype=np.uint8)
if int(mask.max()) >= len(data.classes[layer]):
raise PortableReplayError("semantic mask contains an unknown class")
encoded = _encoded_semantic_png(mask, palettes[layer])
recording.set_time(
"session_time",
duration=np.timedelta64(round(data.times[index] * 1e9), "ns"),
)
recording.log(
f"/perception/camera/segmentation/{layer}",
rr.EncodedImage(contents=encoded, media_type="image/png", opacity=0.72, draw_order=1.0),
)
if data.city_masks is not None:
seen: set[int] = set()
root_seen = False
# Stream gzip once. Members may arrive in archive order rather than frame
# order; every log uses its own verified camera timestamp.
with tarfile.open(data.city_masks, "r|gz") as archive:
for member in archive:
if member.isdir() and member.name.rstrip("/") == "semantic-masks":
if root_seen or member.size != 0:
raise PortableReplayError("EoMT mask archive directory is invalid")
root_seen = True
cast(Any, archive).members.clear()
continue
match = re.fullmatch(r"semantic-masks/frame-([0-9]{6})\.png", member.name)
if not match or not member.isfile() or not 0 < member.size <= _PNG_BOUND:
raise PortableReplayError("EoMT mask archive member is unsafe")
index = int(match[1]) - 1
if not 0 <= index < len(data.times) or index in seen:
raise PortableReplayError(
"EoMT mask sequence is duplicated or outside coverage"
)
source = archive.extractfile(member)
if source is None:
raise PortableReplayError("EoMT mask is unavailable")
with source:
payload = source.read(_PNG_BOUND + 1)
if len(payload) != member.size:
raise PortableReplayError("EoMT mask is truncated")
frame("city", index, payload)
seen.add(index)
# tarfile otherwise retains a TarInfo for every frame even in
# streaming mode on Python 3.12. No lookup uses that history.
cast(Any, archive).members.clear()
if len(seen) != len(data.times):
raise PortableReplayError("EoMT mask sequence is incomplete")
if data.vegetation_masks is not None:
with zipfile.ZipFile(data.vegetation_masks) as masks:
names = masks.namelist()
expected = {f"masks/frame-{index + 1:06d}.png" for index in range(len(data.times))}
if len(names) != len(expected) or set(names) != expected:
raise PortableReplayError("DDRNet mask sequence is incomplete or duplicated")
for index in range(len(data.times)):
info = masks.getinfo(f"masks/frame-{index + 1:06d}.png")
if not 0 < info.file_size <= _PNG_BOUND:
raise PortableReplayError("DDRNet mask exceeds the frame bound")
frame("vegetation", index, masks.read(info))
recording.set_time(
"session_time",
duration=np.timedelta64(round(data.end_seconds * 1e9), "ns"),
)
recording.log("/perception/camera/segmentation", rr.Clear(recursive=True))
@@ -36,7 +36,15 @@ PORTABLE_LABORATORY_SETUP_CATALOG_SCHEMA: Final = (
PORTABLE_LAB_V1_SETUP_ID: Final = "lab-v1-eomt-ddrnet-portable-v1"
PORTABLE_LAB_V1_DISPLAY_NAME: Final = "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39"
PORTABLE_M49_SETUP_ID: Final = "m49-tgs-portable-v2"
PORTABLE_M49_DISPLAY_NAME: Final = "M4.9T5 · TRAVEL TGS · CPU-only, без ML"
PORTABLE_M49_DISPLAY_NAME: Final = "TRAVEL TGS"
AI_DDRNET_SETUP_ID: Final = "ai-segmentation-ddrnet-v1"
AI_EOMT_SETUP_ID: Final = "ai-segmentation-eomt-v1"
AI_RF_DETR_SETUP_ID: Final = "ai-detection-rf-detr-v1"
AI_OBJECT_DISTANCE_SETUP_ID: Final = "ai-range-object-distance-v1"
AI_DDRNET_DISPLAY_NAME: Final = "DDRNet-39 · GOOSE"
AI_EOMT_DISPLAY_NAME: Final = "EoMT Large · Cityscapes"
AI_RF_DETR_DISPLAY_NAME: Final = "RF-DETR Large"
AI_OBJECT_DISTANCE_DISPLAY_NAME: Final = "Дистанция до объектов · K1 LiDAR"
_SOURCE_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_OBSERVATION_ONLY_AUTHORITY: Final = {
@@ -63,6 +71,38 @@ _SETUP_PRESENTATION: Final = {
"incompatible": "Запись не соответствует требованиям TRAVEL TGS.",
"executor_unavailable": "Переносимый вычислительный контур M4.9T5 пока недоступен.",
},
AI_DDRNET_SETUP_ID: {
"lab_id": "LAB AI-DDRNET",
"display_name": AI_DDRNET_DISPLAY_NAME,
"description": "Независимая семантическая сегментация DDRNet записанной K1-сессии.",
"compatible": "Запись соответствует требованиям DDRNet.",
"incompatible": "Запись не соответствует требованиям DDRNet.",
"executor_unavailable": "AI-модуль DDRNet на Worker 006 недоступен.",
},
AI_EOMT_SETUP_ID: {
"lab_id": "LAB AI-EOMT",
"display_name": AI_EOMT_DISPLAY_NAME,
"description": "Независимая семантическая сегментация EoMT записанной K1-сессии.",
"compatible": "Запись соответствует требованиям EoMT.",
"incompatible": "Запись не соответствует требованиям EoMT.",
"executor_unavailable": "AI-модуль EoMT на Worker 006 недоступен.",
},
AI_RF_DETR_SETUP_ID: {
"lab_id": "LAB AI-RF-DETR",
"display_name": AI_RF_DETR_DISPLAY_NAME,
"description": "Независимая детекция объектов RF-DETR на записанном видео.",
"compatible": "Запись соответствует требованиям RF-DETR.",
"incompatible": "Запись не соответствует требованиям RF-DETR.",
"executor_unavailable": "AI-модуль RF-DETR на Worker 006 недоступен.",
},
AI_OBJECT_DISTANCE_SETUP_ID: {
"lab_id": "LAB AI-RANGE",
"display_name": AI_OBJECT_DISTANCE_DISPLAY_NAME,
"description": ("RF-DETR и синхронное облако точек для оценки дистанции до объектов."),
"compatible": "Запись содержит видео, облако точек, позу и калибровку.",
"incompatible": "Для дистанции не хватает видео или пространственных данных.",
"executor_unavailable": "AI-модуль дистанции на Worker 006 недоступен.",
},
}
_MODEL_PRESENTATION: Final = {
@@ -21,6 +21,10 @@ from k1link.observatory.m49_portable_result import (
M49_PORTABLE_RESULT_CONTRACT_SHA256,
validate_m49_portable_result,
)
from k1link.observatory.modular_result import (
MODULAR_RESULT_CONTRACT_SHA256,
validate_modular_result,
)
from k1link.observatory.portable_artifact_transport import (
PortableArtifactTransportError,
PortableObservatoryArtifactTransport,
@@ -55,9 +59,7 @@ OBSERVATORY_WORKER_SOURCE_CAS_ROOT_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_
OBSERVATORY_WORKER_RESULT_STAGING_ROOT_ENV: Final = (
"MISSIONCORE_OBSERVATORY_WORKER_RESULT_STAGING_ROOT"
)
OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: Final = (
"MISSIONCORE_OBSERVATORY_WORKER_LOCAL_ENABLED"
)
OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_LOCAL_ENABLED"
class PortableWorkerIntegrationError(RuntimeError):
@@ -162,6 +164,10 @@ _BUILTIN_VALIDATOR_REGISTRATIONS: Final = (
contract_sha256=M49_PORTABLE_RESULT_CONTRACT_SHA256,
validator=validate_m49_portable_result,
),
PortableResultContractValidatorRegistration(
contract_sha256=MODULAR_RESULT_CONTRACT_SHA256,
validator=validate_modular_result,
),
)
_BUILTIN_VALIDATORS_BY_CONTRACT: Final = {
registration.contract_sha256: registration.validator
@@ -172,26 +178,23 @@ _BUILTIN_VALIDATORS_BY_CONTRACT: Final = {
def portable_result_validator_registry(
definitions: PortableRunDefinitionRegistry,
*,
registrations: tuple[PortableResultContractValidatorRegistration, ...]
| None = None,
registrations: tuple[PortableResultContractValidatorRegistration, ...] | None = None,
) -> PortableResultContractValidatorRegistry:
"""Select server-installed validators by exact result-contract identity."""
installed = (
_BUILTIN_VALIDATOR_REGISTRATIONS
if registrations is None
else registrations
)
installed = _BUILTIN_VALIDATOR_REGISTRATIONS if registrations is None else registrations
by_contract = {
registration.contract_sha256: registration
for registration in PortableResultContractValidatorRegistry(installed).registrations
}
selected = tuple(
by_contract[definition.result_contract.contract_sha256]
for definition in definitions.definitions
if definition.result_contract.contract_sha256 in by_contract
)
registry = PortableResultContractValidatorRegistry(selected)
selected: list[PortableResultContractValidatorRegistration] = []
selected_contracts: set[str] = set()
for definition in definitions.definitions:
contract_sha256 = definition.result_contract.contract_sha256
if contract_sha256 in by_contract and contract_sha256 not in selected_contracts:
selected.append(by_contract[contract_sha256])
selected_contracts.add(contract_sha256)
registry = PortableResultContractValidatorRegistry(tuple(selected))
for definition in definitions.definitions:
if definition.executor.ready:
try:
@@ -287,8 +290,7 @@ def build_portable_observatory_worker_integration(
definition.setup_id
for definition in definitions.definitions
if any(
registration.contract_sha256
== definition.result_contract.contract_sha256
registration.contract_sha256 == definition.result_contract.contract_sha256
for registration in validator_registry.registrations
)
),
+81 -66
View File
@@ -575,9 +575,7 @@ class ObservatoryRecordedJob:
self.claim_heartbeat_at_utc,
)
if (self.active_claim_token is None) != (self.active_claimant_id is None):
raise ObservatoryRecordedQueueIntegrityError(
"recorded-job claim ownership is partial"
)
raise ObservatoryRecordedQueueIntegrityError("recorded-job claim ownership is partial")
if self.active_claim_token is None:
if any(value is not None for value in lease_values) or self.claim_renewal_count != 0:
raise ObservatoryRecordedQueueIntegrityError(
@@ -654,9 +652,7 @@ class ObservatoryRecordedJob:
and self.publication_error is None
and self.published_at_utc is None
),
"pending": (
self.publication_error is None and self.published_at_utc is None
),
"pending": (self.publication_error is None and self.published_at_utc is None),
"failed": (
self.publication_attempts >= 1
and self.publication_error is not None
@@ -673,9 +669,7 @@ class ObservatoryRecordedJob:
"recorded-job publication receipt is inconsistent"
)
if self.publication_state != "not-required" and (
self.state != "succeeded"
or self.result_id is None
or self.result_sha256 is None
self.state != "succeeded" or self.result_id is None or self.result_sha256 is None
):
raise ObservatoryRecordedQueueIntegrityError(
"recorded-job publication lifecycle has no execution result"
@@ -854,10 +848,7 @@ class ObservatoryRecordedReconciliationRequest:
_validate_text(self.reason, "reconciliation reason", max_length=1_000)
if self.resource_release_attestation.job_id != self.job_id:
raise ValueError("resource-release attestation is bound to another job")
if (
self.resource_release_attestation.claim_generation
!= self.expected_claim_generation
):
if self.resource_release_attestation.claim_generation != self.expected_claim_generation:
raise ValueError("resource-release attestation is bound to another generation")
@property
@@ -1237,16 +1228,15 @@ class ObservatoryRecordedJobQueue:
if reject_duplicate_computation:
# This check shares the INSERT transaction: two clients with
# different idempotency keys cannot race into duplicate jobs.
# A sealed result awaiting publication is not a reason to run
# the models again. Published-cache validity is a separate gate.
# One sealed recording/profile version is calculated once after
# it has started successfully. Failed attempts remain as audit
# evidence and may be resubmitted as a fresh queue attempt.
duplicate = connection.execute(
"SELECT job_id FROM observatory_recorded_jobs "
"WHERE identity_sha256 = ? AND ("
"state IN ('accepted', 'queued', 'claimed', 'running', 'paused', "
"'preemption-pending', 'reconciliation-required') OR "
"(state = 'succeeded' AND publication_state IN ('pending', 'failed'))) "
"WHERE source_session_id = ? AND setup_id = ? "
"AND definition_sha256 = ? AND state != 'failed' "
"ORDER BY created_at_utc DESC, job_id DESC LIMIT 1",
(identity_sha256,),
(intent.source_session_id, definition.setup_id, definition.definition_sha256),
).fetchone()
if duplicate is not None:
raise ObservatoryRecordedQueueDuplicateError(duplicate["job_id"])
@@ -1361,12 +1351,13 @@ class ObservatoryRecordedJobQueue:
grant_count = connection.execute(
"SELECT COUNT(*) FROM observatory_recorded_claim_grants_v3"
).fetchone()[0]
job_counts = dict(connection.execute(
"SELECT state, COUNT(*) FROM observatory_recorded_jobs GROUP BY state"
).fetchall())
job_counts = dict(
connection.execute(
"SELECT state, COUNT(*) FROM observatory_recorded_jobs GROUP BY state"
).fetchall()
)
live_count = connection.execute(
"SELECT COUNT(*) FROM observatory_live_leases "
"WHERE state IN ('pending', 'active')"
"SELECT COUNT(*) FROM observatory_live_leases WHERE state IN ('pending', 'active')"
).fetchone()[0]
return {
"schema_version": "missioncore.observatory-claim-readiness/v1",
@@ -1622,7 +1613,11 @@ class ObservatoryRecordedJobQueue:
return self._get_job(connection, job_id)
def report_progress(
self, job_id: str, *, claim_token: str, claimant_id: str,
self,
job_id: str,
*,
claim_token: str,
claimant_id: str,
progress: RecordedProgress,
) -> None:
"""Replace one small observation, fenced in the ownership transaction."""
@@ -1665,10 +1660,11 @@ class ObservatoryRecordedJobQueue:
job = self._get_job(connection, job_id)
row = connection.execute(
"SELECT snapshot_json, received_at_utc FROM observatory_recorded_progress "
"WHERE job_id = ?", (job_id,),
"WHERE job_id = ?",
(job_id,),
).fetchone()
progress = None if row is None else RecordedProgress.model_validate_json(
row["snapshot_json"]
progress = (
None if row is None else RecordedProgress.model_validate_json(row["snapshot_json"])
)
if progress is not None and progress.claim_generation != job.claim_generation:
progress = None
@@ -1681,10 +1677,15 @@ class ObservatoryRecordedJobQueue:
"claim_generation": job.claim_generation,
"state": job.state,
"received_at_utc": None if progress is None else row["received_at_utc"],
"age_seconds": None if progress is None else max(0.0, (
_parse_timestamp(self._timestamp(), "clock")
- _parse_timestamp(row["received_at_utc"], "progress receipt")
).total_seconds()),
"age_seconds": None
if progress is None
else max(
0.0,
(
_parse_timestamp(self._timestamp(), "clock")
- _parse_timestamp(row["received_at_utc"], "progress receipt")
).total_seconds(),
),
"progress": None if progress is None else progress.model_dump(mode="json"),
}
@@ -1720,10 +1721,7 @@ class ObservatoryRecordedJobQueue:
with self._transaction() as connection:
job = self._get_job(connection, job_id)
self._require_active_claim(job, claim_token, now=self._timestamp())
if (
job.claim_generation != claim_generation
or job.active_claimant_id != claimant_id
):
if job.claim_generation != claim_generation or job.active_claimant_id != claimant_id:
raise ObservatoryRecordedQueueStaleClaimError(
"recorded-job claim ownership is stale"
)
@@ -1756,8 +1754,7 @@ class ObservatoryRecordedJobQueue:
with self._transaction() as connection:
existing = connection.execute(
"SELECT * FROM observatory_recorded_reconciliations "
"WHERE reconciliation_id = ?",
"SELECT * FROM observatory_recorded_reconciliations WHERE reconciliation_id = ?",
(request.reconciliation_id,),
).fetchone()
if existing is not None:
@@ -2060,9 +2057,7 @@ class ObservatoryRecordedJobQueue:
with self._transaction() as connection:
job = self._get_job(connection, job_id)
if job.state != "succeeded":
raise ObservatoryRecordedQueueConflictError(
"recorded execution has not succeeded"
)
raise ObservatoryRecordedQueueConflictError("recorded execution has not succeeded")
if job.publication_state == "published":
return job
if job.publication_state not in {"pending", "failed"}:
@@ -2080,15 +2075,39 @@ class ObservatoryRecordedJobQueue:
)
return self._get_job(connection, job_id)
def pending_publications(self) -> tuple[ObservatoryRecordedJob, ...]:
"""Return durable outbox entries in deterministic retry order."""
def pending_publications(
self,
*,
limit: int | None = None,
after: tuple[str, str] | None = None,
) -> tuple[ObservatoryRecordedJob, ...]:
"""Read an outbox page without retaining the entire failed-job history.
The cursor uses immutable creation identity, not ``updated_at_utc``:
attempts and concurrent publication cannot move entries across pages.
The no-argument form preserves the existing internal inspection API.
"""
if limit is not None and (type(limit) is not int or not 1 <= limit <= 100):
raise ValueError("publication page limit must be within 1..100")
conditions = ["publication_state IN ('pending', 'failed')"]
parameters: list[object] = []
if after is not None:
if not isinstance(after, tuple) or len(after) != 2:
raise ValueError("publication cursor is invalid")
_validate_timestamp(after[0], "publication cursor timestamp")
_validate_pattern(after[1], _JOB_ID, "publication cursor job id")
conditions.append("(created_at_utc, job_id) > (?, ?)")
parameters.extend(after)
query = (
"SELECT * FROM observatory_recorded_jobs WHERE "
+ " AND ".join(conditions)
+ " ORDER BY created_at_utc, job_id"
)
if limit is not None:
query += " LIMIT ?"
parameters.append(limit)
with self._read_connection() as connection:
rows = connection.execute(
"SELECT * FROM observatory_recorded_jobs "
"WHERE publication_state IN ('pending', 'failed') "
"ORDER BY created_at_utc, job_id"
).fetchall()
rows = connection.execute(query, parameters).fetchall()
return tuple(_job_from_row(row) for row in rows)
def fail(
@@ -2187,8 +2206,12 @@ class ObservatoryRecordedJobQueue:
return tuple(_job_from_row(row) for row in rows)
def published_results(
self, *, source_session_id: str, source_catalog_sha256: str,
setup_id: str, definition_sha256: str,
self,
*,
source_session_id: str,
source_catalog_sha256: str,
setup_id: str,
definition_sha256: str,
) -> tuple[ObservatoryRecordedJob, ...]:
"""Exact cache candidates, never inferred from labels or a truncated job page."""
@@ -2527,11 +2550,10 @@ class ObservatoryRecordedJobQueue:
)
if exact_replay:
return job
if (
job.terminal_claim_token_sha256 != token_sha256
or job.terminal_code
in {"claim-lease-expired", "claim-lease-migration"}
):
if job.terminal_claim_token_sha256 != token_sha256 or job.terminal_code in {
"claim-lease-expired",
"claim-lease-migration",
}:
raise ObservatoryRecordedQueueStaleClaimError(
"recorded-job terminal acknowledgement is stale"
)
@@ -2618,9 +2640,7 @@ class ObservatoryRecordedJobQueue:
or _parse_timestamp(now, "queue timestamp")
>= _parse_timestamp(job.claim_expires_at_utc, "claim expiry")
):
raise ObservatoryRecordedQueueStaleClaimError(
"recorded-job claim lease expired"
)
raise ObservatoryRecordedQueueStaleClaimError("recorded-job claim lease expired")
def _recover_stale_claims(
self,
@@ -3162,10 +3182,7 @@ def _reconciliation_receipt_from_row(
evidence_sha256=row["resource_release_evidence_sha256"],
attested_at_utc=row["resource_release_attested_at_utc"],
)
if (
attestation.attestation_sha256
!= row["resource_release_attestation_sha256"]
):
if attestation.attestation_sha256 != row["resource_release_attestation_sha256"]:
raise ObservatoryRecordedQueueIntegrityError(
"stored resource-release attestation identity changed"
)
@@ -3433,9 +3450,7 @@ def _validate_claim_lease_seconds(value: object) -> None:
if (
not isinstance(value, int)
or isinstance(value, bool)
or not MIN_RECORDED_CLAIM_LEASE_SECONDS
<= value
<= MAX_RECORDED_CLAIM_LEASE_SECONDS
or not MIN_RECORDED_CLAIM_LEASE_SECONDS <= value <= MAX_RECORDED_CLAIM_LEASE_SECONDS
):
raise ValueError("recorded-job claim lease duration is invalid")
+21 -14
View File
@@ -340,6 +340,20 @@ class RecordedK1SourceAdmissionService:
prepare_media=False,
)
def prepare_check(self, source_session_id: str) -> PortableRecordedSourceAdmission:
"""Prepare the recorded-media manifest and return a non-persistent check.
This is the bounded one-click path used when a compatible recording has
not previously been opened in the viewer. Source bundle documents are
still written only by ``admit`` after the returned identity is fenced.
"""
return self._prepare(
source_session_id,
persist=False,
prepare_media=True,
)
def admit(
self,
source_session_id: str,
@@ -606,14 +620,10 @@ class RecordedK1SourceAdmissionService:
catalog_artifact = catalog_artifacts.get(replay_artifact.artifact_id)
exact_metadata_member = (
catalog_artifact is not None
and replay_artifact.artifact_id
== PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
and catalog_artifact.kind
== PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
and replay_artifact.media_type
== PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
and catalog_artifact.media_type
== PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
and replay_artifact.artifact_id == PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
and catalog_artifact.kind == PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
and replay_artifact.media_type == PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
and catalog_artifact.media_type == PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
)
digest_matches_catalog = (
replay_artifact.expected_sha256 == catalog_artifact.sha256
@@ -854,13 +864,10 @@ def _seal_replay_artifact_digests(
catalog_artifact = catalog_artifacts.get(artifact.artifact_id)
if (
catalog_artifact is None
or artifact.artifact_id
!= PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
or catalog_artifact.kind
!= PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
or artifact.artifact_id != PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
or catalog_artifact.kind != PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
or artifact.media_type != PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
or catalog_artifact.media_type
!= PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
or catalog_artifact.media_type != PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
or catalog_artifact.sha256 is not None
or catalog_artifact.integrity_status not in _SEALED_ARTIFACT_STATES
or artifact.file_byte_length != catalog_artifact.byte_length

Some files were not shown because too many files have changed in this diff Show More