- Будут удалены только каталожная проекция {deleteTarget.label}
+ Будут удалены только каталожная проекция {deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}
{" "}и её отображаемое название в Обсерватории.
@@ -838,6 +1183,7 @@ export function ObservatoryWorkspace({
onClose={() => {
if (mutationPending === "delete") return;
setDeleteTarget(null);
+ setCompositionDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
diff --git a/apps/control-station/test/applicationArchitecture.test.mjs b/apps/control-station/test/applicationArchitecture.test.mjs
index 9508bf3..359ef60 100644
--- a/apps/control-station/test/applicationArchitecture.test.mjs
+++ b/apps/control-station/test/applicationArchitecture.test.mjs
@@ -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(/\(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, / {
});
});
-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);
});
diff --git a/apps/control-station/test/labViewProfile.test.mjs b/apps/control-station/test/labViewProfile.test.mjs
new file mode 100644
index 0000000..250c2be
--- /dev/null
+++ b/apps/control-station/test/labViewProfile.test.mjs
@@ -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",
+ },
+ );
+});
diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs
index dd96a18..d27e302 100644
--- a/apps/control-station/test/m4ReplayThreat.test.mjs
+++ b/apps/control-station/test/m4ReplayThreat.test.mjs
@@ -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\}/);
diff --git a/apps/control-station/test/observationSources.test.mjs b/apps/control-station/test/observationSources.test.mjs
index 2ec910d..bf849be 100644
--- a/apps/control-station/test/observationSources.test.mjs
+++ b/apps/control-station/test/observationSources.test.mjs
@@ -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(
diff --git a/apps/control-station/test/observatoryCatalog.test.mjs b/apps/control-station/test/observatoryCatalog.test.mjs
index d9d1530..225919a 100644
--- a/apps/control-station/test/observatoryCatalog.test.mjs
+++ b/apps/control-station/test/observatoryCatalog.test.mjs
@@ -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", () => {
diff --git a/apps/control-station/test/observatoryRecordedProgress.test.mjs b/apps/control-station/test/observatoryRecordedProgress.test.mjs
index e12b040..e48e4e1 100644
--- a/apps/control-station/test/observatoryRecordedProgress.test.mjs
+++ b/apps/control-station/test/observatoryRecordedProgress.test.mjs
@@ -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) => {
diff --git a/apps/control-station/test/observatoryWorkspace.test.mjs b/apps/control-station/test/observatoryWorkspace.test.mjs
index 695e20a..a1d8004 100644
--- a/apps/control-station/test/observatoryWorkspace.test.mjs
+++ b/apps/control-station/test/observatoryWorkspace.test.mjs
@@ -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]*= 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,
- /\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
+ /\{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]*Документ результата<\/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, /\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
+ assert.match(
+ workspace,
+ /\{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, /[\s\S]*Сохранить/);
assert.match(workspace, /= 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,
+ /[\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('\{moduleLabel\}<\/strong>/);
+ assert.match(workspace, /
\{run\.displayName \?\? run\.presentation\.configurationLabel\}<\/strong>/);
+ assert.doesNotMatch(workspace, /\{selectedSession\.source\.label\} ·/);
+ assert.match(workspace, /type EvidenceSortDirection = "newest" \| "oldest"/);
+ assert.match(workspace, /const presentedRows = useMemo/);
+ 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, //);
+ 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\(\)/,
- );
});
diff --git a/apps/control-station/test/recordedRerunCameraJournal.test.mjs b/apps/control-station/test/recordedRerunCameraJournal.test.mjs
new file mode 100644
index 0000000..d36b03b
--- /dev/null
+++ b/apps/control-station/test/recordedRerunCameraJournal.test.mjs
@@ -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();
+});
diff --git a/apps/control-station/test/recordedViewerResourceLifecycle.test.mjs b/apps/control-station/test/recordedViewerResourceLifecycle.test.mjs
new file mode 100644
index 0000000..e938af5
--- /dev/null
+++ b/apps/control-station/test/recordedViewerResourceLifecycle.test.mjs
@@ -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/);
+});
diff --git a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
index 98fece4..8d652a4 100644
--- a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
+++ b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
@@ -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);
diff --git a/apps/control-station/test/semanticEvidencePrimitives.test.mjs b/apps/control-station/test/semanticEvidencePrimitives.test.mjs
index fe9fc8a..3339949 100644
--- a/apps/control-station/test/semanticEvidencePrimitives.test.mjs
+++ b/apps/control-station/test/semanticEvidencePrimitives.test.mjs
@@ -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\}/);
});
diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs
index 13005ad..77e0e27 100644
--- a/apps/control-station/test/vegetationShadow.test.mjs
+++ b/apps/control-station/test/vegetationShadow.test.mjs
@@ -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, / {
},
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",
diff --git a/config/observatory-ai-modules.json b/config/observatory-ai-modules.json
new file mode 100644
index 0000000..bfaa30d
--- /dev/null
+++ b/config/observatory-ai-modules.json
@@ -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"
+ }
+ ]
+}
diff --git a/config/observatory-domain-ontology.json b/config/observatory-domain-ontology.json
new file mode 100644
index 0000000..0ff4700
--- /dev/null
+++ b/config/observatory-domain-ontology.json
@@ -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
+ }
+}
diff --git a/config/observatory-portable-run-definitions.json b/config/observatory-portable-run-definitions.json
index 2f7df31..36ecee4 100644
--- a/config/observatory-portable-run-definitions.json
+++ b/config/observatory-portable-run-definitions.json
@@ -1 +1 @@
-{"definitions":[{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"ddrnet-portable-runtime-config-v2","kind":"configuration","sha256":"c0ff657dadc86607d77d232e84d041fbf2d8b63e86d02319e9cd607220d00f21"},{"component_id":"eomt-recorded-dependency-set-v1","kind":"dependency-set","sha256":"4eb1f8d33236806e74f9e5bb96b7dce2ac37623dc39b2184be2aa8d7d00e983e"},{"component_id":"eomt-recorded-orchestrator-v1","kind":"orchestrator","sha256":"d3e9435939444ab35b27a744ac314e289ebd66a13fa56e3d59f121e088d22774"},{"component_id":"eomt-recorded-profile-v1","kind":"profile","sha256":"ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875"},{"component_id":"eomt-recorded-runner-v1","kind":"runner","sha256":"1e64869de48d10f1531c742e6067c4c3ae2a709c5eb0d770d1fab74b4a2431ff"},{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"k1-valid-fov-identity-v1","kind":"valid-fov-identity","sha256":"b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2"},{"component_id":"k1-valid-fov-mask-v1","kind":"valid-fov-mask","sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"},{"component_id":"vegetation-mission-policy-v1","kind":"policy","sha256":"b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},{"component_id":"vegetation-provider-label-map-v1","kind":"provider-map","sha256":"f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"}],"definition_id":"lab-v1-eomt-ddrnet-portable","definition_sha256":"269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac","executor":{"contour_id":"worker-006","image_sha256":"5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373","reason":null,"reason_code":null,"release_id":"lab-v1-installed-package-v1","release_sha256":"667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b","state":"ready"},"models":[{"architecture":"EomtForUniversalSegmentation","artifacts":[{"byte_length":1575,"role":"config-json","sha256":"7f4aa94fa4e43c0dbd79a5420edb511120aef62bd82bfbcbcece79948286a650"},{"byte_length":1276175488,"role":"model-weights","sha256":"c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782"},{"byte_length":666,"role":"preprocessor-config","sha256":"97e2fbf7f0bdba2cfc90251c5133bae9c27ddc9c4410509f40670be2332854e7"}],"model_id":"tue-mps/cityscapes_semantic_eomt_large_1024","release_id":"eomt-cityscapes-large-1024-v1","revision":"8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"},{"architecture":"ddrnet_39","artifacts":[{"byte_length":259419077,"role":"checkpoint","sha256":"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"}],"model_id":"goose-ddrnet-class-512","release_id":"lab-v1-ddrnet-39-goose-fine-64-v1","revision":null}],"resource_profile":{"accelerator_id":"nvidia-rtx-4090","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-single-gpu-sequential-ai-v1","profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"recorded-eomt-ddrnet-review-v2","contract_sha256":"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a","publication":"observatory","result_kind":"recorded-perception-qualification","result_schema":"missioncore.recorded-eomt-ddrnet-review/v2","schema_version":"missioncore.observatory-portable-result-contract/v2","version":2},"setup_id":"lab-v1-eomt-ddrnet-portable-v1","source_adapter":{"adapter_id":"xgrids-k1-recorded-observatory-v2","contract_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","version":2},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["point-cloud","trajectory","video"],"seekable":true},"version":2},{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"m49-tgs-portable-profile-v2","kind":"configuration","sha256":"6128d6af7e6137f9a9473db045e3b155e2105319159f17c32f344b4aedf823a9"},{"component_id":"m49-tgs-portable-runner-v1","kind":"runner","sha256":"e3bb2e91c70712eff74e8e69718075a407e042fb494616d65984500da42b21a9"}],"definition_id":"m49-tgs-portable","definition_sha256":"f56d6321bd794ccdfb7d2e3b05d044b11f616ffb81ee29517386cc253046d4eb","executor":{"contour_id":"worker-006","image_sha256":"f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3","reason":null,"reason_code":null,"release_id":"m49-tgs-portable-executor-v1","release_sha256":"c5b0670d943fe0452ef4bbfbc144ab2439a1a674f9ef164798ad9f8b1ecc29fa","state":"ready"},"models":[],"resource_profile":{"accelerator_id":"cpu-only","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-cpu-single-run-portable-v2","profile_sha256":"49e373f1cbf314e7fab2d176db295f3d3d9ddbbcbd724bbde988ef60ad767dee","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"m49-tgs-portable-review-v2","contract_sha256":"9dd80c8e2504559d2156fca933de6eb27901e35305e6853aeb84707e1cb13892","publication":"observatory","result_kind":"recorded-perception-qualification","result_schema":"missioncore.recorded-tgs-costmap-review/v2","schema_version":"missioncore.observatory-portable-result-contract/v2","version":2},"setup_id":"m49-tgs-portable-v2","source_adapter":{"adapter_id":"xgrids-k1-recorded-observatory-v2","contract_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","version":2},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["point-cloud","trajectory","video"],"seekable":true},"version":3}],"schema_version":"missioncore.observatory-portable-run-definition-registry/v2"}
+{"definitions":[{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"ddrnet-portable-runtime-config-v2","kind":"configuration","sha256":"c0ff657dadc86607d77d232e84d041fbf2d8b63e86d02319e9cd607220d00f21"},{"component_id":"eomt-recorded-dependency-set-v1","kind":"dependency-set","sha256":"4eb1f8d33236806e74f9e5bb96b7dce2ac37623dc39b2184be2aa8d7d00e983e"},{"component_id":"eomt-recorded-orchestrator-v1","kind":"orchestrator","sha256":"d3e9435939444ab35b27a744ac314e289ebd66a13fa56e3d59f121e088d22774"},{"component_id":"eomt-recorded-profile-v1","kind":"profile","sha256":"ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875"},{"component_id":"eomt-recorded-runner-v1","kind":"runner","sha256":"1e64869de48d10f1531c742e6067c4c3ae2a709c5eb0d770d1fab74b4a2431ff"},{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"k1-valid-fov-identity-v1","kind":"valid-fov-identity","sha256":"b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2"},{"component_id":"k1-valid-fov-mask-v1","kind":"valid-fov-mask","sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"},{"component_id":"vegetation-mission-policy-v1","kind":"policy","sha256":"b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},{"component_id":"vegetation-provider-label-map-v1","kind":"provider-map","sha256":"f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"}],"definition_id":"lab-v1-eomt-ddrnet-portable","definition_sha256":"269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac","executor":{"contour_id":"worker-006","image_sha256":"5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373","reason":null,"reason_code":null,"release_id":"lab-v1-installed-package-v1","release_sha256":"667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b","state":"ready"},"models":[{"architecture":"EomtForUniversalSegmentation","artifacts":[{"byte_length":1575,"role":"config-json","sha256":"7f4aa94fa4e43c0dbd79a5420edb511120aef62bd82bfbcbcece79948286a650"},{"byte_length":1276175488,"role":"model-weights","sha256":"c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782"},{"byte_length":666,"role":"preprocessor-config","sha256":"97e2fbf7f0bdba2cfc90251c5133bae9c27ddc9c4410509f40670be2332854e7"}],"model_id":"tue-mps/cityscapes_semantic_eomt_large_1024","release_id":"eomt-cityscapes-large-1024-v1","revision":"8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"},{"architecture":"ddrnet_39","artifacts":[{"byte_length":259419077,"role":"checkpoint","sha256":"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"}],"model_id":"goose-ddrnet-class-512","release_id":"lab-v1-ddrnet-39-goose-fine-64-v1","revision":null}],"resource_profile":{"accelerator_id":"nvidia-rtx-4090","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-single-gpu-sequential-ai-v1","profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"recorded-eomt-ddrnet-review-v2","contract_sha256":"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a","publication":"observatory","result_kind":"recorded-perception-qualification","result_schema":"missioncore.recorded-eomt-ddrnet-review/v2","schema_version":"missioncore.observatory-portable-result-contract/v2","version":2},"setup_id":"lab-v1-eomt-ddrnet-portable-v1","source_adapter":{"adapter_id":"xgrids-k1-recorded-observatory-v2","contract_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","version":2},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["point-cloud","trajectory","video"],"seekable":true},"version":2},{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"m49-tgs-portable-profile-v2","kind":"configuration","sha256":"6128d6af7e6137f9a9473db045e3b155e2105319159f17c32f344b4aedf823a9"},{"component_id":"m49-tgs-portable-runner-v1","kind":"runner","sha256":"e3bb2e91c70712eff74e8e69718075a407e042fb494616d65984500da42b21a9"}],"definition_id":"m49-tgs-portable","definition_sha256":"f56d6321bd794ccdfb7d2e3b05d044b11f616ffb81ee29517386cc253046d4eb","executor":{"contour_id":"worker-006","image_sha256":"f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3","reason":null,"reason_code":null,"release_id":"m49-tgs-portable-executor-v1","release_sha256":"c5b0670d943fe0452ef4bbfbc144ab2439a1a674f9ef164798ad9f8b1ecc29fa","state":"ready"},"models":[],"resource_profile":{"accelerator_id":"cpu-only","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-cpu-single-run-portable-v2","profile_sha256":"49e373f1cbf314e7fab2d176db295f3d3d9ddbbcbd724bbde988ef60ad767dee","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"m49-tgs-portable-review-v2","contract_sha256":"9dd80c8e2504559d2156fca933de6eb27901e35305e6853aeb84707e1cb13892","publication":"observatory","result_kind":"recorded-perception-qualification","result_schema":"missioncore.recorded-tgs-costmap-review/v2","schema_version":"missioncore.observatory-portable-result-contract/v2","version":2},"setup_id":"m49-tgs-portable-v2","source_adapter":{"adapter_id":"xgrids-k1-recorded-observatory-v2","contract_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","version":2},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["point-cloud","trajectory","video"],"seekable":true},"version":3},{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"camera-source-recorded-v1","kind":"orchestrator","sha256":"87c9e8864f9469719e69bfd750c418e077944a6260ffc6fd3ce12daf4a1da7d5"},{"component_id":"ddrnet-portable-runtime-config-v2","kind":"configuration","sha256":"c0ff657dadc86607d77d232e84d041fbf2d8b63e86d02319e9cd607220d00f21"},{"component_id":"ddrnet-recorded-profile-v1","kind":"profile","sha256":"c0ff657dadc86607d77d232e84d041fbf2d8b63e86d02319e9cd607220d00f21"},{"component_id":"ddrnet-recorded-runner-v1","kind":"runner","sha256":"b18ad60f277eea69a240a28f290611b94627fb9707faf1bb3e6e22102dad67c1"},{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"k1-valid-fov-identity-v1","kind":"valid-fov-identity","sha256":"b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2"},{"component_id":"k1-valid-fov-mask-v1","kind":"valid-fov-mask","sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"},{"component_id":"vegetation-mission-policy-v1","kind":"policy","sha256":"b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},{"component_id":"vegetation-provider-label-map-v1","kind":"provider-map","sha256":"f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"}],"definition_id":"ai-segmentation-ddrnet","definition_sha256":"04dd11b9bca4956bc253c970a9020a5fac80e9ef5981ff1fffc32b9646087fec","executor":{"contour_id":"worker-006","image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","reason":null,"reason_code":null,"release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721","state":"ready"},"models":[{"architecture":"ddrnet_39","artifacts":[{"byte_length":259419077,"role":"checkpoint","sha256":"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"}],"model_id":"goose-ddrnet-class-512","release_id":"lab-v1-ddrnet-39-goose-fine-64-v1","revision":null}],"resource_profile":{"accelerator_id":"nvidia-rtx-4090","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-single-gpu-sequential-ai-v1","profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"ai-layer-result","contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","publication":"observatory","result_kind":"recorded-ai-layer-review","result_schema":"missioncore.recorded-ai-layer-review/v1","schema_version":"missioncore.observatory-portable-result-contract/v2","version":1},"setup_id":"ai-segmentation-ddrnet-v1","source_adapter":{"adapter_id":"xgrids-k1-recorded-ai-layer-v1","contract_sha256":"2b8e624b2b16b00c9dd75b96e4c56c5438aa6889b0b2fa7a2b2da39d6aa81c6e","version":1},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["video"],"seekable":true},"version":1},{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"camera-source-recorded-v1","kind":"orchestrator","sha256":"87c9e8864f9469719e69bfd750c418e077944a6260ffc6fd3ce12daf4a1da7d5"},{"component_id":"eomt-recorded-dependency-set-v1","kind":"dependency-set","sha256":"4eb1f8d33236806e74f9e5bb96b7dce2ac37623dc39b2184be2aa8d7d00e983e"},{"component_id":"eomt-recorded-orchestrator-v1","kind":"orchestrator","sha256":"d3e9435939444ab35b27a744ac314e289ebd66a13fa56e3d59f121e088d22774"},{"component_id":"eomt-recorded-profile-v1","kind":"profile","sha256":"ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875"},{"component_id":"eomt-recorded-runner-v1","kind":"runner","sha256":"1e64869de48d10f1531c742e6067c4c3ae2a709c5eb0d770d1fab74b4a2431ff"},{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"k1-valid-fov-identity-v1","kind":"valid-fov-identity","sha256":"b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2"},{"component_id":"k1-valid-fov-mask-v1","kind":"valid-fov-mask","sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"}],"definition_id":"ai-segmentation-eomt","definition_sha256":"387f42bdf707b0e3e357fec1cfb5aa90a64133ebf0095f9b01d749ca2364d47c","executor":{"contour_id":"worker-006","image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","reason":null,"reason_code":null,"release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721","state":"ready"},"models":[{"architecture":"EomtForUniversalSegmentation","artifacts":[{"byte_length":1575,"role":"config-json","sha256":"7f4aa94fa4e43c0dbd79a5420edb511120aef62bd82bfbcbcece79948286a650"},{"byte_length":1276175488,"role":"model-weights","sha256":"c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782"},{"byte_length":666,"role":"preprocessor-config","sha256":"97e2fbf7f0bdba2cfc90251c5133bae9c27ddc9c4410509f40670be2332854e7"}],"model_id":"tue-mps/cityscapes_semantic_eomt_large_1024","release_id":"eomt-cityscapes-large-1024-v1","revision":"8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"}],"resource_profile":{"accelerator_id":"nvidia-rtx-4090","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-single-gpu-sequential-ai-v1","profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"ai-layer-result","contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","publication":"observatory","result_kind":"recorded-ai-layer-review","result_schema":"missioncore.recorded-ai-layer-review/v1","schema_version":"missioncore.observatory-portable-result-contract/v2","version":1},"setup_id":"ai-segmentation-eomt-v1","source_adapter":{"adapter_id":"xgrids-k1-recorded-ai-layer-v1","contract_sha256":"2b8e624b2b16b00c9dd75b96e4c56c5438aa6889b0b2fa7a2b2da39d6aa81c6e","version":1},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["video"],"seekable":true},"version":1},{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"camera-source-recorded-v1","kind":"orchestrator","sha256":"87c9e8864f9469719e69bfd750c418e077944a6260ffc6fd3ce12daf4a1da7d5"},{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"k1-valid-fov-identity-v1","kind":"valid-fov-identity","sha256":"b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2"},{"component_id":"k1-valid-fov-mask-v1","kind":"valid-fov-mask","sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"},{"component_id":"rf-detr-recorded-profile-v1","kind":"profile","sha256":"4eb580eb938164bf0624e94fdc3b046aacf32fb8022c93c67fa26bab7c3d19d0"},{"component_id":"rf-detr-recorded-runner-v1","kind":"runner","sha256":"4eb580eb938164bf0624e94fdc3b046aacf32fb8022c93c67fa26bab7c3d19d0"}],"definition_id":"ai-detection-rf-detr","definition_sha256":"598ce44f087c392ee95a024c3c45c95bdd02ad8530a3ccbac13422c2ad2f4a94","executor":{"contour_id":"worker-006","image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","reason":null,"reason_code":null,"release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721","state":"ready"},"models":[{"architecture":"RF-DETR-Large-TensorRT","artifacts":[{"byte_length":68316388,"role":"tensorrt-engine","sha256":"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"}],"model_id":"rf-detr/large-native-kb4","release_id":"rf-detr-large-native-kb4-v1","revision":null}],"resource_profile":{"accelerator_id":"nvidia-rtx-4090","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-single-gpu-sequential-ai-v1","profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"ai-layer-result","contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","publication":"observatory","result_kind":"recorded-ai-layer-review","result_schema":"missioncore.recorded-ai-layer-review/v1","schema_version":"missioncore.observatory-portable-result-contract/v2","version":1},"setup_id":"ai-detection-rf-detr-v1","source_adapter":{"adapter_id":"xgrids-k1-recorded-ai-layer-v1","contract_sha256":"2b8e624b2b16b00c9dd75b96e4c56c5438aa6889b0b2fa7a2b2da39d6aa81c6e","version":1},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["video"],"seekable":true},"version":1},{"authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"components":[{"component_id":"camera-source-recorded-v1","kind":"orchestrator","sha256":"87c9e8864f9469719e69bfd750c418e077944a6260ffc6fd3ce12daf4a1da7d5"},{"component_id":"k1-camera-1-calibration-v1","kind":"calibration","sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"},{"component_id":"k1-valid-fov-identity-v1","kind":"valid-fov-identity","sha256":"b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2"},{"component_id":"k1-valid-fov-mask-v1","kind":"valid-fov-mask","sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"},{"component_id":"m49-tgs-portable-profile-v2","kind":"configuration","sha256":"6128d6af7e6137f9a9473db045e3b155e2105319159f17c32f344b4aedf823a9"},{"component_id":"object-distance-recorded-profile-v1","kind":"profile","sha256":"0e3e426e2f768bc019a3715a50fc77a88626869abd9fa919e1aef5763d0c53da"},{"component_id":"object-distance-recorded-runner-v1","kind":"runner","sha256":"0e3e426e2f768bc019a3715a50fc77a88626869abd9fa919e1aef5763d0c53da"},{"component_id":"rf-detr-recorded-dependency-v1","kind":"dependency-set","sha256":"4eb580eb938164bf0624e94fdc3b046aacf32fb8022c93c67fa26bab7c3d19d0"}],"definition_id":"ai-range-object-distance","definition_sha256":"af5e637b78c670930b60cd4527b611aba4af601e6da58aa4dc8326ea6e13c5c2","executor":{"contour_id":"worker-006","image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","reason":null,"reason_code":null,"release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721","state":"ready"},"models":[{"architecture":"RF-DETR-Large-TensorRT","artifacts":[{"byte_length":68316388,"role":"tensorrt-engine","sha256":"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"}],"model_id":"rf-detr/large-native-kb4","release_id":"rf-detr-large-native-kb4-v1","revision":null}],"resource_profile":{"accelerator_id":"nvidia-rtx-4090","allowed_checkpoints":[],"checkpoint_policy":"non-checkpointable","concurrency":1,"contour_id":"worker-006","profile_id":"worker006-single-gpu-sequential-ai-object-distance-v1","profile_sha256":"c244efed459915508044fed4ef0e73c810fbe750e5e379d06723f734bd4385ca","schema_version":"missioncore.observatory-portable-resource-profile/v2"},"result_contract":{"contract_id":"ai-layer-result","contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","publication":"observatory","result_kind":"recorded-ai-layer-review","result_schema":"missioncore.recorded-ai-layer-review/v1","schema_version":"missioncore.observatory-portable-result-contract/v2","version":1},"setup_id":"ai-range-object-distance-v1","source_adapter":{"adapter_id":"xgrids-k1-recorded-observatory-v2","contract_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","version":2},"source_requirements":{"archive_id":"xgrids-k1.viewer-live.evidence","calibration_identity_sha256":"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9","calibration_slot":"camera_1","camera_height":600,"camera_semantic_channel_id":"camera.video.recorded","camera_source_id":"sensor.camera.right","camera_width":800,"exactly_one_media_epoch":true,"plugin_id":"nodedc.device.xgrids-lixelkity-k1","recorded_media_init_sha256":"e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38","recorded_media_type":"video/mp4; codecs=\"avc1.641028\"","required_modalities":["point-cloud","trajectory","video"],"seekable":true},"version":1}],"schema_version":"missioncore.observatory-portable-run-definition-registry/v2"}
\ No newline at end of file
diff --git a/config/observatory-worker-runtime-candidates.json b/config/observatory-worker-runtime-candidates.json
index d018c68..f525ecf 100644
--- a/config/observatory-worker-runtime-candidates.json
+++ b/config/observatory-worker-runtime-candidates.json
@@ -1 +1 @@
-{"candidates":[{"adapter_id":"lab-v1-installed-package-worker006-v1","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"0b6958e3a1b4e12e04619f447ca9ce915021aa1aaa6034ea6bc309c26efbd060","definition_id":"lab-v1-eomt-ddrnet-portable","definition_sha256":"269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac","definition_version":2,"executor":{"image_sha256":"5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373","release_id":"lab-v1-installed-package-v1","release_sha256":"667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b"},"model_manifest_sha256":"3fd2d43af73bd73f89d9ffae95d8770cfdeb46033ec967509124fac6ae4afe56","phases":[{"phase_id":"source-delivery","state":"implemented"},{"phase_id":"prepare-source","state":"implemented"},{"phase_id":"eomt-step","state":"implemented"},{"phase_id":"ddrnet-step","state":"implemented"},{"phase_id":"assemble-result","state":"implemented"},{"phase_id":"result-publication","state":"implemented"}],"resource_profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","result_contract_sha256":"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a","reusable_assets":[{"asset_id":"agent-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373"},{"asset_id":"ddrnet-checkpoint","byte_length":259419077,"component_id":null,"kind":"model-artifact","model_artifact_role":"checkpoint","model_release_id":"lab-v1-ddrnet-39-goose-fine-64-v1","sha256":"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"},{"asset_id":"ddrnet-goose-mapping","byte_length":1427,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"},{"asset_id":"ddrnet-goose-runner","byte_length":32877,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"b18ad60f277eea69a240a28f290611b94627fb9707faf1bb3e6e22102dad67c1"},{"asset_id":"ddrnet-portable-config","byte_length":4324,"component_id":"ddrnet-portable-runtime-config-v2","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"c0ff657dadc86607d77d232e84d041fbf2d8b63e86d02319e9cd607220d00f21"},{"asset_id":"ddrnet-step-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"e6c986100613ec804f0e0076ca8695abf43ff88ef9d5d85f6857e0b41db74051"},{"asset_id":"eomt-environment","byte_length":211776082,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"8c8f343a5368ff17edbb58defa1669f6eccfba767aab897a23693872070ab9e0"},{"asset_id":"eomt-ffmpeg-runtime","byte_length":256208352,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"03651449fdcccec847a0f1241e1663a82cf374bd94e7470b4ddb0c0e46d88c69"},{"asset_id":"eomt-model-cache","byte_length":2552355458,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"064870e58814b97027d6a7ccd553bf51f5b8e6a8ad82a1cc703584d2dca5690c"},{"asset_id":"eomt-python-environment","byte_length":5120848705,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"b3f4efc53af491f174b1cff74b3ba03016e67c9c5c74257b49c6e7dd7d853f20"},{"asset_id":"eomt-runner-bundle","byte_length":145141,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"0d08f0492d5ad62903874ea224c505e54a6f6059c8283f586bc79e42d55156b7"},{"asset_id":"eomt-step-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174"},{"asset_id":"eomt-transformers-environment","byte_length":225272284,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"f365de01426a33be51a310923c743655634d0868941bbf3f1aae1647fdeadfc9"},{"asset_id":"k1-valid-fov-root","byte_length":6019,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"f4fc2053e4e6213bb364c8773979b755d5682a81b3946c25ff86274bc5f0031e"},{"asset_id":"lab-v1-definition-registry","byte_length":7177,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"1de9a07153fa6f51088aecdbcf74d12c9e58716c5feb0145d6e54f20a8c300cb"},{"asset_id":"lab-v1-package-contract","byte_length":3531,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"c7a576aba09ccb0a343ad46ad90f1dab589ed51353d575026a508f8d6fa8dcc4"},{"asset_id":"vegetation-policy","byte_length":3022,"component_id":"vegetation-mission-policy-v1","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},{"asset_id":"vegetation-provider-map","byte_length":2756,"component_id":"vegetation-provider-label-map-v1","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"lab-v1-eomt-ddrnet-portable-v1","source_adapter_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","state":"ready"},{"adapter_id":"m49-tgs-worker006-portable-v2","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"65cd2063146a1dd320e30d5f4e21e4bf0aab1ff683e846926cbbfbe25a9f8a5e","definition_id":"m49-tgs-portable","definition_sha256":"f56d6321bd794ccdfb7d2e3b05d044b11f616ffb81ee29517386cc253046d4eb","definition_version":3,"executor":{"image_sha256":"f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3","release_id":"m49-tgs-portable-executor-v1","release_sha256":"c5b0670d943fe0452ef4bbfbc144ab2439a1a674f9ef164798ad9f8b1ecc29fa"},"model_manifest_sha256":"489a43448f720a9b5c7993dc8279d167b77191a586f0d87b6d38b81cf728e2f1","phases":[{"phase_id":"source-delivery","state":"implemented"},{"phase_id":"camera-lidar-timeline-materializer","state":"implemented"},{"phase_id":"portable-tgs-input-materializer","state":"implemented"},{"phase_id":"portable-tgs-runner","state":"implemented"},{"phase_id":"result-v2-assembler","state":"implemented"},{"phase_id":"observatory-result-publisher","state":"implemented"}],"resource_profile_sha256":"49e373f1cbf314e7fab2d176db295f3d3d9ddbbcbd724bbde988ef60ad767dee","result_contract_sha256":"9dd80c8e2504559d2156fca933de6eb27901e35305e6853aeb84707e1cb13892","reusable_assets":[{"asset_id":"m49-portable-compiled-runner","byte_length":274168,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"7be449392ef161fb8713b4c984705d2373bc3cd05645332d92b88a8bff0c7db3"},{"asset_id":"m49-portable-compiled-runner-build-seal","byte_length":1014,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"e3bb2e91c70712eff74e8e69718075a407e042fb494616d65984500da42b21a9"},{"asset_id":"m49-portable-executor-release","byte_length":1693,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"c5b0670d943fe0452ef4bbfbc144ab2439a1a674f9ef164798ad9f8b1ecc29fa"},{"asset_id":"m49-portable-profile","byte_length":1683,"component_id":"m49-tgs-portable-profile-v2","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"6128d6af7e6137f9a9473db045e3b155e2105319159f17c32f344b4aedf823a9"},{"asset_id":"m49-portable-worker-installation-receipt","byte_length":2672,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"b560ff9e02746cbb760502f2a3b4b7bd564f95ab52d1149d189927a326b1645a"},{"asset_id":"travel-tgs-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"m49-tgs-portable-v2","source_adapter_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","state":"ready"}],"schema_version":"missioncore.observatory-portable-worker-runtime-registry/v1"}
+{"candidates":[{"adapter_id":"lab-v1-installed-package-worker006-v1","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"0b6958e3a1b4e12e04619f447ca9ce915021aa1aaa6034ea6bc309c26efbd060","definition_id":"lab-v1-eomt-ddrnet-portable","definition_sha256":"269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac","definition_version":2,"executor":{"image_sha256":"5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373","release_id":"lab-v1-installed-package-v1","release_sha256":"667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b"},"model_manifest_sha256":"3fd2d43af73bd73f89d9ffae95d8770cfdeb46033ec967509124fac6ae4afe56","phases":[{"phase_id":"source-delivery","state":"implemented"},{"phase_id":"prepare-source","state":"implemented"},{"phase_id":"eomt-step","state":"implemented"},{"phase_id":"ddrnet-step","state":"implemented"},{"phase_id":"assemble-result","state":"implemented"},{"phase_id":"result-publication","state":"implemented"}],"resource_profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","result_contract_sha256":"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a","reusable_assets":[{"asset_id":"agent-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373"},{"asset_id":"ddrnet-checkpoint","byte_length":259419077,"component_id":null,"kind":"model-artifact","model_artifact_role":"checkpoint","model_release_id":"lab-v1-ddrnet-39-goose-fine-64-v1","sha256":"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"},{"asset_id":"ddrnet-goose-mapping","byte_length":1427,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"},{"asset_id":"ddrnet-goose-runner","byte_length":32877,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"b18ad60f277eea69a240a28f290611b94627fb9707faf1bb3e6e22102dad67c1"},{"asset_id":"ddrnet-portable-config","byte_length":4324,"component_id":"ddrnet-portable-runtime-config-v2","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"c0ff657dadc86607d77d232e84d041fbf2d8b63e86d02319e9cd607220d00f21"},{"asset_id":"ddrnet-step-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"e6c986100613ec804f0e0076ca8695abf43ff88ef9d5d85f6857e0b41db74051"},{"asset_id":"eomt-environment","byte_length":211776082,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"8c8f343a5368ff17edbb58defa1669f6eccfba767aab897a23693872070ab9e0"},{"asset_id":"eomt-ffmpeg-runtime","byte_length":256208352,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"03651449fdcccec847a0f1241e1663a82cf374bd94e7470b4ddb0c0e46d88c69"},{"asset_id":"eomt-model-cache","byte_length":2552355458,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"064870e58814b97027d6a7ccd553bf51f5b8e6a8ad82a1cc703584d2dca5690c"},{"asset_id":"eomt-python-environment","byte_length":5120848705,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"b3f4efc53af491f174b1cff74b3ba03016e67c9c5c74257b49c6e7dd7d853f20"},{"asset_id":"eomt-runner-bundle","byte_length":145141,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"0d08f0492d5ad62903874ea224c505e54a6f6059c8283f586bc79e42d55156b7"},{"asset_id":"eomt-step-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174"},{"asset_id":"eomt-transformers-environment","byte_length":225272284,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"f365de01426a33be51a310923c743655634d0868941bbf3f1aae1647fdeadfc9"},{"asset_id":"k1-valid-fov-root","byte_length":6019,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"f4fc2053e4e6213bb364c8773979b755d5682a81b3946c25ff86274bc5f0031e"},{"asset_id":"lab-v1-definition-registry","byte_length":7177,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"1de9a07153fa6f51088aecdbcf74d12c9e58716c5feb0145d6e54f20a8c300cb"},{"asset_id":"lab-v1-package-contract","byte_length":3531,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"c7a576aba09ccb0a343ad46ad90f1dab589ed51353d575026a508f8d6fa8dcc4"},{"asset_id":"vegetation-policy","byte_length":3022,"component_id":"vegetation-mission-policy-v1","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},{"asset_id":"vegetation-provider-map","byte_length":2756,"component_id":"vegetation-provider-label-map-v1","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"lab-v1-eomt-ddrnet-portable-v1","source_adapter_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","state":"ready"},{"adapter_id":"m49-tgs-worker006-portable-v2","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"65cd2063146a1dd320e30d5f4e21e4bf0aab1ff683e846926cbbfbe25a9f8a5e","definition_id":"m49-tgs-portable","definition_sha256":"f56d6321bd794ccdfb7d2e3b05d044b11f616ffb81ee29517386cc253046d4eb","definition_version":3,"executor":{"image_sha256":"f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3","release_id":"m49-tgs-portable-executor-v1","release_sha256":"c5b0670d943fe0452ef4bbfbc144ab2439a1a674f9ef164798ad9f8b1ecc29fa"},"model_manifest_sha256":"489a43448f720a9b5c7993dc8279d167b77191a586f0d87b6d38b81cf728e2f1","phases":[{"phase_id":"source-delivery","state":"implemented"},{"phase_id":"camera-lidar-timeline-materializer","state":"implemented"},{"phase_id":"portable-tgs-input-materializer","state":"implemented"},{"phase_id":"portable-tgs-runner","state":"implemented"},{"phase_id":"result-v2-assembler","state":"implemented"},{"phase_id":"observatory-result-publisher","state":"implemented"}],"resource_profile_sha256":"49e373f1cbf314e7fab2d176db295f3d3d9ddbbcbd724bbde988ef60ad767dee","result_contract_sha256":"9dd80c8e2504559d2156fca933de6eb27901e35305e6853aeb84707e1cb13892","reusable_assets":[{"asset_id":"m49-portable-compiled-runner","byte_length":274168,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"7be449392ef161fb8713b4c984705d2373bc3cd05645332d92b88a8bff0c7db3"},{"asset_id":"m49-portable-compiled-runner-build-seal","byte_length":1014,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"e3bb2e91c70712eff74e8e69718075a407e042fb494616d65984500da42b21a9"},{"asset_id":"m49-portable-executor-release","byte_length":1693,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"c5b0670d943fe0452ef4bbfbc144ab2439a1a674f9ef164798ad9f8b1ecc29fa"},{"asset_id":"m49-portable-profile","byte_length":1683,"component_id":"m49-tgs-portable-profile-v2","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"6128d6af7e6137f9a9473db045e3b155e2105319159f17c32f344b4aedf823a9"},{"asset_id":"m49-portable-worker-installation-receipt","byte_length":2672,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"b560ff9e02746cbb760502f2a3b4b7bd564f95ab52d1149d189927a326b1645a"},{"asset_id":"travel-tgs-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"m49-tgs-portable-v2","source_adapter_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","state":"ready"},{"adapter_id":"ai-modular-package-worker006-v1-ddrnet","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"66dd1b086b03799b08adde8c64461c7b8e09b183bb9e14f169933e625dcbc070","definition_id":"ai-segmentation-ddrnet","definition_sha256":"04dd11b9bca4956bc253c970a9020a5fac80e9ef5981ff1fffc32b9646087fec","definition_version":1,"executor":{"image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721"},"model_manifest_sha256":"c9144b30d054e3d495961486a4aed70ae5f60bcadadb4e3309865ced4123739e","phases":[{"phase_id":"source-materialization","state":"implemented"},{"phase_id":"camera-source","state":"implemented"},{"phase_id":"ddrnet-inference","state":"implemented"},{"phase_id":"result-assembly","state":"implemented"}],"resource_profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","result_contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","reusable_assets":[{"asset_id":"ai-module-agent-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab"},{"asset_id":"ai-module-ddrnet-profile","byte_length":4324,"component_id":"ddrnet-portable-runtime-config-v2","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"c0ff657dadc86607d77d232e84d041fbf2d8b63e86d02319e9cd607220d00f21"},{"asset_id":"ai-module-definition-registry","byte_length":21610,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"bb62449f5178cd8326409875987f2f0a6502fd1ef53ff928e5a523488c8d7b30"},{"asset_id":"camera-source-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"af42a26fc646483a1c34c1a2760c54ceea7b15792927aa69f4793f4694ee5561"},{"asset_id":"ddrnet-checkpoint","byte_length":259419077,"component_id":null,"kind":"model-artifact","model_artifact_role":"checkpoint","model_release_id":"lab-v1-ddrnet-39-goose-fine-64-v1","sha256":"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"},{"asset_id":"ddrnet-goose-mapping","byte_length":1427,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"},{"asset_id":"ddrnet-goose-runner","byte_length":32877,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"b18ad60f277eea69a240a28f290611b94627fb9707faf1bb3e6e22102dad67c1"},{"asset_id":"ddrnet-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"a3b7d22f5d3bfdf2d84444b936c8b01abf8243be652387d7e2024ba7bda587f5"},{"asset_id":"ddrnet-package-contract","byte_length":2307,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"34768724991372beee8a3e21baca66e5467671c2639ec699a55eecf2dc8268b9"},{"asset_id":"eomt-ffmpeg-runtime","byte_length":256208352,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"03651449fdcccec847a0f1241e1663a82cf374bd94e7470b4ddb0c0e46d88c69"},{"asset_id":"vegetation-policy","byte_length":3022,"component_id":"vegetation-mission-policy-v1","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},{"asset_id":"vegetation-provider-map","byte_length":2756,"component_id":"vegetation-provider-label-map-v1","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"ai-segmentation-ddrnet-v1","source_adapter_sha256":"2b8e624b2b16b00c9dd75b96e4c56c5438aa6889b0b2fa7a2b2da39d6aa81c6e","state":"ready"},{"adapter_id":"ai-modular-package-worker006-v1-eomt","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"b6c7d84a051bd9c5a163f74cb78bd6aa7a96b6c34e3f5cdfc2f99d4aeca8df7b","definition_id":"ai-segmentation-eomt","definition_sha256":"387f42bdf707b0e3e357fec1cfb5aa90a64133ebf0095f9b01d749ca2364d47c","definition_version":1,"executor":{"image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721"},"model_manifest_sha256":"94dc40430c2ac23d45aaf14dd26f85f2ca9ba0ec975fe4b08e585c32dd79a223","phases":[{"phase_id":"source-materialization","state":"implemented"},{"phase_id":"camera-source","state":"implemented"},{"phase_id":"eomt-inference","state":"implemented"},{"phase_id":"result-assembly","state":"implemented"}],"resource_profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","result_contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","reusable_assets":[{"asset_id":"ai-module-agent-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab"},{"asset_id":"ai-module-definition-registry","byte_length":21610,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"bb62449f5178cd8326409875987f2f0a6502fd1ef53ff928e5a523488c8d7b30"},{"asset_id":"camera-source-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"af42a26fc646483a1c34c1a2760c54ceea7b15792927aa69f4793f4694ee5561"},{"asset_id":"eomt-environment","byte_length":211776082,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"8c8f343a5368ff17edbb58defa1669f6eccfba767aab897a23693872070ab9e0"},{"asset_id":"eomt-ffmpeg-runtime","byte_length":256208352,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"03651449fdcccec847a0f1241e1663a82cf374bd94e7470b4ddb0c0e46d88c69"},{"asset_id":"eomt-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"211852dfae4ab1acbd1195d4968a385ea56bd7bd3d0c4241a6be7e02794999b7"},{"asset_id":"eomt-model-cache","byte_length":2552355458,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"064870e58814b97027d6a7ccd553bf51f5b8e6a8ad82a1cc703584d2dca5690c"},{"asset_id":"eomt-package-contract","byte_length":2649,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"3863c4a3f8328221acf66e70dd0cb66c71b0c93b25ef050c072ba351f3edfa5d"},{"asset_id":"eomt-python-environment","byte_length":5120848705,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"b3f4efc53af491f174b1cff74b3ba03016e67c9c5c74257b49c6e7dd7d853f20"},{"asset_id":"eomt-runner-bundle","byte_length":145141,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"0d08f0492d5ad62903874ea224c505e54a6f6059c8283f586bc79e42d55156b7"},{"asset_id":"eomt-transformers-environment","byte_length":225272284,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"f365de01426a33be51a310923c743655634d0868941bbf3f1aae1647fdeadfc9"},{"asset_id":"k1-valid-fov-root","byte_length":6019,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"f4fc2053e4e6213bb364c8773979b755d5682a81b3946c25ff86274bc5f0031e"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"ai-segmentation-eomt-v1","source_adapter_sha256":"2b8e624b2b16b00c9dd75b96e4c56c5438aa6889b0b2fa7a2b2da39d6aa81c6e","state":"ready"},{"adapter_id":"ai-modular-package-worker006-v1-rf-detr","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"aae136d27d4c013dec111d2b83c22f55df8dcd0dd73a2be5ed47b67f490164f4","definition_id":"ai-detection-rf-detr","definition_sha256":"598ce44f087c392ee95a024c3c45c95bdd02ad8530a3ccbac13422c2ad2f4a94","definition_version":1,"executor":{"image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721"},"model_manifest_sha256":"edef8446597b801f67538e9e1f056cbb55e56bf61ca4127efd97ce98e7473788","phases":[{"phase_id":"source-materialization","state":"implemented"},{"phase_id":"camera-source","state":"implemented"},{"phase_id":"rf-detr-inference","state":"implemented"},{"phase_id":"result-assembly","state":"implemented"}],"resource_profile_sha256":"7468138cad115210eda18e7a3350e3423d3d39ac0aae650b3266cdc2bc63fd5d","result_contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","reusable_assets":[{"asset_id":"ai-module-agent-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab"},{"asset_id":"ai-module-definition-registry","byte_length":21610,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"bb62449f5178cd8326409875987f2f0a6502fd1ef53ff928e5a523488c8d7b30"},{"asset_id":"camera-source-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"af42a26fc646483a1c34c1a2760c54ceea7b15792927aa69f4793f4694ee5561"},{"asset_id":"eomt-ffmpeg-runtime","byte_length":256208352,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"03651449fdcccec847a0f1241e1663a82cf374bd94e7470b4ddb0c0e46d88c69"},{"asset_id":"rf-detr-engine","byte_length":68316388,"component_id":null,"kind":"model-artifact","model_artifact_role":"tensorrt-engine","model_release_id":"rf-detr-large-native-kb4-v1","sha256":"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"},{"asset_id":"rf-detr-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"57e5e9bc3033f3dfa696a9135928b8260702a77aadfcaee97a2eb9ccd28d7b48"},{"asset_id":"rf-detr-package-contract","byte_length":1702,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"00291bebb09af4d615b7670f0f4e5084cd5665d46238a6bb5ff93a76ff95cc18"},{"asset_id":"rf-detr-valid-fov","byte_length":3668,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"ai-detection-rf-detr-v1","source_adapter_sha256":"2b8e624b2b16b00c9dd75b96e4c56c5438aa6889b0b2fa7a2b2da39d6aa81c6e","state":"ready"},{"adapter_id":"ai-modular-package-worker006-v1-object-distance","authority":{"actuation_allowed":false,"commands_enabled":false,"navigation_or_safety_accepted":false,"production_accepted":false},"blockers":[],"candidate_sha256":"ec35befdde1a23fb62eda0de582420cd858c9cc7ad46a82f1cf05f07ce2fe39e","definition_id":"ai-range-object-distance","definition_sha256":"af5e637b78c670930b60cd4527b611aba4af601e6da58aa4dc8326ea6e13c5c2","definition_version":1,"executor":{"image_sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab","release_id":"ai-modular-installed-package-v1","release_sha256":"97bfe743689766285b22627c4020d10df7949ef1f4546ef904dccb9ca4c6f721"},"model_manifest_sha256":"edef8446597b801f67538e9e1f056cbb55e56bf61ca4127efd97ce98e7473788","phases":[{"phase_id":"source-materialization","state":"implemented"},{"phase_id":"camera-source","state":"implemented"},{"phase_id":"object-distance-inference","state":"implemented"},{"phase_id":"result-assembly","state":"implemented"}],"resource_profile_sha256":"c244efed459915508044fed4ef0e73c810fbe750e5e379d06723f734bd4385ca","result_contract_sha256":"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305","reusable_assets":[{"asset_id":"ai-module-agent-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"7a1d425b4678dcade7d7d6ffd14733d639a4df29f45e1759ba4c512e930da8ab"},{"asset_id":"ai-module-definition-registry","byte_length":21610,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"bb62449f5178cd8326409875987f2f0a6502fd1ef53ff928e5a523488c8d7b30"},{"asset_id":"ai-module-m49-profile","byte_length":1683,"component_id":"m49-tgs-portable-profile-v2","kind":"definition-component","model_artifact_role":null,"model_release_id":null,"sha256":"6128d6af7e6137f9a9473db045e3b155e2105319159f17c32f344b4aedf823a9"},{"asset_id":"camera-source-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"af42a26fc646483a1c34c1a2760c54ceea7b15792927aa69f4793f4694ee5561"},{"asset_id":"eomt-ffmpeg-runtime","byte_length":256208352,"component_id":null,"kind":"local-tree","model_artifact_role":null,"model_release_id":null,"sha256":"03651449fdcccec847a0f1241e1663a82cf374bd94e7470b4ddb0c0e46d88c69"},{"asset_id":"k1-camera-lidar-calibration","byte_length":72996000,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"},{"asset_id":"object-distance-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"03908003f7531f677fae50ffad12019c6a62ada773f7e84431bfb9c35982c081"},{"asset_id":"object-distance-package-contract","byte_length":2096,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"7e019064c94884f670d2babecdbd69c6d45082d709ef4cdc6421e3498d1c2c69"},{"asset_id":"rf-detr-engine","byte_length":68316388,"component_id":null,"kind":"model-artifact","model_artifact_role":"tensorrt-engine","model_release_id":"rf-detr-large-native-kb4-v1","sha256":"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"},{"asset_id":"rf-detr-image","byte_length":null,"component_id":null,"kind":"container-image","model_artifact_role":null,"model_release_id":null,"sha256":"57e5e9bc3033f3dfa696a9135928b8260702a77aadfcaee97a2eb9ccd28d7b48"},{"asset_id":"rf-detr-valid-fov","byte_length":3668,"component_id":null,"kind":"local-file","model_artifact_role":null,"model_release_id":null,"sha256":"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"}],"schema_version":"missioncore.observatory-portable-worker-runtime-candidate/v1","setup_id":"ai-range-object-distance-v1","source_adapter_sha256":"4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de","state":"ready"}],"schema_version":"missioncore.observatory-portable-worker-runtime-registry/v1"}
\ No newline at end of file
diff --git a/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md b/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md
index 35ff294..25df9d6 100644
--- a/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md
+++ b/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md
@@ -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
diff --git a/docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md b/docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md
index 3acd723..836c96e 100644
--- a/docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md
+++ b/docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md
@@ -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.215–757.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 проверен.
+- [ ] Этап 2C–2D — 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×
diff --git a/docs/adr/0045-upstream-rerun-canonical-recorded-lab-pipeline.md b/docs/adr/0045-upstream-rerun-canonical-recorded-lab-pipeline.md
index 533599e..deb8066 100644
--- a/docs/adr/0045-upstream-rerun-canonical-recorded-lab-pipeline.md
+++ b/docs/adr/0045-upstream-rerun-canonical-recorded-lab-pipeline.md
@@ -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.
diff --git a/docs/adr/0050-recorded-observatory-first.md b/docs/adr/0050-recorded-observatory-first.md
index 43fda5e..361c200 100644
--- a/docs/adr/0050-recorded-observatory-first.md
+++ b/docs/adr/0050-recorded-observatory-first.md
@@ -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.
diff --git a/docs/adr/0051-modular-observatory-profiles.md b/docs/adr/0051-modular-observatory-profiles.md
new file mode 100644
index 0000000..8a8ce2e
--- /dev/null
+++ b/docs/adr/0051-modular-observatory-profiles.md
@@ -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.
diff --git a/docs/domain-model/observatory-domain-ontology-v1.md b/docs/domain-model/observatory-domain-ontology-v1.md
new file mode 100644
index 0000000..4ed95f9
--- /dev/null
+++ b/docs/domain-model/observatory-domain-ontology-v1.md
@@ -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.
diff --git a/experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md b/experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md
new file mode 100644
index 0000000..2447775
--- /dev/null
+++ b/experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md
@@ -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.491–13:24:52.921; monotonic `performance.now()`
+ 130.567750–559.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` |
diff --git a/experiments/perception/OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md b/experiments/perception/OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md
new file mode 100644
index 0000000..1d750ad
--- /dev/null
+++ b/experiments/perception/OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md
@@ -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.
diff --git a/experiments/perception/OBSERVATORY_MEMORY_LIFECYCLE_2026-09-03.md b/experiments/perception/OBSERVATORY_MEMORY_LIFECYCLE_2026-09-03.md
new file mode 100644
index 0000000..381031e
--- /dev/null
+++ b/experiments/perception/OBSERVATORY_MEMORY_LIFECYCLE_2026-09-03.md
@@ -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 оставался около 168–170 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.
diff --git a/experiments/perception/OBSERVATORY_MODULAR_HANDOFF_2026-09-03.md b/experiments/perception/OBSERVATORY_MODULAR_HANDOFF_2026-09-03.md
new file mode 100644
index 0000000..9617445
--- /dev/null
+++ b/experiments/perception/OBSERVATORY_MODULAR_HANDOFF_2026-09-03.md
@@ -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.215–757.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.
diff --git a/experiments/perception/OBSERVATORY_SAVED_REVIEW_RECOVERY_2026-09-03.md b/experiments/perception/OBSERVATORY_SAVED_REVIEW_RECOVERY_2026-09-03.md
new file mode 100644
index 0000000..73c00d9
--- /dev/null
+++ b/experiments/perception/OBSERVATORY_SAVED_REVIEW_RECOVERY_2026-09-03.md
@@ -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.
diff --git a/experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md b/experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md
new file mode 100644
index 0000000..5c4c33a
--- /dev/null
+++ b/experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md
@@ -0,0 +1,129 @@
+# Observatory: повторное использование входов на Worker
+
+Session: `observatory-source-reuse-AsldTM`, 2026-09-03.
+Установлено 12:53:45.460616–12:54:11.038263 UTC; monotonic
+25614923660636–25640502446818 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.
diff --git a/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-camera-source b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-camera-source
new file mode 100644
index 0000000..68830ae
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-camera-source
@@ -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"]
diff --git a/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-ddrnet b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-ddrnet
new file mode 100644
index 0000000..5f67fc8
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-ddrnet
@@ -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"]
diff --git a/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-eomt b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-eomt
new file mode 100644
index 0000000..10f275a
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-eomt
@@ -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"]
diff --git a/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-object-distance b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-object-distance
new file mode 100644
index 0000000..534735c
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-object-distance
@@ -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"]
diff --git a/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-rf-detr b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-rf-detr
new file mode 100644
index 0000000..3248027
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/Dockerfile.ai-module-rf-detr
@@ -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"]
diff --git a/experiments/perception/worker/observatory_portable/Dockerfile.installed-lab-worker-agent b/experiments/perception/worker/observatory_portable/Dockerfile.installed-lab-worker-agent
index 01a5fd5..8e0ed97 100644
--- a/experiments/perception/worker/observatory_portable/Dockerfile.installed-lab-worker-agent
+++ b/experiments/perception/worker/observatory_portable/Dockerfile.installed-lab-worker-agent
@@ -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" \
diff --git a/experiments/perception/worker/observatory_portable/install_recorded_heartbeat_recovery.py b/experiments/perception/worker/observatory_portable/install_recorded_heartbeat_recovery.py
new file mode 100644
index 0000000..116af1c
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/install_recorded_heartbeat_recovery.py
@@ -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()
diff --git a/experiments/perception/worker/observatory_portable/install_recorded_source_reuse.py b/experiments/perception/worker/observatory_portable/install_recorded_source_reuse.py
new file mode 100644
index 0000000..e13d0e7
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/install_recorded_source_reuse.py
@@ -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()
diff --git a/experiments/perception/worker/observatory_portable/portable_lab_v1_component_adapter.py b/experiments/perception/worker/observatory_portable/portable_lab_v1_component_adapter.py
index aed37ac..97e3737 100644
--- a/experiments/perception/worker/observatory_portable/portable_lab_v1_component_adapter.py
+++ b/experiments/perception/worker/observatory_portable/portable_lab_v1_component_adapter.py
@@ -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")
diff --git a/experiments/perception/worker/observatory_portable/probe_recorded_source_reuse.py b/experiments/perception/worker/observatory_portable/probe_recorded_source_reuse.py
new file mode 100644
index 0000000..07f89cb
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/probe_recorded_source_reuse.py
@@ -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()
diff --git a/experiments/perception/worker/observatory_portable/run_ai_module_object_distance.py b/experiments/perception/worker/observatory_portable/run_ai_module_object_distance.py
new file mode 100644
index 0000000..4fd8f9e
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/run_ai_module_object_distance.py
@@ -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())
diff --git a/experiments/perception/worker/observatory_portable/run_ai_module_rf_detr.py b/experiments/perception/worker/observatory_portable/run_ai_module_rf_detr.py
new file mode 100644
index 0000000..4d1f089
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/run_ai_module_rf_detr.py
@@ -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())
diff --git a/experiments/perception/worker/observatory_portable/run_portable_camera_source_component.py b/experiments/perception/worker/observatory_portable/run_portable_camera_source_component.py
new file mode 100644
index 0000000..0ee8449
--- /dev/null
+++ b/experiments/perception/worker/observatory_portable/run_portable_camera_source_component.py
@@ -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())
diff --git a/experiments/perception/worker/observatory_portable/run_portable_lab_v1_ddrnet_component.py b/experiments/perception/worker/observatory_portable/run_portable_lab_v1_ddrnet_component.py
index b1e8721..c8688e1 100644
--- a/experiments/perception/worker/observatory_portable/run_portable_lab_v1_ddrnet_component.py
+++ b/experiments/perception/worker/observatory_portable/run_portable_lab_v1_ddrnet_component.py
@@ -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,
diff --git a/experiments/perception/worker/observatory_portable/run_portable_lab_v1_eomt_component.py b/experiments/perception/worker/observatory_portable/run_portable_lab_v1_eomt_component.py
index 83decd4..aa681f0 100644
--- a/experiments/perception/worker/observatory_portable/run_portable_lab_v1_eomt_component.py
+++ b/experiments/perception/worker/observatory_portable/run_portable_lab_v1_eomt_component.py
@@ -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:
diff --git a/scripts/build_m49_portable_executor_release.py b/scripts/build_m49_portable_executor_release.py
index 9767069..a5ce7d2 100644
--- a/scripts/build_m49_portable_executor_release.py
+++ b/scripts/build_m49_portable_executor_release.py
@@ -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"),
diff --git a/scripts/build_modular_ai_worker_release.py b/scripts/build_modular_ai_worker_release.py
new file mode 100644
index 0000000..6465598
--- /dev/null
+++ b/scripts/build_modular_ai_worker_release.py
@@ -0,0 +1,1088 @@
+#!/usr/bin/env python3
+"""Build a sealed Worker 006 release for independent Observatory AI modules."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import runpy
+import shutil
+from pathlib import Path
+from typing import Any, Final, cast
+
+from k1link.observatory.installed_lab_packages import (
+ INSTALLED_LAB_PACKAGE_REGISTRY_SCHEMA,
+ InstalledLabContainer,
+ InstalledLabPackageMount,
+ seal_installed_lab_package,
+)
+from k1link.observatory.modular_composition import ModuleRegistry
+from k1link.observatory.modular_installed_package_steps import (
+ MODULAR_PACKAGE_CONTRACT_SCHEMA,
+)
+from k1link.observatory.modular_result import MODULAR_RESULT_CONTRACT_SHA256
+from k1link.observatory.portable_run_definitions import (
+ PORTABLE_RESULT_CONTRACT_SCHEMA,
+ PORTABLE_RUN_DEFINITION_IDENTITY_SCHEMA,
+ PORTABLE_RUN_DEFINITION_REGISTRY_SCHEMA,
+ PORTABLE_SOURCE_ADAPTER_IDENTITY_SCHEMA,
+ PortableRunDefinition,
+ PortableRunDefinitionRegistry,
+ canonical_sha256,
+)
+from k1link.observatory.portable_worker_runtime import (
+ PORTABLE_WORKER_RUNTIME_CANDIDATE_SCHEMA,
+ PORTABLE_WORKER_RUNTIME_REGISTRY_SCHEMA,
+ PortableWorkerAssetRequirement,
+ PortableWorkerExecutorSeal,
+ PortableWorkerRuntimeCandidate,
+ PortableWorkerRuntimePhase,
+ PortableWorkerRuntimeRegistry,
+)
+from k1link.observatory.recorded_jobs import RecordedExecutorIdentity
+
+_LEGACY_PROMOTION = runpy.run_path(
+ str(
+ Path(__file__).resolve().parents[1]
+ / "experiments/perception/worker/observatory_portable/promote_installed_lab_v1_package.py"
+ )
+)
+_DDRNET_ASSETS = cast(tuple[tuple[str, str, str, int], ...], _LEGACY_PROMOTION["_DDRNET_ASSETS"])
+_EOMT_ASSETS = cast(tuple[tuple[str, str, str, int], ...], _LEGACY_PROMOTION["_EOMT_ASSETS"])
+_RUNTIME_ASSET_RELATIVE_PATHS = cast(
+ dict[str, str], _LEGACY_PROMOTION["_RUNTIME_ASSET_RELATIVE_PATHS"]
+)
+
+AUTHORITY: Final = {
+ "commands_enabled": False,
+ "actuation_allowed": False,
+ "navigation_or_safety_accepted": False,
+ "production_accepted": False,
+}
+AGENT_ASSET = "ai-module-agent-image"
+DEFINITIONS_ASSET = "ai-module-definition-registry"
+DDRNET_PROFILE_ASSET = "ai-module-ddrnet-profile"
+M49_PROFILE_ASSET = "ai-module-m49-profile"
+RF_ENGINE_ASSET = "rf-detr-engine"
+RF_VALID_FOV_ASSET = "rf-detr-valid-fov"
+K1_CALIBRATION_ASSET = "k1-camera-lidar-calibration"
+RF_ENGINE_SHA256 = "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"
+RF_ENGINE_BYTES = 68_316_388
+RF_VALID_FOV_SHA256 = "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
+RF_VALID_FOV_BYTES = 3_668
+K1_CALIBRATION_FILE_SHA256 = "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
+K1_CALIBRATION_FILE_BYTES = 72_996_000
+RELEASE_ID = "ai-modular-installed-package-v1"
+ADAPTER_ID = "ai-modular-package-worker006-v1"
+MODULES = ("ddrnet", "eomt", "rf-detr", "object-distance")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--agent-image-sha256", required=True)
+ parser.add_argument("--camera-source-image-sha256", required=True)
+ parser.add_argument("--ddrnet-image-sha256", required=True)
+ parser.add_argument("--eomt-image-sha256", required=True)
+ parser.add_argument("--rf-detr-image-sha256", required=True)
+ parser.add_argument("--object-distance-image-sha256", required=True)
+ parser.add_argument("--source-tree-sha256", required=True)
+ parser.add_argument("--engine-release-root", required=True)
+ parser.add_argument("--engine-work-root", required=True)
+ options = parser.parse_args()
+ build(
+ output=options.output,
+ agent_image=options.agent_image_sha256,
+ camera_image=options.camera_source_image_sha256,
+ ddrnet_image=options.ddrnet_image_sha256,
+ eomt_image=options.eomt_image_sha256,
+ rf_detr_image=options.rf_detr_image_sha256,
+ object_distance_image=options.object_distance_image_sha256,
+ source_tree_sha=options.source_tree_sha256,
+ engine_release_root=options.engine_release_root,
+ engine_work_root=options.engine_work_root,
+ )
+ return 0
+
+
+def build(
+ *,
+ output: Path,
+ agent_image: str,
+ camera_image: str,
+ ddrnet_image: str,
+ eomt_image: str,
+ rf_detr_image: str,
+ object_distance_image: str,
+ source_tree_sha: str,
+ engine_release_root: str,
+ engine_work_root: str,
+) -> None:
+ for value in (
+ agent_image,
+ camera_image,
+ ddrnet_image,
+ eomt_image,
+ rf_detr_image,
+ object_distance_image,
+ source_tree_sha,
+ ):
+ if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
+ raise ValueError("release image or source identity is invalid")
+ root = output.expanduser().absolute()
+ if root.exists():
+ shutil.rmtree(root)
+ root.mkdir(mode=0o700, parents=True)
+ repository = Path(__file__).resolve().parents[1]
+ base_registry_path = repository / "config" / "observatory-portable-run-definitions.json"
+ base_document = json.loads(base_registry_path.read_text())
+ base_definition = cast(dict[str, Any], base_document["definitions"][0])
+ m49_definition = cast(
+ dict[str, Any],
+ next(
+ row for row in base_document["definitions"] if row["setup_id"] == "m49-tgs-portable-v2"
+ ),
+ )
+ module_registry = ModuleRegistry.from_file(
+ repository / "config" / "observatory-ai-modules.json"
+ )
+ modules = {item.module_id: item for item in module_registry.modules}
+ release_sha = canonical_sha256(
+ {
+ "schema_version": "missioncore.observatory-ai-module-worker-release/v1",
+ "release_id": RELEASE_ID,
+ "source_tree_sha256": source_tree_sha,
+ "agent_image_sha256": agent_image,
+ "camera_source_image_sha256": camera_image,
+ "ddrnet_image_sha256": ddrnet_image,
+ "eomt_image_sha256": eomt_image,
+ "rf_detr_image_sha256": rf_detr_image,
+ "object_distance_image_sha256": object_distance_image,
+ "authority": AUTHORITY,
+ }
+ )
+ generated = [
+ _definition(
+ base_definition,
+ m49_definition,
+ module_id,
+ release_sha,
+ agent_image,
+ modules[module_id].implementation_sha256,
+ modules["rf-detr"].implementation_sha256,
+ )
+ for module_id in MODULES
+ ]
+ retained_definitions = [
+ row
+ for row in base_document["definitions"]
+ if row.get("setup_id")
+ not in {
+ "ai-segmentation-ddrnet-v1",
+ "ai-segmentation-eomt-v1",
+ "ai-detection-rf-detr-v1",
+ "ai-range-object-distance-v1",
+ }
+ ]
+ definitions_doc = {
+ "schema_version": PORTABLE_RUN_DEFINITION_REGISTRY_SCHEMA,
+ "definitions": [*retained_definitions, *generated],
+ }
+ definitions_path = root / "observatory-portable-run-definitions.json"
+ _write(definitions_path, definitions_doc)
+ definitions = PortableRunDefinitionRegistry.from_file(definitions_path)
+ profile_source = repository / "config" / "perception" / "lab-v1-eomt-ddrnet-portable-v2.json"
+ profile_path = root / "ddrnet-profile.json"
+ shutil.copyfile(profile_source, profile_path)
+ m49_profile_path = root / "m49-profile.json"
+ shutil.copyfile(repository / "config/perception/m49-tgs-portable-v2.json", m49_profile_path)
+ definition_registry_sha = _sha256(definitions_path)
+ module_images = {
+ "ddrnet": ddrnet_image,
+ "eomt": eomt_image,
+ "rf-detr": rf_detr_image,
+ "object-distance": object_distance_image,
+ }
+
+ contracts: dict[str, Path] = {}
+ for module_id in MODULES:
+ module = modules[module_id]
+ component_images = {"camera-source": camera_image, module_id: module_images[module_id]}
+ component_assets: dict[str, list[dict[str, object]]] = {
+ "camera-source": [
+ _component_asset(
+ "eomt-ffmpeg-runtime",
+ "/opt/nodedc/assets/ffmpeg-runtime",
+ _EOMT_ASSETS[1][2],
+ _EOMT_ASSETS[1][3],
+ tree=True,
+ )
+ ],
+ }
+ if module_id in {"ddrnet", "eomt"}:
+ component_assets[module_id] = [
+ _component_asset(*row, tree=module_id == "eomt")
+ for row in (_DDRNET_ASSETS if module_id == "ddrnet" else _EOMT_ASSETS)
+ ]
+ elif module_id == "rf-detr":
+ component_assets[module_id] = _rf_component_assets()
+ else:
+ component_images["rf-detr"] = rf_detr_image
+ component_assets["rf-detr"] = _rf_component_assets()
+ component_assets[module_id] = [
+ _component_asset(
+ K1_CALIBRATION_ASSET,
+ "/opt/nodedc/assets/k1-camera-lidar-calibration",
+ K1_CALIBRATION_FILE_SHA256,
+ K1_CALIBRATION_FILE_BYTES,
+ tree=False,
+ )
+ ]
+ path = root / f"{module_id}-package-contract.json"
+ _write(
+ path,
+ {
+ "schema_version": MODULAR_PACKAGE_CONTRACT_SCHEMA,
+ "setup_id": _setup_id(module_id),
+ "module": {
+ "module_id": module_id,
+ "label": module.label,
+ "module_sha256": module.sha256,
+ "image_sha256": module.image_sha256,
+ },
+ "executor": {
+ "release_id": RELEASE_ID,
+ "release_sha256": release_sha,
+ "image_sha256": agent_image,
+ },
+ "component_images": component_images,
+ "component_assets": component_assets,
+ "authority": AUTHORITY,
+ },
+ )
+ contracts[module_id] = path
+
+ candidates: list[PortableWorkerRuntimeCandidate] = []
+ packages = []
+ for module_id in MODULES:
+ definition = definitions.resolve_setup(_setup_id(module_id))
+ image = module_images[module_id]
+ requirements = _requirements(
+ definition=definition,
+ module_id=module_id,
+ agent_image=agent_image,
+ camera_image=camera_image,
+ module_image=image,
+ contract_path=contracts[module_id],
+ definitions_path=definitions_path,
+ profile_path=profile_path,
+ m49_profile_path=m49_profile_path,
+ rf_detr_image=rf_detr_image,
+ )
+ executor = PortableWorkerExecutorSeal(RELEASE_ID, release_sha, agent_image)
+ phases = tuple(
+ PortableWorkerRuntimePhase(value, "implemented")
+ for value in (
+ "source-materialization",
+ "camera-source",
+ f"{module_id}-inference",
+ "result-assembly",
+ )
+ )
+ candidate_identity = {
+ "schema_version": PORTABLE_WORKER_RUNTIME_CANDIDATE_SCHEMA,
+ "adapter_id": f"{ADAPTER_ID}-{module_id}",
+ "setup_id": definition.setup_id,
+ "definition_id": definition.definition_id,
+ "definition_version": definition.version,
+ "definition_sha256": definition.definition_sha256,
+ "source_adapter_sha256": definition.source_adapter.contract_sha256,
+ "model_manifest_sha256": definition.model_manifest_sha256,
+ "resource_profile_sha256": definition.resource_profile.profile_sha256,
+ "result_contract_sha256": definition.result_contract.contract_sha256,
+ "state": "ready",
+ "executor": executor.as_dict(),
+ "reusable_assets": [item.as_dict() for item in requirements],
+ "phases": [item.as_dict() for item in phases],
+ "blockers": [],
+ "authority": AUTHORITY,
+ }
+ candidate = PortableWorkerRuntimeCandidate(
+ adapter_id=f"{ADAPTER_ID}-{module_id}",
+ setup_id=definition.setup_id,
+ definition_id=definition.definition_id,
+ definition_version=definition.version,
+ definition_sha256=definition.definition_sha256,
+ source_adapter_sha256=definition.source_adapter.contract_sha256,
+ model_manifest_sha256=definition.model_manifest_sha256,
+ resource_profile_sha256=definition.resource_profile.profile_sha256,
+ result_contract_sha256=definition.result_contract.contract_sha256,
+ state="ready",
+ executor=executor,
+ reusable_assets=requirements,
+ phases=phases,
+ blockers=(),
+ candidate_sha256=canonical_sha256(candidate_identity),
+ )
+ candidate.bind_definition(definition)
+ candidates.append(candidate)
+ executor_identity = RecordedExecutorIdentity(
+ release_sha256=release_sha,
+ image_sha256=agent_image,
+ model_manifest_sha256=definition.model_manifest_sha256,
+ resource_profile_sha256=definition.resource_profile.profile_sha256,
+ )
+ package = seal_installed_lab_package(
+ package_id=f"{_setup_id(module_id)[:-3]}-package-v1",
+ package_version=1,
+ setup_id=definition.setup_id,
+ definition_id=definition.definition_id,
+ definition_version=definition.version,
+ definition_sha256=definition.definition_sha256,
+ runtime_candidate_sha256=candidate.candidate_sha256,
+ source_adapter_sha256=definition.source_adapter.contract_sha256,
+ result_contract_sha256=definition.result_contract.contract_sha256,
+ executor_identity=executor_identity,
+ execution_mode="fixed-stack",
+ asset_ids=tuple(item.asset_id for item in requirements),
+ containers=_containers(
+ module_id,
+ agent_image=agent_image,
+ camera_image=camera_image,
+ module_image=image,
+ rf_detr_image=rf_detr_image,
+ ),
+ )
+ package.bind(definition, candidate)
+ packages.append(package)
+
+ executor_identities = [package.executor_identity for package in packages]
+ if len(executor_identities) != len(set(executor_identities)):
+ raise ValueError("modular worker executor identities must be unique")
+
+ runtime_path = root / "observatory-worker-runtime-candidates.json"
+ _write(
+ runtime_path,
+ {
+ "schema_version": PORTABLE_WORKER_RUNTIME_REGISTRY_SCHEMA,
+ "candidates": [
+ {**item.identity_document(), "candidate_sha256": item.candidate_sha256}
+ for item in candidates
+ ],
+ },
+ )
+ packages_path = root / "observatory-installed-lab-packages.json"
+ _write(
+ packages_path,
+ {
+ "schema_version": INSTALLED_LAB_PACKAGE_REGISTRY_SCHEMA,
+ "packages": [
+ {**item.identity_document(), "package_sha256": item.package_sha256}
+ for item in packages
+ ],
+ },
+ )
+ PortableWorkerRuntimeRegistry.from_file(runtime_path, definitions=definitions)
+
+ bindings = _bindings(
+ requirements=tuple(
+ {
+ item.asset_id: item
+ for candidate in candidates
+ for item in candidate.reusable_assets
+ }.values()
+ ),
+ root=root,
+ engine_release_root=engine_release_root,
+ engine_work_root=engine_work_root,
+ agent_image=agent_image,
+ camera_image=camera_image,
+ ddrnet_image=ddrnet_image,
+ eomt_image=eomt_image,
+ rf_detr_image=rf_detr_image,
+ object_distance_image=object_distance_image,
+ )
+ _write(root / "observatory-installed-lab-asset-bindings.json", bindings)
+ _write(
+ root / "release-summary.json",
+ {
+ "schema_version": "missioncore.observatory-ai-module-worker-release-summary/v1",
+ "release_id": RELEASE_ID,
+ "release_sha256": release_sha,
+ "definition_registry_sha256": definition_registry_sha,
+ "source_tree_sha256": source_tree_sha,
+ "agent_image_sha256": agent_image,
+ "setups": [
+ {
+ "module_id": module_id,
+ "setup_id": definitions.resolve_setup(_setup_id(module_id)).setup_id,
+ "definition_sha256": definitions.resolve_setup(
+ _setup_id(module_id)
+ ).definition_sha256,
+ "candidate_sha256": candidates[index].candidate_sha256,
+ "package_sha256": packages[index].package_sha256,
+ }
+ for index, module_id in enumerate(MODULES)
+ ],
+ "authority": AUTHORITY,
+ },
+ )
+
+
+def _setup_id(module_id: str) -> str:
+ return {
+ "ddrnet": "ai-segmentation-ddrnet-v1",
+ "eomt": "ai-segmentation-eomt-v1",
+ "rf-detr": "ai-detection-rf-detr-v1",
+ "object-distance": "ai-range-object-distance-v1",
+ }[module_id]
+
+
+def _rf_component_assets() -> list[dict[str, object]]:
+ return [
+ _component_asset(
+ RF_ENGINE_ASSET,
+ "/models/rf_detr_large_native_kb4/1/model.plan",
+ RF_ENGINE_SHA256,
+ RF_ENGINE_BYTES,
+ tree=False,
+ ),
+ _component_asset(
+ RF_VALID_FOV_ASSET,
+ "/opt/nodedc/assets/valid-fov-mask",
+ RF_VALID_FOV_SHA256,
+ RF_VALID_FOV_BYTES,
+ tree=False,
+ ),
+ ]
+
+
+def _definition(
+ base: dict[str, Any],
+ m49: dict[str, Any],
+ module_id: str,
+ release_sha: str,
+ agent_image: str,
+ module_implementation_sha256: str,
+ rf_implementation_sha256: str,
+) -> dict[str, object]:
+ spatial = module_id == "object-distance"
+ requirements = dict(
+ m49["source_requirements"]
+ if spatial
+ else {**base["source_requirements"], "required_modalities": ["video"]}
+ )
+ if spatial:
+ adapter = dict(m49["source_adapter"])
+ else:
+ adapter = {
+ "adapter_id": "xgrids-k1-recorded-ai-layer-v1",
+ "version": 1,
+ }
+ adapter_identity = {
+ "schema_version": PORTABLE_SOURCE_ADAPTER_IDENTITY_SCHEMA,
+ **adapter,
+ "source": {
+ "plugin_id": requirements["plugin_id"],
+ "archive_id": requirements["archive_id"],
+ "required_modalities": requirements["required_modalities"],
+ "camera_source_id": requirements["camera_source_id"],
+ "camera_semantic_channel_id": requirements["camera_semantic_channel_id"],
+ },
+ "camera_profile": {
+ "media_type": requirements["recorded_media_type"],
+ "init_sha256": requirements["recorded_media_init_sha256"],
+ "width": requirements["camera_width"],
+ "height": requirements["camera_height"],
+ "attestation": "exact-isobmff-init-sha256",
+ },
+ "calibration": {
+ "slot": requirements["calibration_slot"],
+ "sha256": requirements["calibration_identity_sha256"],
+ "binding": "external-rig-profile",
+ },
+ "camera_epoch_policy": "exactly-one-complete-epoch",
+ "authority": AUTHORITY,
+ }
+ adapter["contract_sha256"] = canonical_sha256(adapter_identity)
+ by_component = {item["component_id"]: item for item in base["components"]}
+ if module_id == "eomt":
+ component_ids = (
+ "eomt-recorded-dependency-set-v1",
+ "eomt-recorded-orchestrator-v1",
+ "eomt-recorded-profile-v1",
+ "eomt-recorded-runner-v1",
+ "k1-camera-1-calibration-v1",
+ "k1-valid-fov-identity-v1",
+ "k1-valid-fov-mask-v1",
+ )
+ components = [by_component[value] for value in component_ids]
+ models = [item for item in base["models"] if item["release_id"].startswith("eomt-")]
+ elif module_id == "ddrnet":
+ components = [
+ {
+ "component_id": "ddrnet-recorded-profile-v1",
+ "kind": "profile",
+ "sha256": by_component["ddrnet-portable-runtime-config-v2"]["sha256"],
+ },
+ {
+ "component_id": "ddrnet-recorded-runner-v1",
+ "kind": "runner",
+ "sha256": _DDRNET_ASSETS[2][2],
+ },
+ by_component["ddrnet-portable-runtime-config-v2"],
+ by_component["k1-camera-1-calibration-v1"],
+ by_component["k1-valid-fov-identity-v1"],
+ by_component["k1-valid-fov-mask-v1"],
+ by_component["vegetation-mission-policy-v1"],
+ by_component["vegetation-provider-label-map-v1"],
+ ]
+ models = [item for item in base["models"] if item["release_id"].startswith("lab-v1-ddrnet")]
+ else:
+ components = [
+ by_component["k1-camera-1-calibration-v1"],
+ by_component["k1-valid-fov-identity-v1"],
+ by_component["k1-valid-fov-mask-v1"],
+ {
+ "component_id": (
+ "object-distance-recorded-profile-v1"
+ if spatial
+ else "rf-detr-recorded-profile-v1"
+ ),
+ "kind": "profile",
+ "sha256": module_implementation_sha256,
+ },
+ {
+ "component_id": (
+ "rf-detr-recorded-dependency-v1" if spatial else "rf-detr-recorded-runner-v1"
+ ),
+ "kind": "dependency-set" if spatial else "runner",
+ "sha256": rf_implementation_sha256,
+ },
+ ]
+ if spatial:
+ m49_components = {item["component_id"]: item for item in m49["components"]}
+ components.extend(
+ [
+ m49_components["m49-tgs-portable-profile-v2"],
+ {
+ "component_id": "object-distance-recorded-runner-v1",
+ "kind": "runner",
+ "sha256": module_implementation_sha256,
+ },
+ ]
+ )
+ models = [
+ {
+ "release_id": "rf-detr-large-native-kb4-v1",
+ "model_id": "rf-detr/large-native-kb4",
+ "revision": None,
+ "architecture": "RF-DETR-Large-TensorRT",
+ "artifacts": [
+ {
+ "role": "tensorrt-engine",
+ "byte_length": RF_ENGINE_BYTES,
+ "sha256": RF_ENGINE_SHA256,
+ }
+ ],
+ }
+ ]
+ camera_component = {
+ "component_id": "camera-source-recorded-v1",
+ "kind": "orchestrator",
+ "sha256": "87c9e8864f9469719e69bfd750c418e077944a6260ffc6fd3ce12daf4a1da7d5",
+ }
+ components = sorted([camera_component, *components], key=lambda value: value["component_id"])
+ result_contract = {
+ "schema_version": PORTABLE_RESULT_CONTRACT_SCHEMA,
+ "contract_id": "ai-layer-result",
+ "version": 1,
+ "result_schema": "missioncore.recorded-ai-layer-review/v1",
+ "result_kind": "recorded-ai-layer-review",
+ "publication": "observatory",
+ "contract_sha256": MODULAR_RESULT_CONTRACT_SHA256,
+ }
+ setup_id = _setup_id(module_id)
+ definition_id = setup_id.removesuffix("-v1")
+ resource_profile = dict(base["resource_profile"])
+ if module_id == "object-distance":
+ # The ranged-object package is a composite camera + detector + LiDAR
+ # workload. Its executor identity must not collapse onto the standalone
+ # RF-DETR executor merely because both use the same detector weights.
+ resource_profile["profile_id"] = "worker006-single-gpu-sequential-ai-object-distance-v1"
+ profile_identity = {
+ key: value for key, value in resource_profile.items() if key != "profile_sha256"
+ }
+ resource_profile["profile_sha256"] = canonical_sha256(profile_identity)
+ executor_identity = {
+ "contour_id": "worker-006",
+ "state": "ready",
+ "release_id": RELEASE_ID,
+ "release_sha256": release_sha,
+ "image_sha256": agent_image,
+ }
+ identity = {
+ "schema_version": PORTABLE_RUN_DEFINITION_IDENTITY_SCHEMA,
+ "setup_id": setup_id,
+ "definition_id": definition_id,
+ "version": 1,
+ "source_requirements": requirements,
+ "source_adapter": adapter,
+ "components": components,
+ "models": models,
+ "model_manifest_sha256": canonical_sha256(
+ {
+ "schema_version": "missioncore.observatory-portable-model-manifest/v2",
+ "models": models,
+ }
+ ),
+ "resource_profile": resource_profile,
+ "result_contract": result_contract,
+ "executor": executor_identity,
+ "authority": AUTHORITY,
+ }
+ return {
+ "setup_id": setup_id,
+ "definition_id": definition_id,
+ "version": 1,
+ "definition_sha256": canonical_sha256(identity),
+ "source_requirements": requirements,
+ "source_adapter": adapter,
+ "components": components,
+ "models": models,
+ "resource_profile": resource_profile,
+ "result_contract": result_contract,
+ "executor": {**executor_identity, "reason_code": None, "reason": None},
+ "authority": AUTHORITY,
+ }
+
+
+def _requirements(
+ *,
+ definition: PortableRunDefinition,
+ module_id: str,
+ agent_image: str,
+ camera_image: str,
+ module_image: str,
+ contract_path: Path,
+ definitions_path: Path,
+ profile_path: Path,
+ m49_profile_path: Path,
+ rf_detr_image: str,
+) -> tuple[PortableWorkerAssetRequirement, ...]:
+ result = [
+ _asset(AGENT_ASSET, "container-image", agent_image),
+ _asset("camera-source-image", "container-image", camera_image),
+ _asset(f"{module_id}-image", "container-image", module_image),
+ _asset(
+ f"{module_id}-package-contract",
+ "local-file",
+ _sha256(contract_path),
+ contract_path.stat().st_size,
+ ),
+ _asset(
+ DEFINITIONS_ASSET,
+ "local-file",
+ _sha256(definitions_path),
+ definitions_path.stat().st_size,
+ ),
+ ]
+ if module_id == "ddrnet":
+ result.append(
+ PortableWorkerAssetRequirement(
+ DDRNET_PROFILE_ASSET,
+ "definition-component",
+ _sha256(profile_path),
+ profile_path.stat().st_size,
+ "ddrnet-portable-runtime-config-v2",
+ None,
+ None,
+ )
+ )
+ result.append(
+ _asset("eomt-ffmpeg-runtime", "local-tree", _EOMT_ASSETS[1][2], _EOMT_ASSETS[1][3])
+ )
+ assets = _DDRNET_ASSETS
+ elif module_id == "eomt":
+ assets = _EOMT_ASSETS
+ else:
+ assets = ()
+ result.extend(
+ [
+ _asset(
+ "eomt-ffmpeg-runtime",
+ "local-tree",
+ _EOMT_ASSETS[1][2],
+ _EOMT_ASSETS[1][3],
+ ),
+ PortableWorkerAssetRequirement(
+ RF_ENGINE_ASSET,
+ "model-artifact",
+ RF_ENGINE_SHA256,
+ RF_ENGINE_BYTES,
+ None,
+ "rf-detr-large-native-kb4-v1",
+ "tensorrt-engine",
+ ),
+ _asset(
+ RF_VALID_FOV_ASSET,
+ "local-file",
+ RF_VALID_FOV_SHA256,
+ RF_VALID_FOV_BYTES,
+ ),
+ ]
+ )
+ if module_id == "object-distance":
+ result.extend(
+ [
+ _asset("rf-detr-image", "container-image", rf_detr_image),
+ _asset(
+ K1_CALIBRATION_ASSET,
+ "local-file",
+ K1_CALIBRATION_FILE_SHA256,
+ K1_CALIBRATION_FILE_BYTES,
+ ),
+ PortableWorkerAssetRequirement(
+ M49_PROFILE_ASSET,
+ "definition-component",
+ _sha256(m49_profile_path),
+ m49_profile_path.stat().st_size,
+ "m49-tgs-portable-profile-v2",
+ None,
+ None,
+ ),
+ ]
+ )
+ for asset_id, _target, sha, size in assets:
+ if module_id == "ddrnet" and asset_id == "ddrnet-checkpoint":
+ result.append(
+ PortableWorkerAssetRequirement(
+ asset_id,
+ "model-artifact",
+ sha,
+ size,
+ None,
+ "lab-v1-ddrnet-39-goose-fine-64-v1",
+ "checkpoint",
+ )
+ )
+ elif module_id == "ddrnet" and asset_id in {
+ "vegetation-policy",
+ "vegetation-provider-map",
+ }:
+ result.append(
+ PortableWorkerAssetRequirement(
+ asset_id,
+ "definition-component",
+ sha,
+ size,
+ "vegetation-mission-policy-v1"
+ if asset_id == "vegetation-policy"
+ else "vegetation-provider-label-map-v1",
+ None,
+ None,
+ )
+ )
+ elif not (module_id == "ddrnet" and asset_id == "eomt-ffmpeg-runtime"):
+ result.append(
+ _asset(asset_id, "local-tree" if module_id == "eomt" else "local-file", sha, size)
+ )
+ return tuple(sorted(result, key=lambda item: item.asset_id))
+
+
+def _containers(
+ module_id: str,
+ *,
+ agent_image: str,
+ camera_image: str,
+ module_image: str,
+ rf_detr_image: str,
+) -> tuple[InstalledLabContainer, ...]:
+ prepare_mounts = [
+ InstalledLabPackageMount(
+ f"{module_id}-package-contract", "/opt/nodedc/package/contract.json"
+ ),
+ InstalledLabPackageMount(
+ DEFINITIONS_ASSET, "/opt/nodedc/package/portable-run-definitions.json"
+ ),
+ ]
+ if module_id == "ddrnet":
+ prepare_mounts.append(
+ InstalledLabPackageMount(
+ DDRNET_PROFILE_ASSET, "/opt/nodedc/package/ddrnet-profile.json"
+ )
+ )
+ if module_id == "object-distance":
+ prepare_mounts.append(
+ InstalledLabPackageMount(M49_PROFILE_ASSET, "/opt/nodedc/package/m49-profile.json")
+ )
+ values = [
+ InstalledLabContainer(
+ "assemble",
+ "result-writer",
+ agent_image,
+ ("-m", "k1link.observatory.modular_installed_package_steps", "assemble"),
+ (module_id,),
+ tuple(sorted(prepare_mounts, key=lambda item: item.target)),
+ "none",
+ 0,
+ 8 * 1024**3,
+ 4_000_000_000,
+ 2048,
+ 4 * 1024**3,
+ 4 * 1024**3,
+ 7200,
+ ),
+ InstalledLabContainer(
+ "camera-source",
+ "step",
+ camera_image,
+ (
+ "/opt/nodedc/adapter/run_portable_camera_source_component.py",
+ "--package-step",
+ "camera-source",
+ ),
+ ("prepare",),
+ (InstalledLabPackageMount("eomt-ffmpeg-runtime", "/opt/nodedc/assets/ffmpeg-runtime"),),
+ "none",
+ 1,
+ 8 * 1024**3,
+ 4_000_000_000,
+ 1024,
+ 4 * 1024**3,
+ 4 * 1024**3,
+ 7200,
+ ),
+ InstalledLabContainer(
+ "prepare",
+ "step",
+ agent_image,
+ ("-m", "k1link.observatory.modular_installed_package_steps", "prepare"),
+ (),
+ tuple(sorted(prepare_mounts, key=lambda item: item.target)),
+ "none",
+ 0,
+ 4 * 1024**3,
+ 2_000_000_000,
+ 1024,
+ 4 * 1024**3,
+ 4 * 1024**3,
+ 7200,
+ ),
+ ]
+ if module_id in {"ddrnet", "eomt"}:
+ module_assets = _DDRNET_ASSETS if module_id == "ddrnet" else _EOMT_ASSETS
+ values.append(
+ InstalledLabContainer(
+ module_id,
+ "step",
+ module_image,
+ (
+ f"/opt/nodedc/adapter/run_portable_lab_v1_{module_id}_component.py",
+ "--package-step",
+ module_id,
+ ),
+ ("camera-source",),
+ tuple(
+ sorted(
+ (InstalledLabPackageMount(row[0], row[1]) for row in module_assets),
+ key=lambda item: (item.target, item.asset_id),
+ )
+ ),
+ "none",
+ 1,
+ (16 if module_id == "ddrnet" else 24) * 1024**3,
+ (4 if module_id == "ddrnet" else 8) * 1_000_000_000,
+ 2048,
+ 4 * 1024**3,
+ 4 * 1024**3,
+ 7200,
+ )
+ )
+ else:
+ values.append(
+ InstalledLabContainer(
+ "rf-detr",
+ "step",
+ rf_detr_image,
+ ("-B", "/probe/run_joint_pilot.py", "--package-step", "rf-detr"),
+ ("camera-source",),
+ _rf_package_mounts(),
+ "none",
+ 1,
+ 16 * 1024**3,
+ 8_000_000_000,
+ 2048,
+ 4 * 1024**3,
+ 4 * 1024**3,
+ 7200,
+ )
+ )
+ if module_id == "object-distance":
+ values.append(
+ InstalledLabContainer(
+ "object-distance",
+ "step",
+ module_image,
+ (
+ "-B",
+ "/probe/run_joint_pilot.py",
+ "--package-step",
+ "object-distance",
+ ),
+ ("rf-detr",),
+ (
+ InstalledLabPackageMount(
+ K1_CALIBRATION_ASSET,
+ "/opt/nodedc/assets/k1-camera-lidar-calibration",
+ ),
+ ),
+ "none",
+ 0,
+ 16 * 1024**3,
+ 8_000_000_000,
+ 2048,
+ 4 * 1024**3,
+ 4 * 1024**3,
+ 7200,
+ )
+ )
+ return tuple(sorted(values, key=lambda item: item.container_id))
+
+
+def _rf_package_mounts() -> tuple[InstalledLabPackageMount, ...]:
+ return tuple(
+ sorted(
+ (
+ InstalledLabPackageMount(
+ RF_ENGINE_ASSET, "/models/rf_detr_large_native_kb4/1/model.plan"
+ ),
+ InstalledLabPackageMount(RF_VALID_FOV_ASSET, "/opt/nodedc/assets/valid-fov-mask"),
+ ),
+ key=lambda item: (item.target, item.asset_id),
+ )
+ )
+
+
+def _bindings(
+ *,
+ requirements: tuple[PortableWorkerAssetRequirement, ...],
+ root: Path,
+ engine_release_root: str,
+ engine_work_root: str,
+ agent_image: str,
+ camera_image: str,
+ ddrnet_image: str,
+ eomt_image: str,
+ rf_detr_image: str,
+ object_distance_image: str,
+) -> dict[str, object]:
+ image_by_id = {
+ AGENT_ASSET: agent_image,
+ "camera-source-image": camera_image,
+ "ddrnet-image": ddrnet_image,
+ "eomt-image": eomt_image,
+ "rf-detr-image": rf_detr_image,
+ "object-distance-image": object_distance_image,
+ }
+ release_file_by_id = {
+ DEFINITIONS_ASSET: "observatory-portable-run-definitions.json",
+ DDRNET_PROFILE_ASSET: "ddrnet-profile.json",
+ M49_PROFILE_ASSET: "m49-profile.json",
+ "ddrnet-package-contract": "ddrnet-package-contract.json",
+ "eomt-package-contract": "eomt-package-contract.json",
+ "rf-detr-package-contract": "rf-detr-package-contract.json",
+ "object-distance-package-contract": "object-distance-package-contract.json",
+ }
+ runtime_file_by_id = {
+ RF_ENGINE_ASSET: (
+ "experiments\\m48n-native-candidate\\native-608x800-uint8-v3\\rf-detr-native-uint8.plan"
+ ),
+ RF_VALID_FOV_ASSET: (
+ "inputs\\e2\\valid-fov-mask-"
+ "b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2"
+ "\\mask.png"
+ ),
+ K1_CALIBRATION_ASSET: (
+ "derived\\e10-lidar-pack-"
+ "576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b"
+ "\\lidar-pack.npz"
+ ),
+ }
+ rows: list[dict[str, object]] = []
+ for item in sorted(requirements, key=lambda value: value.asset_id):
+ if item.asset_id in image_by_id:
+ rows.append(
+ {
+ "asset_id": item.asset_id,
+ "controller_path": None,
+ "engine_path": None,
+ "image_sha256": image_by_id[item.asset_id],
+ }
+ )
+ continue
+ release_name = release_file_by_id.get(item.asset_id)
+ if release_name:
+ rows.append(
+ {
+ "asset_id": item.asset_id,
+ "controller_path": f"/release/{release_name}",
+ "engine_path": f"{engine_release_root}\\{release_name}",
+ "image_sha256": None,
+ }
+ )
+ continue
+ relative = runtime_file_by_id.get(
+ item.asset_id, _RUNTIME_ASSET_RELATIVE_PATHS.get(item.asset_id)
+ )
+ if relative is None:
+ raise ValueError(f"no Worker binding for {item.asset_id}")
+ rows.append(
+ {
+ "asset_id": item.asset_id,
+ "controller_path": "/runtime/" + relative.replace("\\", "/"),
+ "engine_path": "D:\\NDC_MISSIONCORE\\runtime\\" + relative,
+ "image_sha256": None,
+ }
+ )
+ unique = {cast(str, row["asset_id"]): row for row in rows}
+ return {
+ "schema_version": "missioncore.observatory-installed-lab-asset-bindings/v1",
+ "engine_work_root": engine_work_root,
+ "assets": [unique[key] for key in sorted(unique)],
+ }
+
+
+def _asset(
+ asset_id: str, kind: str, sha: str, byte_length: int | None = None
+) -> PortableWorkerAssetRequirement:
+ return PortableWorkerAssetRequirement(
+ asset_id, cast(Any, kind), sha, byte_length, None, None, None
+ )
+
+
+def _component_asset(
+ asset_id: str, path: str, sha: str, byte_length: int, *, tree: bool
+) -> dict[str, object]:
+ return {
+ "asset_id": asset_id,
+ "path": path,
+ "kind": "tree" if tree else "file",
+ "verification": "identity-sha256" if tree else "sha256",
+ "identity_sha256": sha,
+ "byte_length": byte_length,
+ }
+
+
+def _write(path: Path, value: object) -> None:
+ path.write_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode())
+
+
+def _sha256(path: Path) -> str:
+ with path.open("rb") as stream:
+ return hashlib.file_digest(stream, "sha256").hexdigest()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/k1link/compute/lidar_preparation.py b/src/k1link/compute/lidar_preparation.py
new file mode 100644
index 0000000..b6b925e
--- /dev/null
+++ b/src/k1link/compute/lidar_preparation.py
@@ -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")
diff --git a/src/k1link/laboratory/canonical_rerun_overlay.py b/src/k1link/laboratory/canonical_rerun_overlay.py
index 4e8afe6..9b30bcc 100644
--- a/src/k1link/laboratory/canonical_rerun_overlay.py
+++ b/src/k1link/laboratory/canonical_rerun_overlay.py
@@ -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)
)
diff --git a/src/k1link/observatory/composition_runs.py b/src/k1link/observatory/composition_runs.py
new file mode 100644
index 0000000..cac3964
--- /dev/null
+++ b/src/k1link/observatory/composition_runs.py
@@ -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"]),
+ )
diff --git a/src/k1link/observatory/domain_ontology.py b/src/k1link/observatory/domain_ontology.py
new file mode 100644
index 0000000..fbdf9cb
--- /dev/null
+++ b/src/k1link/observatory/domain_ontology.py
@@ -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,
+ )
diff --git a/src/k1link/observatory/installed_lab_package_runner.py b/src/k1link/observatory/installed_lab_package_runner.py
index 8578243..ab3e3f2 100644
--- a/src/k1link/observatory/installed_lab_package_runner.py
+++ b/src/k1link/observatory/installed_lab_package_runner.py
@@ -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,
diff --git a/src/k1link/observatory/installed_lab_packages.py b/src/k1link/observatory/installed_lab_packages.py
index a86c58e..71ebcb0 100644
--- a/src/k1link/observatory/installed_lab_packages.py
+++ b/src/k1link/observatory/installed_lab_packages.py
@@ -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"
diff --git a/src/k1link/observatory/lab_view_profiles.py b/src/k1link/observatory/lab_view_profiles.py
new file mode 100644
index 0000000..5b90afd
--- /dev/null
+++ b/src/k1link/observatory/lab_view_profiles.py
@@ -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"],
+ )
diff --git a/src/k1link/observatory/m49_portable_source.py b/src/k1link/observatory/m49_portable_source.py
index eaddb76..8d01c62 100644
--- a/src/k1link/observatory/m49_portable_source.py
+++ b/src/k1link/observatory/m49_portable_source.py
@@ -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:
diff --git a/src/k1link/observatory/modular_composition.py b/src/k1link/observatory/modular_composition.py
new file mode 100644
index 0000000..b71465a
--- /dev/null
+++ b/src/k1link/observatory/modular_composition.py
@@ -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 "",
+ )
diff --git a/src/k1link/observatory/modular_composition_store.py b/src/k1link/observatory/modular_composition_store.py
new file mode 100644
index 0000000..8e4ade9
--- /dev/null
+++ b/src/k1link/observatory/modular_composition_store.py
@@ -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)
diff --git a/src/k1link/observatory/modular_installed_package_steps.py b/src/k1link/observatory/modular_installed_package_steps.py
new file mode 100644
index 0000000..100d0f9
--- /dev/null
+++ b/src/k1link/observatory/modular_installed_package_steps.py
@@ -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
diff --git a/src/k1link/observatory/modular_node_cache.py b/src/k1link/observatory/modular_node_cache.py
new file mode 100644
index 0000000..eeb8036
--- /dev/null
+++ b/src/k1link/observatory/modular_node_cache.py
@@ -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)
diff --git a/src/k1link/observatory/modular_result.py b/src/k1link/observatory/modular_result.py
new file mode 100644
index 0000000..351df00
--- /dev/null
+++ b/src/k1link/observatory/modular_result.py
@@ -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()
diff --git a/src/k1link/observatory/portable_lab_v1_executor.py b/src/k1link/observatory/portable_lab_v1_executor.py
index f246541..a1c20c6 100644
--- a/src/k1link/observatory/portable_lab_v1_executor.py
+++ b/src/k1link/observatory/portable_lab_v1_executor.py
@@ -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
diff --git a/src/k1link/observatory/portable_lab_v1_worker.py b/src/k1link/observatory/portable_lab_v1_worker.py
index c39b2d8..d110db9 100644
--- a/src/k1link/observatory/portable_lab_v1_worker.py
+++ b/src/k1link/observatory/portable_lab_v1_worker.py
@@ -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
diff --git a/src/k1link/observatory/portable_object_replay.py b/src/k1link/observatory/portable_object_replay.py
new file mode 100644
index 0000000..fc155ec
--- /dev/null
+++ b/src/k1link/observatory/portable_object_replay.py
@@ -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))
diff --git a/src/k1link/observatory/portable_publication_reconciler.py b/src/k1link/observatory/portable_publication_reconciler.py
index 494a30f..a13b5b1 100644
--- a/src/k1link/observatory/portable_publication_reconciler.py
+++ b/src/k1link/observatory/portable_publication_reconciler.py
@@ -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
diff --git a/src/k1link/observatory/portable_queue_binding.py b/src/k1link/observatory/portable_queue_binding.py
index d2a8ff6..f0b78f5 100644
--- a/src/k1link/observatory/portable_queue_binding.py
+++ b/src/k1link/observatory/portable_queue_binding.py
@@ -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,
*,
diff --git a/src/k1link/observatory/portable_replay.py b/src/k1link/observatory/portable_replay.py
index 0a16d33..b73cb4f 100644
--- a/src/k1link/observatory/portable_replay.py
+++ b/src/k1link/observatory/portable_replay.py
@@ -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"])
diff --git a/src/k1link/observatory/portable_semantic_replay.py b/src/k1link/observatory/portable_semantic_replay.py
new file mode 100644
index 0000000..a781c58
--- /dev/null
+++ b/src/k1link/observatory/portable_semantic_replay.py
@@ -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))
diff --git a/src/k1link/observatory/portable_setup_projection.py b/src/k1link/observatory/portable_setup_projection.py
index 353401d..a29ec6c 100644
--- a/src/k1link/observatory/portable_setup_projection.py
+++ b/src/k1link/observatory/portable_setup_projection.py
@@ -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 = {
diff --git a/src/k1link/observatory/portable_worker_integration.py b/src/k1link/observatory/portable_worker_integration.py
index 19e2ac9..d38432b 100644
--- a/src/k1link/observatory/portable_worker_integration.py
+++ b/src/k1link/observatory/portable_worker_integration.py
@@ -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
)
),
diff --git a/src/k1link/observatory/recorded_jobs.py b/src/k1link/observatory/recorded_jobs.py
index 3a219e2..b8a3fbb 100644
--- a/src/k1link/observatory/recorded_jobs.py
+++ b/src/k1link/observatory/recorded_jobs.py
@@ -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")
diff --git a/src/k1link/observatory/source_admission.py b/src/k1link/observatory/source_admission.py
index 6fb8261..c2b3954 100644
--- a/src/k1link/observatory/source_admission.py
+++ b/src/k1link/observatory/source_admission.py
@@ -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
diff --git a/src/k1link/observatory/worker_agent.py b/src/k1link/observatory/worker_agent.py
index 621b384..06f1f35 100644
--- a/src/k1link/observatory/worker_agent.py
+++ b/src/k1link/observatory/worker_agent.py
@@ -15,8 +15,10 @@ from __future__ import annotations
import hashlib
import json
+import logging
import re
import threading
+import time
from collections.abc import Callable, Mapping
from contextlib import nullcontext
from dataclasses import dataclass
@@ -36,6 +38,7 @@ from k1link.observatory.recorded_progress import observe_recorded_execution
WORKER_006_CONTOUR_ID: Final = "worker-006"
MAX_EXECUTOR_FAILURE_MESSAGE_LENGTH: Final = 512
+_LOG = logging.getLogger(__name__)
_JOB_ID_PATTERN: Final = r"^observatory-run-[a-f0-9]{32}$"
_CLAIM_TOKEN_PATTERN: Final = r"^[a-f0-9]{64}$"
@@ -97,6 +100,10 @@ class ObservatoryWorkerClaimRejectedError(ObservatoryWorkerAgentError):
"""A transport supplied an unknown, spoofed, or corrupted claim."""
+class ObservatoryWorkerTransientTransportError(ObservatoryWorkerAgentError):
+ """A bounded request may be replayed with its unchanged idempotent identity."""
+
+
class ObservatoryWorkerExecutorUnavailableError(ObservatoryWorkerAgentError):
"""No local adapter matches the exact sealed executor identity."""
@@ -505,16 +512,28 @@ class ObservatoryWorkerAgent:
observe_recorded_execution(
active_job.claim_generation,
lambda snapshot: send_progress(
- job_id=active_job.job_id, claim_token=claim.claim_token,
+ job_id=active_job.job_id,
+ claim_token=claim.claim_token,
progress=snapshot,
),
- ) if callable(send_progress) else nullcontext()
+ )
+ if callable(send_progress)
+ else nullcontext()
)
with observation:
result = adapter.execute(active_job)
if not isinstance(result, ObservatoryWorkerExecutionResult):
raise TypeError("executor returned an unknown result contract")
except Exception as exc:
+ # Do not expose exception text, request headers, paths or secrets.
+ # Preserve the independent executor failure even if its claim was lost.
+ _LOG.error(
+ "Recorded executor failed job=%s generation=%s error_class=%s cause_class=%s",
+ active_job.job_id,
+ active_job.claim_generation,
+ type(exc).__name__,
+ type(exc.__cause__).__name__ if exc.__cause__ else "none",
+ )
heartbeat.stop()
if heartbeat.failed:
return ObservatoryWorkerCycleReport(
@@ -594,6 +613,7 @@ class _ClaimHeartbeat:
claim_token: str,
interval_seconds: float,
stop_timeout_seconds: float,
+ clock: Callable[[], float] = time.monotonic,
) -> None:
if interval_seconds <= 0:
raise ValueError("Worker heartbeat interval must be positive")
@@ -602,6 +622,11 @@ class _ClaimHeartbeat:
self._claim_token = claim_token
self._interval_seconds = interval_seconds
self._stop_timeout_seconds = stop_timeout_seconds
+ self._clock = clock
+ self._lease_seconds = _heartbeat_lease_seconds(
+ job.claim_expires_at_utc,
+ job.claim_heartbeat_at_utc,
+ )
self._stop = threading.Event()
self._state_lock = threading.Lock()
self._failure: Exception | None = None
@@ -631,11 +656,20 @@ class _ClaimHeartbeat:
def _run(self) -> None:
sequence = self._job.claim_renewal_count + 1
+ # Local monotonic deadlines do not depend on Worker/Core clock skew.
+ # Server-side lease/generation checks remain the final authority.
+ deadline = self._clock() + self._lease_seconds
+ sequence_started = self._clock()
+ retrying = False
# Renew immediately once start has been acknowledged. Waiting a full
# interval here would assume that claim/start transport latency consumed
# none of the original lease window.
while not self._stop.is_set():
try:
+ if self._clock() >= deadline:
+ raise ObservatoryWorkerClaimRejectedError(
+ "Worker heartbeat lease budget expired"
+ )
acknowledgement = self._transport.renew_claim(
claimant_id=WORKER_006_CONTOUR_ID,
job_id=self._job.job_id,
@@ -648,25 +682,74 @@ class _ClaimHeartbeat:
expected_job=self._job,
expected_state=("claimed", "running"),
)
- if (
- renewed.claim_lease is None
- or renewed.claim_lease.renewal_count != sequence
- ):
+ if renewed.claim_lease is None or renewed.claim_lease.renewal_count != sequence:
raise ObservatoryWorkerClaimRejectedError(
"Worker heartbeat acknowledgement changed its sequence"
)
+ deadline = sequence_started + _heartbeat_lease_seconds(
+ renewed.claim_lease.expires_at_utc,
+ renewed.claim_lease.heartbeat_at_utc,
+ )
+ if self._clock() >= deadline:
+ raise ObservatoryWorkerClaimRejectedError(
+ "Worker heartbeat acknowledgement expired"
+ )
+ if retrying:
+ _LOG.warning(
+ "Recorded heartbeat recovered job=%s generation=%s sequence=%s",
+ self._job.job_id,
+ self._job.claim_generation,
+ sequence,
+ )
+ retrying = False
sequence += 1
+ except ObservatoryWorkerTransientTransportError as exc:
+ if not retrying:
+ _LOG.warning(
+ "Recorded heartbeat retry job=%s generation=%s sequence=%s error_class=%s",
+ self._job.job_id,
+ self._job.claim_generation,
+ sequence,
+ type(exc).__name__,
+ )
+ retrying = True
+ remaining = deadline - self._clock()
+ if remaining <= 0 or self._stop.wait(
+ min(5.0, self._interval_seconds, max(0.0, remaining))
+ ):
+ # Finishing compute during an unacknowledged renewal does
+ # not turn uncertain ownership into successful publication.
+ self._record_failure(exc)
+ return
+ continue
except Exception as exc:
self._record_failure(exc)
self._stop.set()
return
if self._stop.wait(self._interval_seconds):
return
+ sequence_started = self._clock()
def _record_failure(self, exc: Exception) -> None:
with self._state_lock:
if self._failure is None:
self._failure = exc
+ _LOG.error(
+ "Recorded heartbeat lost job=%s generation=%s error_class=%s cause_class=%s",
+ self._job.job_id,
+ self._job.claim_generation,
+ type(exc).__name__,
+ type(exc.__cause__).__name__ if exc.__cause__ else "none",
+ )
+
+
+def _heartbeat_lease_seconds(expires: str | None, renewed: str | None) -> float:
+ if expires is None or renewed is None:
+ raise ObservatoryWorkerClaimRejectedError("Worker heartbeat has no lease clock")
+ duration = (datetime.fromisoformat(expires) - datetime.fromisoformat(renewed)).total_seconds()
+ if not 0 < duration <= 3600:
+ raise ObservatoryWorkerClaimRejectedError("Worker heartbeat lease duration is invalid")
+ return duration
def _validate_claim(
@@ -687,8 +770,7 @@ def _validate_claim(
"claim_request_id": claim_request_id,
"claimant_id": WORKER_006_CONTOUR_ID,
"supported_executor_identities": [
- identity.as_dict()
- for identity in sorted(supported_executor_identities)
+ identity.as_dict() for identity in sorted(supported_executor_identities)
],
}
)
@@ -699,9 +781,7 @@ def _validate_claim(
"Worker claim job is not in a claimed generation"
)
if claim.job.claim_lease is None:
- raise ObservatoryWorkerClaimRejectedError(
- "Worker claim has no renewable lease"
- )
+ raise ObservatoryWorkerClaimRejectedError("Worker claim has no renewable lease")
if claim.job.result is not None or claim.job.terminal is not None:
raise ObservatoryWorkerClaimRejectedError(
"Worker claim already carries a terminal outcome"
@@ -889,12 +969,9 @@ def _default_claim_request_id() -> str:
def _default_heartbeat_interval(job: SealedObservatoryRecordedJob) -> float:
if job.claim_heartbeat_at_utc is None or job.claim_expires_at_utc is None:
- raise ObservatoryWorkerClaimRejectedError(
- "Worker claim has no heartbeat lease bounds"
- )
+ raise ObservatoryWorkerClaimRejectedError("Worker claim has no heartbeat lease bounds")
remaining = (
- _parse_timestamp(job.claim_expires_at_utc)
- - _parse_timestamp(job.claim_heartbeat_at_utc)
+ _parse_timestamp(job.claim_expires_at_utc) - _parse_timestamp(job.claim_heartbeat_at_utc)
).total_seconds()
if remaining <= 0:
raise ObservatoryWorkerClaimRejectedError("Worker claim lease already expired")
diff --git a/src/k1link/observatory/worker_http_transport.py b/src/k1link/observatory/worker_http_transport.py
index e95bd2d..ca4d0de 100644
--- a/src/k1link/observatory/worker_http_transport.py
+++ b/src/k1link/observatory/worker_http_transport.py
@@ -56,9 +56,11 @@ from k1link.observatory.worker_agent import (
WORKER_006_CONTOUR_ID,
ObservatoryWorkerExecutionResult,
ObservatoryWorkerExecutorIdentity,
+ ObservatoryWorkerTransientTransportError,
ObservatoryWorkerTransport,
SealedObservatoryRecordedJob,
)
+from k1link.observatory.worker_source_cache import WorkerSourceCache, WorkerSourceCacheError
WORKER_HTTP_MAX_JSON_BYTES: Final = 16 * 1024 * 1024
WORKER_HTTP_COPY_CHUNK_BYTES: Final = 1024 * 1024
@@ -84,6 +86,13 @@ class ObservatoryWorkerHttpError(RuntimeError):
"""The authenticated Worker HTTP boundary failed closed."""
+class ObservatoryWorkerHttpTransientError(
+ ObservatoryWorkerHttpError,
+ ObservatoryWorkerTransientTransportError,
+):
+ """Temporary unavailability, not permission to replay arbitrary operations."""
+
+
@dataclass(frozen=True, slots=True)
class _ClaimContext:
job_id: str
@@ -128,9 +137,7 @@ class _SourceDestinationLayout:
if member.primary:
return self.root / "mqtt.raw.k1mqtt"
if self.spatial is None:
- raise ObservatoryWorkerHttpError(
- "spatial source destination is unavailable"
- )
+ raise ObservatoryWorkerHttpError("spatial source destination is unavailable")
return self.spatial / member.member_id
if member.kind == "spatial-replay-metadata":
return self.root / "mqtt.metadata.jsonl"
@@ -165,6 +172,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
contour_id: str = WORKER_006_CONTOUR_ID,
timeout_seconds: float = WORKER_HTTP_DEFAULT_TIMEOUT_SECONDS,
transport: httpx.BaseTransport | None = None,
+ source_cache_root: Path | None = None,
) -> None:
self._base_url = _validated_base_url(base_url)
if _TOKEN.fullmatch(bearer_token) is None:
@@ -180,6 +188,9 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
}
self._timeout_seconds = timeout_seconds
self._work_root = _secure_directory(work_root)
+ self._source_cache = WorkerSourceCache(
+ source_cache_root or self._work_root / "source-cas-v1"
+ )
self._client = httpx.Client(
base_url=self._base_url,
headers=self._headers,
@@ -217,8 +228,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
"schema_version": "missioncore.observatory-worker-claim-request/v3",
"claim_request_id": claim_request_id,
"supported_executor_identities": [
- identity.as_dict()
- for identity in sorted(supported_executor_identities)
+ identity.as_dict() for identity in sorted(supported_executor_identities)
],
},
allow_empty=True,
@@ -272,6 +282,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
return self._required_json_request(
"POST",
self._job_path(job_id, "lease/renew"),
+ timeout_seconds=5.0,
json_body={
"schema_version": "missioncore.observatory-worker-renew-request/v1",
"claim_token": claim_token,
@@ -281,18 +292,25 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
)
def report_progress(
- self, *, job_id: str, claim_token: str, progress: RecordedProgress,
+ self,
+ *,
+ job_id: str,
+ claim_token: str,
+ progress: RecordedProgress,
) -> None:
context = self._require_cached_claim(job_id, claim_token)
if progress.claim_generation != context.claim_generation:
raise ObservatoryWorkerHttpError("progress generation changed")
# No response body is needed. Bounded I/O cannot stall the execution thread.
with self._client.stream(
- "POST", self._job_path(job_id, "progress"),
+ "POST",
+ self._job_path(job_id, "progress"),
json={
"schema_version": "missioncore.observatory-worker-progress-request/v1",
- "claim_token": claim_token, "progress": progress.model_dump(mode="json"),
- }, timeout=httpx.Timeout(2.0),
+ "claim_token": claim_token,
+ "progress": progress.model_dump(mode="json"),
+ },
+ timeout=httpx.Timeout(2.0),
) as response:
if response.status_code != 204:
raise ObservatoryWorkerHttpError("progress observation was not accepted")
@@ -354,7 +372,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
headers=headers,
)
members = _source_members(manifest, job)
- report_recorded_progress("source-transfer", 0, len(members), "members")
+ report_recorded_progress("source-preparation", 0, len(members), "members")
root = _secure_directory(
self._work_root
/ "sources"
@@ -371,40 +389,56 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
"source materialization members select the same local role"
)
destinations[destination] = member
+ ready_members: set[str] = set()
+ try:
+ for checked_members, (destination, member) in enumerate(destinations.items(), start=1):
+ if _matches_file(
+ destination, member.sha256, member.byte_length
+ ) or self._source_cache.restore(
+ destination,
+ sha256=member.sha256,
+ byte_length=member.byte_length,
+ ):
+ ready_members.add(member.member_id)
+ report_recorded_progress(
+ "source-preparation", checked_members, len(members), "members"
+ )
+ except WorkerSourceCacheError as exc:
+ raise ObservatoryWorkerHttpError("Worker source cache admission failed") from exc
camera_members = _ordered_camera_epoch_members(members)
- camera_epoch_ready = all(
- _matches_file(
- layout.destination(member),
- member.sha256,
- member.byte_length,
- )
- for member in camera_members
- )
- if not camera_epoch_ready:
+ camera_epoch_ready = all(member.member_id in ready_members for member in camera_members)
+ # A partly cached epoch needs only missing members, not another complete
+ # epoch archive. Cold delivery retains the existing packed transport.
+ if not camera_epoch_ready and not any(
+ member.member_id in ready_members for member in camera_members
+ ):
camera_epoch_ready = self._download_camera_epoch_archive(
job=job,
context=context,
members=camera_members,
layout=layout,
)
- completed_members = len(camera_members) if camera_epoch_ready else 0
+ if camera_epoch_ready:
+ ready_members.update(member.member_id for member in camera_members)
+ completed_members = len(ready_members)
report_recorded_progress("source-transfer", completed_members, len(members), "members")
for destination, member in destinations.items():
- if camera_epoch_ready and member.kind in {"camera-init", "camera-segment"}:
- continue
- if _matches_file(destination, member.sha256, member.byte_length):
- completed_members += 1
- report_recorded_progress(
- "source-transfer", completed_members, len(members), "members",
+ if member.member_id not in ready_members:
+ self._download_member(
+ job_id=job.job_id,
+ context=context,
+ member=member,
+ destination=destination,
)
- continue
- self._download_member(
- job_id=job.job_id,
- context=context,
- member=member,
- destination=destination,
- )
- completed_members += 1
+ completed_members += 1
+ try:
+ self._source_cache.retain(
+ destination,
+ sha256=member.sha256,
+ byte_length=member.byte_length,
+ )
+ except WorkerSourceCacheError as exc:
+ raise ObservatoryWorkerHttpError("Worker source cache publication failed") from exc
report_recorded_progress("source-transfer", completed_members, len(members), "members")
manifest_path = root / "materialization-manifest.json"
_write_local_exact(manifest_path, canonical_json(manifest))
@@ -426,9 +460,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
try:
package = PortableResultPackageManifest.from_bytes(manifest_payload)
except Exception as exc:
- raise ObservatoryWorkerHttpError(
- "Worker result draft manifest is invalid"
- ) from exc
+ raise ObservatoryWorkerHttpError("Worker result draft manifest is invalid") from exc
if (
package.manifest_sha256 != draft.result_sha256
or package.result.get("result_id") != draft.result_id
@@ -441,9 +473,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
"claim_generation": job.claim_generation,
}
):
- raise ObservatoryWorkerHttpError(
- "Worker result draft belongs to another sealed job"
- )
+ raise ObservatoryWorkerHttpError("Worker result draft belongs to another sealed job")
headers = self._claim_headers(context)
path = self._job_path(
job.job_id,
@@ -456,9 +486,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
content=manifest_payload,
)
if plan.get("package_identity_sha256") != package.identity_sha256:
- raise ObservatoryWorkerHttpError(
- "result upload plan uses another package identity"
- )
+ raise ObservatoryWorkerHttpError("result upload plan uses another package identity")
upload_members = _upload_members(plan, job, draft)
artifacts = {artifact.role: artifact for artifact in package.artifacts}
if (
@@ -478,19 +506,25 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
}
)
).hexdigest()
- if artifact is None or (
- artifact.media_type,
- artifact.byte_length,
- artifact.sha256,
- ) != (member.media_type, member.byte_length, member.sha256) or (
- member.member_id != expected_member_id
+ if (
+ artifact is None
+ or (
+ artifact.media_type,
+ artifact.byte_length,
+ artifact.sha256,
+ )
+ != (member.media_type, member.byte_length, member.sha256)
+ or (member.member_id != expected_member_id)
):
raise ObservatoryWorkerHttpError(
"result upload plan differs from the local manifest"
)
if member.uploaded:
report_recorded_progress(
- "result-transfer", member_index + 1, len(upload_members), "members",
+ "result-transfer",
+ member_index + 1,
+ len(upload_members),
+ "members",
)
continue
relative = relative_artifact_path(artifact.relative_path)
@@ -500,10 +534,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
"PUT",
self._job_path(
job.job_id,
- (
- f"result-packages/{draft.result_sha256}/members/"
- f"{member.member_id}"
- ),
+ (f"result-packages/{draft.result_sha256}/members/{member.member_id}"),
),
headers={**headers, "Content-Type": member.media_type},
content=stream,
@@ -514,14 +545,16 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
)
updated_members = _upload_members(updated, job, draft)
if not any(
- item.member_id == member.member_id and item.uploaded
- for item in updated_members
+ item.member_id == member.member_id and item.uploaded for item in updated_members
):
raise ObservatoryWorkerHttpError(
"result upload acknowledgement did not seal its member"
)
report_recorded_progress(
- "result-transfer", member_index + 1, len(upload_members), "members",
+ "result-transfer",
+ member_index + 1,
+ len(upload_members),
+ "members",
)
receipt = self._required_json_request(
"POST",
@@ -536,23 +569,17 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
"job_id": job.job_id,
"job_identity_sha256": job.identity_sha256,
"claim_generation": job.claim_generation,
- "claim_token_sha256": hashlib.sha256(
- context.claim_token.encode("ascii")
- ).hexdigest(),
+ "claim_token_sha256": hashlib.sha256(context.claim_token.encode("ascii")).hexdigest(),
"result_id": draft.result_id,
"result_sha256": draft.result_sha256,
"package_identity_sha256": package.identity_sha256,
"member_count": len(package.artifacts),
- "total_bytes": sum(
- artifact.byte_length for artifact in package.artifacts
- ),
+ "total_bytes": sum(artifact.byte_length for artifact in package.artifacts),
"authority": dict(OBSERVATION_ONLY_AUTHORITY),
}
expected_receipt = {
**receipt_identity,
- "receipt_sha256": hashlib.sha256(
- canonical_json(receipt_identity)
- ).hexdigest(),
+ "receipt_sha256": hashlib.sha256(canonical_json(receipt_identity)).hexdigest(),
}
if receipt != expected_receipt:
raise ObservatoryWorkerHttpError(
@@ -593,20 +620,12 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
return False
self._raise_for_status(response)
content_encoding = response.headers.get("content-encoding")
- if (
- content_encoding is not None
- and content_encoding.lower() != "identity"
- ):
+ if content_encoding is not None and content_encoding.lower() != "identity":
raise ObservatoryWorkerHttpError(
"camera epoch archive content encoding changed"
)
- if (
- response.headers.get("content-type")
- != PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE
- ):
- raise ObservatoryWorkerHttpError(
- "camera epoch archive media type changed"
- )
+ if response.headers.get("content-type") != PORTABLE_CAMERA_EPOCH_ARCHIVE_MEDIA_TYPE:
+ raise ObservatoryWorkerHttpError("camera epoch archive media type changed")
declared = response.headers.get("content-length")
if (
declared is None
@@ -615,9 +634,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
or len(declared) > len(str(MAX_CAMERA_EPOCH_ARCHIVE_BYTES))
or str(int(declared)) != declared
):
- raise ObservatoryWorkerHttpError(
- "camera epoch archive length is invalid"
- )
+ raise ObservatoryWorkerHttpError("camera epoch archive length is invalid")
expected_bytes = int(declared)
expected_sha256 = response.headers.get(_CONTENT_SHA_HEADER, "")
archive_id = response.headers.get(
@@ -629,15 +646,10 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
or _SHA256.fullmatch(expected_sha256) is None
or _SHA256.fullmatch(archive_id) is None
):
- raise ObservatoryWorkerHttpError(
- "camera epoch archive seal is invalid"
- )
+ raise ObservatoryWorkerHttpError("camera epoch archive seal is invalid")
descriptor = os.open(
temporary,
- os.O_WRONLY
- | os.O_CREAT
- | os.O_EXCL
- | getattr(os, "O_NOFOLLOW", 0),
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
0o600,
)
digest = hashlib.sha256()
@@ -671,9 +683,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
sha256=expected_sha256,
)
if archive_id != expected_archive_id:
- raise ObservatoryWorkerHttpError(
- "camera epoch archive identity changed"
- )
+ raise ObservatoryWorkerHttpError("camera epoch archive identity changed")
_extract_camera_epoch_archive(
temporary,
layout=layout,
@@ -715,28 +725,16 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
) as response:
self._raise_for_status(response)
content_encoding = response.headers.get("content-encoding")
- if (
- content_encoding is not None
- and content_encoding.lower() != "identity"
- ):
- raise ObservatoryWorkerHttpError(
- "source member content encoding changed"
- )
+ if content_encoding is not None and content_encoding.lower() != "identity":
+ raise ObservatoryWorkerHttpError("source member content encoding changed")
declared = response.headers.get("content-length")
if declared is not None and declared != str(member.byte_length):
- raise ObservatoryWorkerHttpError(
- "source member Content-Length changed"
- )
+ raise ObservatoryWorkerHttpError("source member Content-Length changed")
if response.headers.get(_CONTENT_SHA_HEADER) != member.sha256:
- raise ObservatoryWorkerHttpError(
- "source member digest header changed"
- )
+ raise ObservatoryWorkerHttpError("source member digest header changed")
descriptor = os.open(
temporary,
- os.O_WRONLY
- | os.O_CREAT
- | os.O_EXCL
- | getattr(os, "O_NOFOLLOW", 0),
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
0o600,
)
with os.fdopen(descriptor, "wb") as stream:
@@ -751,9 +749,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
stream.flush()
os.fsync(stream.fileno())
if byte_length != member.byte_length or digest.hexdigest() != member.sha256:
- raise ObservatoryWorkerHttpError(
- "source member content differs from its manifest"
- )
+ raise ObservatoryWorkerHttpError("source member content differs from its manifest")
_publish_local_file(temporary, destination, member.sha256, member.byte_length)
finally:
with suppress(FileNotFoundError):
@@ -767,6 +763,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
json_body: object | None = None,
headers: Mapping[str, str] | None = None,
content: bytes | object | None = None,
+ timeout_seconds: float | None = None,
) -> dict[str, object]:
payload = self._json_request(
method,
@@ -774,6 +771,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
json_body=json_body,
headers=headers,
content=content,
+ timeout_seconds=timeout_seconds,
)
if payload is None:
raise ObservatoryWorkerHttpError("Worker endpoint returned no document")
@@ -788,6 +786,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
headers: Mapping[str, str] | None = None,
content: bytes | object | None = None,
allow_empty: bool = False,
+ timeout_seconds: float | None = None,
) -> dict[str, object] | None:
try:
with self._client.stream(
@@ -796,6 +795,7 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
json=json_body,
headers=headers,
content=content, # type: ignore[arg-type]
+ timeout=self._client.timeout if timeout_seconds is None else timeout_seconds,
) as response:
if allow_empty and response.status_code == 204:
return None
@@ -804,11 +804,13 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
for chunk in response.iter_bytes():
payload.extend(chunk)
if len(payload) > WORKER_HTTP_MAX_JSON_BYTES:
- raise ObservatoryWorkerHttpError(
- "Worker JSON response exceeds bounds"
- )
+ raise ObservatoryWorkerHttpError("Worker JSON response exceeds bounds")
except ObservatoryWorkerHttpError:
raise
+ except httpx.RequestError as exc:
+ raise ObservatoryWorkerHttpTransientError(
+ "Worker HTTP transport is unavailable"
+ ) from exc
except httpx.HTTPError as exc:
raise ObservatoryWorkerHttpError("Worker HTTP transport is unavailable") from exc
if not payload:
@@ -825,6 +827,10 @@ class ObservatoryWorkerHttpGateway(ObservatoryWorkerTransport):
if response.is_redirect:
raise ObservatoryWorkerHttpError("Worker endpoint redirect was rejected")
if response.status_code < 200 or response.status_code >= 300:
+ if response.status_code in {408, 429, 500, 502, 503, 504}:
+ raise ObservatoryWorkerHttpTransientError(
+ f"Worker endpoint temporarily unavailable with HTTP {response.status_code}"
+ )
raise ObservatoryWorkerHttpError(
f"Worker endpoint rejected the request with HTTP {response.status_code}"
)
@@ -898,17 +904,13 @@ def _source_members(
or document.get("claim_generation") != job.claim_generation
or document.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
- raise ObservatoryWorkerHttpError(
- "source materialization belongs to another sealed job"
- )
+ raise ObservatoryWorkerHttpError("source materialization belongs to another sealed job")
source = _object(document.get("source"), "source materialization identity")
if (
- set(source)
- != {"session_id", "bundle_sha256", "capability_manifest_sha256"}
+ set(source) != {"session_id", "bundle_sha256", "capability_manifest_sha256"}
or source.get("session_id") != job.source_session_id
or source.get("bundle_sha256") != job.source_bundle_sha256
- or source.get("capability_manifest_sha256")
- != job.source_capability_manifest_sha256
+ or source.get("capability_manifest_sha256") != job.source_capability_manifest_sha256
):
raise ObservatoryWorkerHttpError("source materialization identity changed")
values = document.get("members")
@@ -919,8 +921,7 @@ def _source_members(
len({member.member_id for member in members}) != len(members)
or tuple(member.member_id for member in members)
!= tuple(sorted(member.member_id for member in members))
- or sum(member.byte_length for member in members)
- > 2 * 1024 * 1024 * 1024 * 1024
+ or sum(member.byte_length for member in members) > 2 * 1024 * 1024 * 1024 * 1024
):
raise ObservatoryWorkerHttpError("source materialization bounds changed")
for member in members:
@@ -941,29 +942,18 @@ def _source_members(
)
).hexdigest()
if member.member_id != expected_member_id:
- raise ObservatoryWorkerHttpError(
- "source materialization member identity changed"
- )
- bundle_members = tuple(
- member for member in members if member.kind == "source-bundle"
- )
- capability_members = tuple(
- member for member in members if member.kind == "source-capability"
- )
+ raise ObservatoryWorkerHttpError("source materialization member identity changed")
+ bundle_members = tuple(member for member in members if member.kind == "source-bundle")
+ capability_members = tuple(member for member in members if member.kind == "source-capability")
if (
len(bundle_members) != 1
or bundle_members[0].sha256 != job.source_bundle_sha256
or len(capability_members) != 1
- or capability_members[0].sha256
- != job.source_capability_manifest_sha256
+ or capability_members[0].sha256 != job.source_capability_manifest_sha256
):
raise ObservatoryWorkerHttpError("source materialization documents are incomplete")
- if sum(
- member.kind == "spatial-replay" and member.primary for member in members
- ) != 1:
- raise ObservatoryWorkerHttpError(
- "source materialization primary replay is invalid"
- )
+ if sum(member.kind == "spatial-replay" and member.primary for member in members) != 1:
+ raise ObservatoryWorkerHttpError("source materialization primary replay is invalid")
metadata_members = tuple(
member for member in members if member.kind == "spatial-replay-metadata"
)
@@ -973,15 +963,9 @@ def _source_members(
or member.primary
for member in metadata_members
):
- raise ObservatoryWorkerHttpError(
- "source materialization replay metadata is invalid"
- )
- camera_inits = tuple(
- member for member in members if member.kind == "camera-init"
- )
- camera_segments = tuple(
- member for member in members if member.kind == "camera-segment"
- )
+ raise ObservatoryWorkerHttpError("source materialization replay metadata is invalid")
+ camera_inits = tuple(member for member in members if member.kind == "camera-init")
+ camera_segments = tuple(member for member in members if member.kind == "camera-segment")
if (
len(camera_inits) != 1
or not camera_segments
@@ -991,9 +975,7 @@ def _source_members(
for member in camera_segments
)
):
- raise ObservatoryWorkerHttpError(
- "source materialization camera epoch is incomplete"
- )
+ raise ObservatoryWorkerHttpError("source materialization camera epoch is incomplete")
return members
@@ -1053,9 +1035,7 @@ def _source_member(value: object) -> _SourceMember:
or camera_epoch is not None
or camera_sequence is not None
):
- raise ObservatoryWorkerHttpError(
- "source document member metadata is invalid"
- )
+ raise ObservatoryWorkerHttpError("source document member metadata is invalid")
elif kind in {"spatial-replay", "spatial-replay-metadata"}:
if (
artifact_id is None
@@ -1063,9 +1043,7 @@ def _source_member(value: object) -> _SourceMember:
or camera_sequence is not None
or (kind == "spatial-replay-metadata" and row["primary"])
):
- raise ObservatoryWorkerHttpError(
- "spatial source member metadata is invalid"
- )
+ raise ObservatoryWorkerHttpError("spatial source member metadata is invalid")
elif kind == "camera-init":
if (
artifact_id is None
@@ -1073,18 +1051,9 @@ def _source_member(value: object) -> _SourceMember:
or camera_epoch is None
or camera_sequence is not None
):
- raise ObservatoryWorkerHttpError(
- "camera init member metadata is invalid"
- )
- elif (
- artifact_id is None
- or row["primary"]
- or camera_epoch is None
- or camera_sequence is None
- ):
- raise ObservatoryWorkerHttpError(
- "camera segment member metadata is invalid"
- )
+ raise ObservatoryWorkerHttpError("camera init member metadata is invalid")
+ elif artifact_id is None or row["primary"] or camera_epoch is None or camera_sequence is None:
+ raise ObservatoryWorkerHttpError("camera segment member metadata is invalid")
return _SourceMember(
member_id=member_id,
kind=cast(
@@ -1114,27 +1083,18 @@ def _ordered_camera_epoch_members(
inits = tuple(member for member in members if member.kind == "camera-init")
segments = tuple(member for member in members if member.kind == "camera-segment")
if len(inits) != 1 or not segments:
- raise ObservatoryWorkerHttpError(
- "source materialization camera epoch is incomplete"
- )
+ raise ObservatoryWorkerHttpError("source materialization camera epoch is incomplete")
init = inits[0]
if init.artifact_id is None or init.camera_epoch is None:
- raise ObservatoryWorkerHttpError(
- "source materialization camera identity is incomplete"
- )
- ordered_segments = tuple(
- sorted(segments, key=lambda member: member.camera_sequence or 0)
- )
+ raise ObservatoryWorkerHttpError("source materialization camera identity is incomplete")
+ ordered_segments = tuple(sorted(segments, key=lambda member: member.camera_sequence or 0))
if tuple(member.camera_sequence for member in ordered_segments) != tuple(
range(1, len(ordered_segments) + 1)
) or any(
- member.artifact_id != init.artifact_id
- or member.camera_epoch != init.camera_epoch
+ member.artifact_id != init.artifact_id or member.camera_epoch != init.camera_epoch
for member in ordered_segments
):
- raise ObservatoryWorkerHttpError(
- "source materialization camera member order changed"
- )
+ raise ObservatoryWorkerHttpError("source materialization camera member order changed")
return (init, *ordered_segments)
@@ -1147,13 +1107,9 @@ def _camera_archive_relative_name(member: _SourceMember) -> str:
def _camera_epoch_archive_byte_length(members: tuple[_SourceMember, ...]) -> int:
- content_bytes = sum(
- 512 + ((member.byte_length + 511) // 512) * 512 for member in members
- )
+ content_bytes = sum(512 + ((member.byte_length + 511) // 512) * 512 for member in members)
logical_bytes = content_bytes + 1024
- return ((logical_bytes + tarfile.RECORDSIZE - 1) // tarfile.RECORDSIZE) * (
- tarfile.RECORDSIZE
- )
+ return ((logical_bytes + tarfile.RECORDSIZE - 1) // tarfile.RECORDSIZE) * (tarfile.RECORDSIZE)
def _extract_camera_epoch_archive(
@@ -1166,9 +1122,7 @@ def _extract_camera_epoch_archive(
try:
archive_metadata = archive_path.lstat()
except OSError as exc:
- raise ObservatoryWorkerHttpError(
- "camera epoch archive is unavailable"
- ) from exc
+ raise ObservatoryWorkerHttpError("camera epoch archive is unavailable") from exc
if (
stat.S_ISLNK(archive_metadata.st_mode)
or not stat.S_ISREG(archive_metadata.st_mode)
@@ -1178,9 +1132,7 @@ def _extract_camera_epoch_archive(
"camera epoch archive size differs from its member inventory"
)
transfer_root = _secure_directory(archive_path.parent)
- staging = Path(
- tempfile.mkdtemp(prefix=".camera-epoch-extract-", dir=transfer_root)
- )
+ staging = Path(tempfile.mkdtemp(prefix=".camera-epoch-extract-", dir=transfer_root))
staged: list[Path] = []
archive_descriptor = -1
try:
@@ -1243,9 +1195,7 @@ def _extract_camera_epoch_archive(
except ObservatoryWorkerHttpError:
raise
except (OSError, tarfile.TarError) as exc:
- raise ObservatoryWorkerHttpError(
- "camera epoch archive could not be extracted"
- ) from exc
+ raise ObservatoryWorkerHttpError("camera epoch archive could not be extracted") from exc
finally:
if archive_descriptor >= 0:
os.close(archive_descriptor)
@@ -1261,10 +1211,7 @@ def _copy_camera_archive_member(
) -> None:
descriptor = os.open(
destination,
- os.O_WRONLY
- | os.O_CREAT
- | os.O_EXCL
- | getattr(os, "O_NOFOLLOW", 0),
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
0o600,
)
digest = hashlib.sha256()
@@ -1284,9 +1231,7 @@ def _copy_camera_archive_member(
stream.flush()
os.fsync(stream.fileno())
if byte_length != expected_byte_length or digest.hexdigest() != expected_sha256:
- raise ObservatoryWorkerHttpError(
- "camera epoch archive member content changed"
- )
+ raise ObservatoryWorkerHttpError("camera epoch archive member content changed")
verified = True
finally:
if descriptor >= 0:
@@ -1300,13 +1245,9 @@ def _prepare_source_destination_layout(
root: Path,
members: tuple[_SourceMember, ...],
) -> _SourceDestinationLayout:
- camera_inits = tuple(
- member for member in members if member.kind == "camera-init"
- )
+ camera_inits = tuple(member for member in members if member.kind == "camera-init")
if len(camera_inits) != 1 or camera_inits[0].camera_epoch is None:
- raise ObservatoryWorkerHttpError(
- "source materialization camera epoch is incomplete"
- )
+ raise ObservatoryWorkerHttpError("source materialization camera epoch is incomplete")
camera_epoch = camera_inits[0].camera_epoch
camera_root = _secure_source_subdirectory(root, root / "camera")
camera_epoch_root = _secure_source_subdirectory(
@@ -1318,10 +1259,7 @@ def _prepare_source_destination_layout(
camera_epoch_root / "segments",
)
spatial = None
- if any(
- member.kind == "spatial-replay" and not member.primary
- for member in members
- ):
+ if any(member.kind == "spatial-replay" and not member.primary for member in members):
spatial = _secure_source_subdirectory(root, root / "spatial")
return _SourceDestinationLayout(
root=root,
@@ -1335,9 +1273,7 @@ def _prepare_source_destination_layout(
def _secure_source_subdirectory(root: Path, candidate: Path) -> Path:
resolved = _secure_directory(candidate)
if not resolved.is_relative_to(root):
- raise ObservatoryWorkerHttpError(
- "source materialization destination escapes its root"
- )
+ raise ObservatoryWorkerHttpError("source materialization destination escapes its root")
return resolved
@@ -1409,8 +1345,7 @@ def _upload_members(
or len({member.role for member in members}) != len(members)
or tuple(member.member_id for member in members)
!= tuple(sorted(member.member_id for member in members))
- or sum(member.byte_length for member in members)
- > 256 * 1024 * 1024 * 1024
+ or sum(member.byte_length for member in members) > 256 * 1024 * 1024 * 1024
or document.get("complete") != all(member.uploaded for member in members)
):
raise ObservatoryWorkerHttpError("result upload member inventory is invalid")
@@ -1489,9 +1424,7 @@ def _write_local_exact(path: Path, payload: bytes) -> None:
os.chmod(path, 0o400, follow_symlinks=False)
except FileExistsError:
if _read_local_file(path, max(1, len(payload))) != payload:
- raise ObservatoryWorkerHttpError(
- "Worker local manifest identity collided"
- ) from None
+ raise ObservatoryWorkerHttpError("Worker local manifest identity collided") from None
finally:
with suppress(FileNotFoundError):
temporary.unlink()
@@ -1512,9 +1445,7 @@ def _publish_local_file(
os.chmod(destination, 0o400, follow_symlinks=False)
except FileExistsError:
if not _matches_file(destination, sha256, byte_length):
- raise ObservatoryWorkerHttpError(
- "Worker local member publication collided"
- ) from None
+ raise ObservatoryWorkerHttpError("Worker local member publication collided") from None
def _matches_file(path: Path, sha256: str, byte_length: int) -> bool:
@@ -1530,11 +1461,7 @@ def _read_local_file(path: Path, maximum_bytes: int) -> bytes:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
before = os.fstat(descriptor)
- if (
- not stat.S_ISREG(before.st_mode)
- or before.st_size < 0
- or before.st_size > maximum_bytes
- ):
+ if not stat.S_ISREG(before.st_mode) or before.st_size < 0 or before.st_size > maximum_bytes:
raise ObservatoryWorkerHttpError("Worker local file is outside bounds")
payload = bytearray()
while len(payload) < before.st_size:
@@ -1562,11 +1489,7 @@ def _hash_local_regular_file(path: Path, maximum_bytes: int) -> tuple[str, int]:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
try:
before = os.fstat(descriptor)
- if (
- not stat.S_ISREG(before.st_mode)
- or before.st_size < 0
- or before.st_size > maximum_bytes
- ):
+ if not stat.S_ISREG(before.st_mode) or before.st_size < 0 or before.st_size > maximum_bytes:
raise ObservatoryWorkerHttpError("Worker local file is outside bounds")
digest = hashlib.sha256()
byte_length = 0
diff --git a/src/k1link/observatory/worker_service.py b/src/k1link/observatory/worker_service.py
index 743e4e1..eae58ff 100644
--- a/src/k1link/observatory/worker_service.py
+++ b/src/k1link/observatory/worker_service.py
@@ -52,6 +52,7 @@ from k1link.observatory.worker_http_transport import (
OBSERVATORY_WORKER_BASE_URL_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_BASE_URL"
OBSERVATORY_WORKER_TOKEN_FILE_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_TOKEN_FILE"
OBSERVATORY_WORKER_WORK_ROOT_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_WORK_ROOT"
+OBSERVATORY_WORKER_SOURCE_CACHE_ROOT_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CACHE_ROOT"
OBSERVATORY_WORKER_IDLE_POLL_SECONDS_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_IDLE_POLL_SECONDS"
OBSERVATORY_WORKER_TRANSPORT_BACKOFF_SECONDS_ENV: Final = (
"MISSIONCORE_OBSERVATORY_WORKER_TRANSPORT_BACKOFF_SECONDS"
@@ -140,11 +141,14 @@ class ObservatoryWorkerServiceConfiguration:
idle_poll_seconds: float = DEFAULT_OBSERVATORY_WORKER_IDLE_POLL_SECONDS
transport_backoff_seconds: float = DEFAULT_OBSERVATORY_WORKER_TRANSPORT_BACKOFF_SECONDS
max_consecutive_transport_failures: int = DEFAULT_OBSERVATORY_WORKER_MAX_TRANSPORT_FAILURES
+ source_cache_root: Path | None = None
def __post_init__(self) -> None:
_validate_worker_base_url(self.base_url)
_absolute_path(self.bearer_token_file, "Worker bearer token file")
_absolute_path(self.work_root, "Worker work root")
+ if self.source_cache_root is not None:
+ _absolute_path(self.source_cache_root, "Worker source cache root")
if isinstance(self.idle_poll_seconds, bool) or not (
0.05 <= self.idle_poll_seconds <= 300.0
):
@@ -175,6 +179,11 @@ class ObservatoryWorkerServiceConfiguration:
),
bearer_token_file=token_file,
work_root=work_root,
+ source_cache_root=(
+ _required_path(values, OBSERVATORY_WORKER_SOURCE_CACHE_ROOT_ENV)
+ if values.get(OBSERVATORY_WORKER_SOURCE_CACHE_ROOT_ENV)
+ else None
+ ),
idle_poll_seconds=_environment_float(
values,
OBSERVATORY_WORKER_IDLE_POLL_SECONDS_ENV,
@@ -265,6 +274,7 @@ def compose_installed_observatory_worker_service(
bearer_token=bearer_token,
work_root=configuration.work_root,
transport=http_transport,
+ source_cache_root=configuration.source_cache_root,
)
finally:
# The immutable string remains owned by the gateway headers for the
@@ -302,6 +312,7 @@ def compose_installed_observatory_worker_service_from_builders(
bearer_token=bearer_token,
work_root=configuration.work_root,
transport=http_transport,
+ source_cache_root=configuration.source_cache_root,
)
executors = build_ready_executor_registry(
definitions=definitions,
@@ -343,6 +354,7 @@ def compose_installed_observatory_worker_service_from_packages(
bearer_token=bearer_token,
work_root=configuration.work_root,
transport=http_transport,
+ source_cache_root=configuration.source_cache_root,
)
executors = build_ready_executor_registry_from_packages(
definitions=definitions,
@@ -381,9 +393,7 @@ def build_ready_executor_registry_from_packages(
_absolute_path(work_root, "Worker package build work root")
ready = definitions.ready_recorded_definitions()
ready_keys = {(item.setup_id, item.definition_sha256) for item in ready}
- package_keys = {
- (package.setup_id, package.definition_sha256) for package in packages.packages
- }
+ package_keys = {(package.setup_id, package.definition_sha256) for package in packages.packages}
if not package_keys.issubset(ready_keys):
raise ObservatoryWorkerServiceError(
"installed LAB packages must bind only ready RunDefinitions"
@@ -420,7 +430,7 @@ def build_ready_executor_registry_from_packages(
if built.identity != package.executor_identity:
raise ObservatoryWorkerServiceError(
"generic package factory returned another executor identity"
- )
+ )
registrations.append(built)
return ObservatoryWorkerExecutorRegistry(tuple(registrations))
diff --git a/src/k1link/observatory/worker_source_cache.py b/src/k1link/observatory/worker_source_cache.py
new file mode 100644
index 0000000..5d135f9
--- /dev/null
+++ b/src/k1link/observatory/worker_source_cache.py
@@ -0,0 +1,170 @@
+"""Worker-local reuse of immutable source bytes, never of claim authority.
+
+Each job must still obtain and validate its own current source manifest. Only
+exact digest/length matches may populate its fixed, generation-scoped layout.
+Files use read-only hardlinks on the same filesystem. An explicitly shared cache
+on another filesystem uses bounded verified disk copies, never whole-file RAM.
+An absent or damaged cache object is a miss; it is never overwritten or deleted.
+"""
+
+from __future__ import annotations
+
+import errno
+import hashlib
+import os
+import re
+import secrets
+import shutil
+import stat
+from contextlib import suppress
+from pathlib import Path
+
+_SHA256 = re.compile(r"^[a-f0-9]{64}$")
+_CHUNK_BYTES = 1024 * 1024
+_FREE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024
+
+
+class WorkerSourceCacheError(RuntimeError):
+ """A caller selected an unsafe cache root, identity or destination."""
+
+
+class WorkerSourceCache:
+ def __init__(self, root: Path) -> None:
+ candidate = root.expanduser().absolute()
+ candidate.mkdir(mode=0o700, parents=True, exist_ok=True)
+ if candidate.is_symlink() or not candidate.is_dir():
+ raise WorkerSourceCacheError("Worker source cache root is unsafe")
+ self.root = candidate.resolve(strict=True)
+
+ def restore(self, destination: Path, *, sha256: str, byte_length: int) -> bool:
+ """Verify cached bytes before linking them into an empty job-owned path."""
+ cached = self._path(sha256, byte_length)
+ if not cached.exists() or cached.is_symlink():
+ return False
+ if destination.exists() or destination.is_symlink():
+ raise WorkerSourceCacheError("Worker source cache destination is not empty")
+ return _publish_exact(cached, destination, sha256, byte_length)
+
+ def retain(self, source: Path, *, sha256: str, byte_length: int) -> bool:
+ """Retain a fully downloaded/extracted file without copying its contents.
+
+ Corrupt existing cache files are preserved for diagnosis, not used and
+ not replaced. The current run can use its freshly downloaded exact file.
+ """
+ cached = self._path(sha256, byte_length)
+ if cached.exists() or cached.is_symlink():
+ # Not consumed by this call. Restore will validate it when needed;
+ # do not add another whole-file read to an already-ready job.
+ return False
+ return _publish_exact(source, cached, sha256, byte_length)
+
+ def _path(self, sha256: str, byte_length: int) -> Path:
+ if (
+ not isinstance(sha256, str)
+ or _SHA256.fullmatch(sha256) is None
+ or isinstance(byte_length, bool)
+ or not isinstance(byte_length, int)
+ or byte_length < 0
+ ):
+ raise WorkerSourceCacheError("Worker source cache identity is invalid")
+ if self.root.is_symlink() or not self.root.is_dir():
+ raise WorkerSourceCacheError("Worker source cache root changed")
+ return self.root / sha256
+
+
+def _publish_exact(source: Path, destination: Path, sha256: str, byte_length: int) -> bool:
+ """Hash actual staged bytes before publication; never replace a path."""
+ temporary = destination.with_name(f".source-cache-{secrets.token_hex(16)}")
+ try:
+ try:
+ os.link(source, temporary, follow_symlinks=False)
+ except OSError as exc:
+ if exc.errno != errno.EXDEV:
+ return False
+ # Different agent work mounts may share one CAS mount. Copy only
+ # in bounded chunks and leave a cache miss when space is low.
+ if shutil.disk_usage(destination.parent).free < byte_length + _FREE_RESERVE_BYTES:
+ return False
+ if not _copy_exact(source, temporary, sha256, byte_length):
+ return False
+ else:
+ if not _matches(temporary, sha256, byte_length):
+ return False
+ os.chmod(temporary, 0o400, follow_symlinks=False)
+ try:
+ os.link(temporary, destination, follow_symlinks=False)
+ except FileExistsError:
+ return _matches(destination, sha256, byte_length)
+ return True
+ except OSError:
+ # Cache publication is optional; a fully downloaded job remains valid.
+ return False
+ finally:
+ with suppress(FileNotFoundError):
+ temporary.unlink() # Only this call's temporary link/copy.
+
+
+def _matches(path: Path, sha256: str, byte_length: int) -> bool:
+ descriptor = -1
+ try:
+ if not stat.S_ISREG(path.lstat().st_mode):
+ return False
+ descriptor = os.open(
+ path,
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
+ )
+ before = os.fstat(descriptor)
+ if not stat.S_ISREG(before.st_mode) or before.st_size != byte_length:
+ return False
+ digest = hashlib.sha256()
+ remaining = byte_length
+ while remaining:
+ chunk = os.read(descriptor, min(_CHUNK_BYTES, remaining))
+ if not chunk:
+ return False
+ digest.update(chunk)
+ remaining -= len(chunk)
+ after = os.fstat(descriptor)
+ named = path.lstat()
+ return (
+ stat.S_ISREG(named.st_mode)
+ and _stamp(before) == _stamp(after) == _stamp(named)
+ and digest.hexdigest() == sha256
+ )
+ except OSError:
+ return False
+ finally:
+ if descriptor >= 0:
+ os.close(descriptor)
+
+
+def _stamp(value: os.stat_result) -> tuple[int, int, int, int, int]:
+ return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns
+
+
+def _copy_exact(source: Path, temporary: Path, sha256: str, byte_length: int) -> bool:
+ if not stat.S_ISREG(source.lstat().st_mode):
+ return False
+ descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
+ try:
+ before = os.fstat(descriptor)
+ if not stat.S_ISREG(before.st_mode) or before.st_size != byte_length:
+ return False
+ digest = hashlib.sha256()
+ remaining = byte_length
+ with temporary.open("xb") as target:
+ while remaining:
+ chunk = os.read(descriptor, min(_CHUNK_BYTES, remaining))
+ if not chunk:
+ return False
+ digest.update(chunk)
+ target.write(chunk)
+ remaining -= len(chunk)
+ target.flush()
+ os.fsync(target.fileno())
+ valid = _stamp(before) == _stamp(os.fstat(descriptor)) and digest.hexdigest() == sha256
+ if not valid:
+ temporary.unlink()
+ return valid
+ finally:
+ os.close(descriptor)
diff --git a/src/k1link/sessions/equipment.py b/src/k1link/sessions/equipment.py
index c1e5670..b37bf69 100644
--- a/src/k1link/sessions/equipment.py
+++ b/src/k1link/sessions/equipment.py
@@ -19,9 +19,7 @@ from .models import ObservationSessionCandidate
EQUIPMENT_REGISTRY_SCHEMA: Final = "missioncore.equipment-model-registry/v1"
EQUIPMENT_MODEL_SCHEMA: Final = "missioncore.equipment-model/v1"
-CAPTURE_PROFILE_REGISTRY_SCHEMA: Final = (
- "missioncore.recorded-capture-profile-registry/v1"
-)
+CAPTURE_PROFILE_REGISTRY_SCHEMA: Final = "missioncore.recorded-capture-profile-registry/v1"
CAPTURE_PROFILE_SCHEMA: Final = "missioncore.recorded-capture-profile/v1"
CAPTURE_ATTESTATION_SCHEMA: Final = "missioncore.recorded-capture-attestation/v1"
@@ -119,9 +117,7 @@ class EquipmentCaptureRegistry:
@classmethod
def from_repository(cls, repository_root: Path) -> EquipmentCaptureRegistry | None:
equipment_path = repository_root / "config" / "observatory-equipment-models.json"
- capture_path = (
- repository_root / "config" / "observatory-recorded-capture-profiles.json"
- )
+ capture_path = repository_root / "config" / "observatory-recorded-capture-profiles.json"
if not equipment_path.exists() and not capture_path.exists():
return None
if not equipment_path.is_file() or not capture_path.is_file():
@@ -152,9 +148,7 @@ class EquipmentCaptureRegistry:
if len(by_id) != len(equipment_models):
raise EquipmentRegistryError("equipment model ids must be unique")
capture_profiles = tuple(_capture_profile(row, by_id) for row in capture_rows)
- if len({item.capture_profile_id for item in capture_profiles}) != len(
- capture_profiles
- ):
+ if len({item.capture_profile_id for item in capture_profiles}) != len(capture_profiles):
raise EquipmentRegistryError("capture profile ids must be unique")
return cls(
equipment_models=equipment_models,
@@ -227,26 +221,28 @@ class EquipmentCaptureRegistry:
row = profile.document
camera = row["camera_media"]
calibration = row["calibration"]
+ modalities = row["modalities"]
semantic_channels = row["semantic_channels"]
assert isinstance(camera, dict)
assert isinstance(calibration, dict)
+ assert isinstance(modalities, list)
assert isinstance(semantic_channels, list)
+ required_modalities = requirements.get("required_modalities")
if (
row["plugin_id"] == requirements.get("plugin_id")
and row["archive_id"] == requirements.get("archive_id")
- and row["modalities"] == requirements.get("required_modalities")
+ and isinstance(required_modalities, list)
+ and set(required_modalities).issubset(modalities)
and camera["source_id"] == requirements.get("camera_source_id")
and "camera.video.recorded" in semantic_channels
- and requirements.get("camera_semantic_channel_id")
- == "camera.video.recorded"
+ and requirements.get("camera_semantic_channel_id") == "camera.video.recorded"
and camera["media_type"] == requirements.get("recorded_media_type")
and camera["initialization_sha256"]
== requirements.get("recorded_media_init_sha256")
and camera["width"] == requirements.get("camera_width")
and camera["height"] == requirements.get("camera_height")
and calibration["slot_id"] == requirements.get("calibration_slot")
- and calibration["sha256"]
- == requirements.get("calibration_identity_sha256")
+ and calibration["sha256"] == requirements.get("calibration_identity_sha256")
and requirements.get("exactly_one_media_epoch") is True
and requirements.get("seekable") is True
):
@@ -336,8 +332,7 @@ def _capture_profile(
or not channels
or channels != sorted(channels)
or any(
- not isinstance(item, str) or _IDENTIFIER.fullmatch(item) is None
- for item in channels
+ not isinstance(item, str) or _IDENTIFIER.fullmatch(item) is None for item in channels
)
):
raise EquipmentRegistryError("capture semantic channels are invalid")
diff --git a/src/k1link/viewer/recorded.py b/src/k1link/viewer/recorded.py
index 833a151..fbccdf7 100644
--- a/src/k1link/viewer/recorded.py
+++ b/src/k1link/viewer/recorded.py
@@ -2,17 +2,20 @@
from __future__ import annotations
-from collections import OrderedDict
from collections.abc import Callable
from contextlib import suppress
from threading import Lock
from typing import Any, Literal
-from uuid import UUID
+from uuid import UUID, uuid5
import rerun as rr
import rerun_bindings as bindings
from rerun import blueprint as rrb
+from k1link.viewer.recorded_blueprint_lifecycle import (
+ BlueprintSessionReleased,
+ RecordedBlueprintSessions,
+)
from k1link.viewer.rerun_bridge import RerunSceneSettings
APPLICATION_ID = "nodedc_mission_core_recorded"
@@ -48,8 +51,9 @@ class _RecordedBlueprintStream:
"""Keep one bounded SDK blueprint source for each browser viewport.
Upstream 0.36.3 activates a clone, not this source store. Explicit refresh
- makes layer changes visible but cannot retain the clone's operator eye.
- It is opt-in for portable replay pending native camera-state support.
+ makes layer changes visible. Ordinary layer updates deliberately omit eye
+ components so the active clone retains the operator's camera. Only an
+ explicit 3D/plan/follow transition may write a new spatial eye preset.
"""
def __init__(
@@ -57,8 +61,10 @@ class _RecordedBlueprintStream:
application_id: str,
recording_id: str,
*,
+ blueprint_session_id: str,
view_reset_generation: Literal[0, 1],
) -> None:
+ self.blueprint_session_id = blueprint_session_id
self.view_reset_generation = view_reset_generation
self._lock = Lock()
self._sequence = 0
@@ -81,7 +87,7 @@ class _RecordedBlueprintStream:
def render(
self,
- blueprint_factory: Callable[[bool, bool], rrb.Blueprint],
+ blueprint_factory: Callable[[bool, bool, str], rrb.Blueprint],
*,
follow_trajectory: bool,
plan_view: bool,
@@ -94,10 +100,20 @@ class _RecordedBlueprintStream:
update_eye_controls = self._eye_contract != eye_contract
# Initial admission/reset uses native framing. Explicit presets
# apply to mode transitions, after the viewer has a source cursor.
- blueprint = blueprint_factory(update_eye_controls, self._eye_contract is not None)
+ # Keep one view identity for the lifetime of this browser owner.
+ # Replacing the UUID on every layer toggle rebuilt both native
+ # viewports, delayed a simple visibility change, and discarded the
+ # operator's active interaction state. The explicit reset
+ # generation already owns the one intentional identity change.
+ view_instance_token = self.blueprint_session_id
+ blueprint = blueprint_factory(
+ update_eye_controls,
+ self._eye_contract is not None,
+ view_instance_token,
+ )
# Appending rows alone does not refresh upstream's active clone.
- # Keep legacy admission unchanged; portable replay opts into
- # working layer updates with an explicitly documented eye reset.
+ # Reactivation makes layer changes visible; eye components remain
+ # absent unless the explicit view-mode contract changed above.
make_active = self._sequence == 0 or reactivate_updates
self._blueprint_recording.set_time(
"blueprint",
@@ -127,40 +143,7 @@ class _RecordedBlueprintStream:
self._transport_recording.disconnect()
-_MAX_RECORDED_BLUEPRINT_STREAMS = 32
-_recorded_blueprint_streams_lock = Lock()
-_recorded_blueprint_streams: OrderedDict[tuple[str, str, str], _RecordedBlueprintStream] = (
- OrderedDict()
-)
-
-
-def _stable_recorded_blueprint_stream(
- *,
- application_id: str,
- recording_id: str,
- blueprint_session_id: str,
- view_reset_generation: Literal[0, 1],
-) -> _RecordedBlueprintStream:
- key = (application_id, recording_id, blueprint_session_id)
- with _recorded_blueprint_streams_lock:
- stream = _recorded_blueprint_streams.get(key)
- if stream is not None and stream.view_reset_generation != view_reset_generation:
- stream.close()
- del _recorded_blueprint_streams[key]
- stream = None
- if stream is None:
- stream = _RecordedBlueprintStream(
- application_id,
- recording_id,
- view_reset_generation=view_reset_generation,
- )
- _recorded_blueprint_streams[key] = stream
- else:
- _recorded_blueprint_streams.move_to_end(key)
- while len(_recorded_blueprint_streams) > _MAX_RECORDED_BLUEPRINT_STREAMS:
- _, stale = _recorded_blueprint_streams.popitem(last=False)
- stale.close()
- return stream
+recorded_blueprint_sessions = RecordedBlueprintSessions[_RecordedBlueprintStream]()
def recorded_blueprint(
@@ -170,15 +153,21 @@ def recorded_blueprint(
active_view: RecordedView = "spatial",
view_reset_generation: Literal[0, 1] = 0,
unified_perception: bool = False,
+ unified_camera_share: float = 0.46,
semantic_layer: Literal["city", "vegetation"] | None = None,
plan_view: bool = False,
show_detections_2d: bool = False,
+ show_camera_image: bool = True,
show_segmentation: bool = False,
show_cuboids_3d: bool = False,
show_costmap: bool = False,
follow_trajectory: bool = False,
update_eye_controls: bool = True,
explicit_spatial_preset: bool = False,
+ eye_position: tuple[float, float, float] | None = None,
+ eye_look_target: tuple[float, float, float] | None = None,
+ eye_up: tuple[float, float, float] | None = None,
+ view_instance_token: str | None = None,
) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
time_ranges: list[rr.VisibleTimeRange] | None = None
@@ -218,6 +207,14 @@ def recorded_blueprint(
)
spatial_eye_controls = (
rrb.EyeControls3D.from_fields(
+ kind=rrb.Eye3DKind.Orbital,
+ position=eye_position,
+ look_target=eye_look_target,
+ eye_up=eye_up,
+ tracking_entity="/world/sensor_pose" if follow_trajectory else "",
+ )
+ if eye_position is not None and eye_look_target is not None and eye_up is not None
+ else rrb.EyeControls3D.from_fields(
kind=rrb.Eye3DKind.Orbital,
position=[0.0, 0.0, 30.0],
look_target=[0.0, 0.0, 0.0],
@@ -267,15 +264,21 @@ def recorded_blueprint(
# alone does not preserve edits in upstream's activated blueprint clone.
eye_controls=spatial_eye_controls,
)
- spatial_view.id = (
- RECORDED_SPATIAL_RESET_VIEW_ID if view_reset_generation else RECORDED_SPATIAL_VIEW_ID
+
+ def instance_id(primary: UUID, reset: UUID) -> UUID:
+ base = reset if view_reset_generation else primary
+ return uuid5(base, view_instance_token) if view_instance_token is not None else base
+
+ spatial_view.id = instance_id(
+ RECORDED_SPATIAL_VIEW_ID,
+ RECORDED_SPATIAL_RESET_VIEW_ID,
)
camera_view = rrb.Spatial2DView(
origin="/perception/camera",
name="Оригинальное видео · слои AI",
background=[7, 8, 10, 255],
overrides={
- "/perception/camera/image": rrb.EntityBehavior(visible=True),
+ "/perception/camera/image": rrb.EntityBehavior(visible=show_camera_image),
"/perception/camera/detections": rrb.EntityBehavior(
visible=show_detections_2d,
),
@@ -290,8 +293,9 @@ def recorded_blueprint(
),
},
)
- camera_view.id = (
- RECORDED_CAMERA_RESET_VIEW_ID if view_reset_generation else RECORDED_CAMERA_VIEW_ID
+ camera_view.id = instance_id(
+ RECORDED_CAMERA_VIEW_ID,
+ RECORDED_CAMERA_RESET_VIEW_ID,
)
perception_3d_view = rrb.Spatial3DView(
# Cuboids are expressed in the same calibrated world frame as the
@@ -325,10 +329,9 @@ def recorded_blueprint(
},
eye_controls=spatial_eye_controls,
)
- perception_3d_view.id = (
- RECORDED_PERCEPTION_3D_RESET_VIEW_ID
- if view_reset_generation
- else RECORDED_PERCEPTION_3D_VIEW_ID
+ perception_3d_view.id = instance_id(
+ RECORDED_PERCEPTION_3D_VIEW_ID,
+ RECORDED_PERCEPTION_3D_RESET_VIEW_ID,
)
metrics_view = rrb.TimeSeriesView(
origin="/metrics/device",
@@ -357,16 +360,16 @@ def recorded_blueprint(
spatial_view.visualizer_overrides["/world/perception/boxes3d"] = [
rrb.EntityBehavior(visible=show_cuboids_3d),
]
+ camera_share = min(0.9, max(0.1, unified_camera_share))
root_container = rrb.Horizontal(
camera_view,
spatial_view,
- column_shares=[0.46, 0.54],
+ column_shares=[camera_share, 1.0 - camera_share],
name="Единая сцена восприятия",
)
- root_container.id = (
- RECORDED_UNIFIED_RESET_ROOT_CONTAINER_ID
- if view_reset_generation
- else RECORDED_UNIFIED_ROOT_CONTAINER_ID
+ root_container.id = instance_id(
+ RECORDED_UNIFIED_ROOT_CONTAINER_ID,
+ RECORDED_UNIFIED_RESET_ROOT_CONTAINER_ID,
)
else:
# Keep operator video and 3D cuboids as direct root views. Rerun's nested
@@ -384,7 +387,7 @@ def recorded_blueprint(
# replace it from a later blueprint message. Give every operator mode (and
# its explicit reset generation) a stable root identity so the requested
# child is authoritative instead of inheriting a previously visited tab.
- root_container.id = {
+ base_root_id = {
("spatial", 0): RECORDED_ROOT_CONTAINER_ID,
("spatial", 1): RECORDED_SPATIAL_RESET_ROOT_CONTAINER_ID,
("perception", 0): RECORDED_PERCEPTION_ROOT_CONTAINER_ID,
@@ -394,6 +397,11 @@ def recorded_blueprint(
("metrics", 0): RECORDED_METRICS_ROOT_CONTAINER_ID,
("metrics", 1): RECORDED_METRICS_RESET_ROOT_CONTAINER_ID,
}[(active_view, view_reset_generation)]
+ root_container.id = (
+ uuid5(base_root_id, view_instance_token)
+ if view_instance_token is not None
+ else base_root_id
+ )
if include_initial_playback_state:
return rrb.Blueprint(
@@ -424,19 +432,26 @@ def recorded_blueprint_rrd(
active_view: RecordedView = "spatial",
view_reset_generation: Literal[0, 1] = 0,
unified_perception: bool = False,
+ unified_camera_share: float = 0.46,
semantic_layer: Literal["city", "vegetation"] | None = None,
plan_view: bool = False,
show_detections_2d: bool = False,
+ show_camera_image: bool = True,
show_segmentation: bool = False,
show_cuboids_3d: bool = False,
show_costmap: bool = False,
follow_trajectory: bool = False,
reactivate_updates: bool = False,
+ eye_position: tuple[float, float, float] | None = None,
+ eye_look_target: tuple[float, float, float] | None = None,
+ eye_up: tuple[float, float, float] | None = None,
) -> bytes:
"""Serialize a bounded active blueprint update without recorded data."""
def build_blueprint(
- update_eye_controls: bool, use_spatial_preset: bool = False
+ update_eye_controls: bool,
+ use_spatial_preset: bool = False,
+ view_instance_token: str | None = None,
) -> rrb.Blueprint:
return recorded_blueprint(
settings,
@@ -444,32 +459,43 @@ def recorded_blueprint_rrd(
active_view=active_view,
view_reset_generation=view_reset_generation,
unified_perception=unified_perception,
+ unified_camera_share=unified_camera_share,
semantic_layer=semantic_layer,
plan_view=plan_view,
show_detections_2d=show_detections_2d,
+ show_camera_image=show_camera_image,
show_segmentation=show_segmentation,
show_cuboids_3d=show_cuboids_3d,
show_costmap=show_costmap,
follow_trajectory=follow_trajectory,
update_eye_controls=update_eye_controls,
- explicit_spatial_preset=reactivate_updates and use_spatial_preset,
+ explicit_spatial_preset=use_spatial_preset and update_eye_controls,
+ eye_position=eye_position,
+ eye_look_target=eye_look_target,
+ eye_up=eye_up,
+ view_instance_token=view_instance_token,
)
payload: bytes | None
if blueprint_session_id is not None:
try:
- payload = _stable_recorded_blueprint_stream(
- application_id=application_id,
- recording_id=recording_id,
- blueprint_session_id=blueprint_session_id,
- view_reset_generation=view_reset_generation,
- ).render(
- build_blueprint,
- follow_trajectory=follow_trajectory,
- plan_view=plan_view,
- reactivate_updates=reactivate_updates,
+ payload = recorded_blueprint_sessions.use(
+ (application_id, recording_id, blueprint_session_id),
+ view_reset_generation,
+ lambda: _RecordedBlueprintStream(
+ application_id,
+ recording_id,
+ blueprint_session_id=blueprint_session_id,
+ view_reset_generation=view_reset_generation,
+ ),
+ lambda stream: stream.render(
+ build_blueprint,
+ follow_trajectory=follow_trajectory,
+ plan_view=plan_view,
+ reactivate_updates=reactivate_updates,
+ ),
)
- except RecordedBlueprintError:
+ except (RecordedBlueprintError, BlueprintSessionReleased):
raise
except Exception as exc:
raise RecordedBlueprintError("failed to serialize stable recorded blueprint") from exc
diff --git a/src/k1link/viewer/recorded_blueprint_lifecycle.py b/src/k1link/viewer/recorded_blueprint_lifecycle.py
new file mode 100644
index 0000000..db8f69e
--- /dev/null
+++ b/src/k1link/viewer/recorded_blueprint_lifecycle.py
@@ -0,0 +1,129 @@
+"""Ephemeral viewport resources: renew while mounted, release at termination."""
+
+from __future__ import annotations
+
+import time
+from collections import OrderedDict
+from collections.abc import Callable
+from dataclasses import dataclass
+from threading import RLock
+from typing import Protocol
+
+BlueprintKey = tuple[str, str, str]
+
+
+class Closable(Protocol):
+ def close(self) -> None: ...
+
+
+class BlueprintSessionReleased(RuntimeError):
+ """A late update cannot resurrect an explicitly released viewport."""
+
+
+@dataclass
+class _Entry[Resource: Closable]:
+ resource: Resource
+ generation: int
+ last_used: float
+
+
+class RecordedBlueprintSessions[Resource: Closable]:
+ """No TTL applies while a render is running; idle owners renew separately.
+
+ The small registry lock also serializes release with render. Thus the
+ release acknowledgement means native resources have actually been closed,
+ and a previously queued update is fenced by a lightweight tombstone.
+ """
+
+ def __init__(
+ self,
+ *,
+ ttl_seconds: float = 300.0,
+ max_entries: int = 32,
+ clock: Callable[[], float] = time.monotonic,
+ ) -> None:
+ self._ttl = ttl_seconds
+ self._max_entries = max_entries
+ self._clock = clock
+ self._lock = RLock()
+ self._entries: OrderedDict[BlueprintKey, _Entry[Resource]] = OrderedDict()
+ self._released: OrderedDict[BlueprintKey, float] = OrderedDict()
+
+ def _expire(self, now: float) -> None:
+ for key, entry in list(self._entries.items()):
+ if now - entry.last_used >= self._ttl:
+ del self._entries[key]
+ entry.resource.close()
+ for key, expires in list(self._released.items()):
+ if expires <= now:
+ del self._released[key]
+
+ def use[Result](
+ self,
+ key: BlueprintKey,
+ generation: int,
+ create: Callable[[], Resource],
+ render: Callable[[Resource], Result],
+ ) -> Result:
+ with self._lock:
+ now = self._clock()
+ self._expire(now)
+ if key in self._released:
+ raise BlueprintSessionReleased("recorded viewport has been released")
+ entry = self._entries.get(key)
+ if entry is not None and entry.generation != generation:
+ del self._entries[key]
+ entry.resource.close()
+ entry = None
+ if entry is None:
+ entry = _Entry(create(), generation, now)
+ self._entries[key] = entry
+ self._entries.move_to_end(key)
+ while len(self._entries) > self._max_entries:
+ _, stale = self._entries.popitem(last=False)
+ stale.resource.close()
+ try:
+ return render(entry.resource)
+ except BaseException:
+ # A failed operation must not retain its partial native store.
+ del self._entries[key]
+ entry.resource.close()
+ raise
+ finally:
+ entry.last_used = self._clock()
+
+ def renew(self, key: BlueprintKey) -> bool:
+ with self._lock:
+ now = self._clock()
+ self._expire(now)
+ entry = self._entries.get(key)
+ if entry is None:
+ return False
+ entry.last_used = now
+ self._entries.move_to_end(key)
+ return True
+
+ def release(self, key: BlueprintKey) -> None:
+ with self._lock:
+ now = self._clock()
+ self._expire(now)
+ entry = self._entries.pop(key, None)
+ self._released[key] = now + self._ttl
+ self._released.move_to_end(key)
+ # Tombstones contain identities only, never SDK resources or data.
+ while len(self._released) > 4096:
+ self._released.popitem(last=False)
+ if entry is not None:
+ entry.resource.close()
+
+ def expire(self) -> None:
+ with self._lock:
+ self._expire(self._clock())
+
+ def close(self) -> None:
+ with self._lock:
+ entries = list(self._entries.values())
+ self._entries.clear()
+ self._released.clear()
+ for entry in entries:
+ entry.resource.close()
diff --git a/src/k1link/viewer/recorded_camera_bounds.py b/src/k1link/viewer/recorded_camera_bounds.py
new file mode 100644
index 0000000..b925e98
--- /dev/null
+++ b/src/k1link/viewer/recorded_camera_bounds.py
@@ -0,0 +1,187 @@
+"""Scene-derived orbital camera bounds for recorded Rerun views."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+
+import numpy as np
+import pyarrow as pa
+import pyarrow.compute as pc
+import rerun_bindings as bindings
+
+SESSION_TIMELINE = "session_time"
+MIN_ORBIT_DISTANCE = 0.02
+MAX_ORBITAL_ZOOM_OUT_FACTOR = 5.0
+
+
+@dataclass(frozen=True)
+class _SpatialBoundsSeries:
+ times_ns: np.ndarray
+ lower: np.ndarray
+ upper: np.ndarray
+
+
+@lru_cache(maxsize=8)
+def _recorded_spatial_bounds_index(
+ path_text: str,
+ byte_length: int,
+ modified_ns: int,
+) -> dict[str, _SpatialBoundsSeries]:
+ """Build a small temporal bounds index for one immutable RRD generation.
+
+ Layer and follow buttons only change a blueprint. Re-decoding the complete
+ source RRD for every click made those controls wait on archive I/O. The
+ cache key includes the file generation fingerprint; cached values contain
+ only timestamps and six floats per sample, never point-cloud payloads.
+ """
+
+ del byte_length, modified_ns # Generation identity is carried by the cache key.
+ reader = bindings.RrdReaderInternal(path_text)
+ recording = next(
+ (entry for entry in reader.store_entries() if entry.kind == "recording"),
+ None,
+ )
+ if recording is None:
+ raise ValueError("recorded camera source has no recording store")
+
+ rows: dict[str, list[tuple[int, np.ndarray, np.ndarray]]] = {
+ "/world/points": [],
+ "/world/trajectory": [],
+ }
+ for chunk in reader.stream(recording):
+ entity_path = str(chunk.entity_path)
+ if entity_path not in rows:
+ continue
+ batch = chunk.to_record_batch()
+ names = batch.column_names
+ if SESSION_TIMELINE not in names:
+ continue
+ component = (
+ "Points3D:positions" if entity_path == "/world/points" else "LineStrips3D:strips"
+ )
+ if component not in names:
+ continue
+ times = np.asarray(
+ batch.column(names.index(SESSION_TIMELINE)).cast(pa.int64()),
+ dtype=np.int64,
+ )
+ spatial = batch.column(names.index(component))
+ for index, timestamp in enumerate(times):
+ values = _spatial_values(
+ spatial.slice(index, 1),
+ nested=entity_path == "/world/trajectory",
+ )
+ if not values.size:
+ continue
+ finite = values[np.isfinite(values).all(axis=1)]
+ if not finite.size:
+ continue
+ rows[entity_path].append(
+ (
+ int(timestamp),
+ np.min(finite, axis=0),
+ np.max(finite, axis=0),
+ )
+ )
+
+ result: dict[str, _SpatialBoundsSeries] = {}
+ for entity_path, samples in rows.items():
+ if not samples:
+ continue
+ samples.sort(key=lambda sample: sample[0])
+ result[entity_path] = _SpatialBoundsSeries(
+ times_ns=np.asarray([sample[0] for sample in samples], dtype=np.int64),
+ lower=np.asarray([sample[1] for sample in samples], dtype=np.float32),
+ upper=np.asarray([sample[2] for sample in samples], dtype=np.float32),
+ )
+ return result
+
+
+def recorded_orbital_radius_limit(
+ recording_path: Path,
+ *,
+ current_time_ns: int,
+ accumulation_seconds: float,
+ show_points: bool,
+ show_trajectory: bool,
+) -> float | None:
+ """Match Rerun 0.36.3's current scene-diagonal zoom-out limit.
+
+ The LAB 3D view roots its native mapping data at ``/world``. Points and
+ trajectory are the source entities whose visible-time query changes with
+ the operator's accumulation setting. Derived layers are deliberately not
+ folded into this source contract: they use the same calibrated world frame
+ and do not own navigation.
+ """
+
+ if current_time_ns < 0 or not math.isfinite(accumulation_seconds) or accumulation_seconds < 0:
+ raise ValueError("invalid recorded camera query")
+ wanted = {
+ *(("/world/points",) if show_points else ()),
+ *(("/world/trajectory",) if show_trajectory else ()),
+ }
+ if not wanted:
+ return None
+ path = recording_path.expanduser().absolute()
+ if path.is_symlink() or not path.is_file():
+ raise ValueError("recorded camera source is unavailable")
+
+ stat = path.stat()
+ series_by_entity = _recorded_spatial_bounds_index(
+ str(path),
+ stat.st_size,
+ stat.st_mtime_ns,
+ )
+
+ lower_ns = current_time_ns - round(accumulation_seconds * 1_000_000_000)
+ lower = np.array([np.inf, np.inf, np.inf], dtype=np.float32)
+ upper = np.array([-np.inf, -np.inf, -np.inf], dtype=np.float32)
+ found = False
+ for entity_path in wanted:
+ series = series_by_entity.get(entity_path)
+ if series is None:
+ continue
+ if accumulation_seconds > 0:
+ selected = (series.times_ns >= lower_ns) & (series.times_ns <= current_time_ns)
+ else:
+ eligible_end = int(np.searchsorted(series.times_ns, current_time_ns, side="right"))
+ if eligible_end == 0:
+ continue
+ latest_time = series.times_ns[eligible_end - 1]
+ selected = series.times_ns == latest_time
+ if not selected.any():
+ continue
+ lower = np.minimum(lower, np.min(series.lower[selected], axis=0))
+ upper = np.maximum(upper, np.max(series.upper[selected], axis=0))
+ found = True
+ if not found:
+ return None
+
+ # macaw::BoundingBox and Rerun's eye controller operate in f32.
+ diagonal = np.float32(np.linalg.norm((upper - lower).astype(np.float32)))
+ if not np.isfinite(diagonal) or diagonal <= 0:
+ return None
+ return float(
+ max(
+ np.float32(MIN_ORBIT_DISTANCE),
+ np.float32(diagonal * np.float32(MAX_ORBITAL_ZOOM_OUT_FACTOR)),
+ )
+ )
+
+
+def _spatial_values(column: pa.Array, *, nested: bool) -> np.ndarray:
+ flattened = pc.list_flatten(column)
+ if nested:
+ flattened = pc.list_flatten(flattened)
+ if not pa.types.is_fixed_size_list(flattened.type) or flattened.type.list_size != 3:
+ raise ValueError("recorded spatial component is not a 3D vector")
+ # Flatten through Arrow rather than reading ``FixedSizeListArray.values``:
+ # the latter exposes the complete backing buffer and ignores a sliced
+ # array's logical offset.
+ values = pc.list_flatten(flattened).to_numpy(zero_copy_only=False)
+ if values.size == 0:
+ return np.empty((0, 3), dtype=np.float32)
+ return np.asarray(values, dtype=np.float32).reshape((-1, 3))
diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py
index 452df2c..4caf97f 100644
--- a/src/k1link/web/app.py
+++ b/src/k1link/web/app.py
@@ -42,11 +42,19 @@ from k1link.observatory import (
ObservatoryRunPreparationLedger,
load_observatory_run_preparation_ledger,
)
+from k1link.observatory.composition_runs import CompositionRunError, CompositionRunStore
+from k1link.observatory.domain_ontology import (
+ ObservatoryDomainOntology,
+ ObservatoryOntologyError,
+)
+from k1link.observatory.lab_view_profiles import LabViewProfileError, LabViewProfileStore
from k1link.observatory.m49_queue_binding import (
M49QueueBindingConfig,
M49QueueBindingError,
M49RecordedQueueBindingService,
)
+from k1link.observatory.modular_composition import CompositionError, ModuleRegistry
+from k1link.observatory.modular_composition_store import ModularCompositionStore
from k1link.observatory.portable_publication_reconciler import (
PortablePublicationReconciler,
)
@@ -196,6 +204,7 @@ from k1link.web.map_api import (
build_map_router,
)
from k1link.web.map_view_api import build_map_view_router
+from k1link.web.modular_observatory_api import build_modular_observatory_router
from k1link.web.observatory_api import build_observatory_router
from k1link.web.observatory_worker_api import (
ObservatoryWorkerAuthentication,
@@ -288,6 +297,37 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
session_store = SessionStore(REPOSITORY_ROOT)
+OBSERVATORY_AI_COMPOSITIONS: ModularCompositionStore | None
+OBSERVATORY_AI_COMPOSITION_RUNS: CompositionRunStore | None
+OBSERVATORY_DOMAIN_ONTOLOGY: ObservatoryDomainOntology | None
+OBSERVATORY_LAB_VIEW_PROFILES: LabViewProfileStore | None
+try:
+ OBSERVATORY_DOMAIN_ONTOLOGY = ObservatoryDomainOntology.from_file(
+ REPOSITORY_ROOT / "config" / "observatory-domain-ontology.json"
+ )
+ OBSERVATORY_AI_COMPOSITIONS = ModularCompositionStore(
+ session_store.data_dir / "observatory-ai-compositions",
+ ModuleRegistry.from_file(REPOSITORY_ROOT / "config" / "observatory-ai-modules.json"),
+ )
+ OBSERVATORY_AI_COMPOSITION_RUNS = CompositionRunStore(
+ session_store.data_dir / "observatory-ai-composition-runs"
+ )
+ OBSERVATORY_LAB_VIEW_PROFILES = LabViewProfileStore(
+ session_store.data_dir / "observatory-lab-view-profiles"
+ )
+except (
+ CompositionError,
+ CompositionRunError,
+ LabViewProfileError,
+ ObservatoryOntologyError,
+ OSError,
+ ValueError,
+):
+ # A modular catalog failure cannot disable recordings, existing LABs or Legacy.
+ OBSERVATORY_AI_COMPOSITIONS = None
+ OBSERVATORY_AI_COMPOSITION_RUNS = None
+ OBSERVATORY_DOMAIN_ONTOLOGY = None
+ OBSERVATORY_LAB_VIEW_PROFILES = None
OBSERVATORY_PORTABLE_DEFINITION_REGISTRY: PortableRunDefinitionRegistry | None
OBSERVATORY_PORTABLE_DEFINITION_REGISTRY_ERROR: str | None
@@ -322,9 +362,7 @@ def _resolve_observatory_calculation_profile(
summary: SessionSummary,
) -> dict[str, object] | None:
if OBSERVATORY_LABORATORY_SETUP_REGISTRY is not None:
- legacy = OBSERVATORY_LABORATORY_SETUP_REGISTRY.observatory_calculation_profile(
- summary
- )
+ legacy = OBSERVATORY_LABORATORY_SETUP_REGISTRY.observatory_calculation_profile(summary)
if legacy is not None:
return legacy
if (
@@ -437,47 +475,30 @@ try:
or "portable definition registry is unavailable"
)
if OBSERVATORY_PORTABLE_CALCULATION_PROFILES is None:
- raise PortableWorkerIntegrationError(
- "portable calculation profile registry is unavailable"
- )
+ raise PortableWorkerIntegrationError("portable calculation profile registry is unavailable")
if OBSERVATORY_PORTABLE_RESULT_VALIDATORS is None:
- raise PortableWorkerIntegrationError(
- "portable result validator registry is unavailable"
- )
+ raise PortableWorkerIntegrationError("portable result validator registry is unavailable")
if OBSERVATORY_RECORDED_JOB_QUEUE is None:
raise PortableWorkerIntegrationError(
- OBSERVATORY_RECORDED_JOB_QUEUE_ERROR
- or "Observatory recorded-job queue is unavailable"
+ OBSERVATORY_RECORDED_JOB_QUEUE_ERROR or "Observatory recorded-job queue is unavailable"
)
if session_artifact_gateway is None:
- raise PortableWorkerIntegrationError(
- "central artifact store is not configured"
- )
+ raise PortableWorkerIntegrationError("central artifact store is not configured")
if session_artifact_gateway.status().central_status != "ready":
- raise PortableWorkerIntegrationError(
- "central artifact store is unavailable"
- )
- OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS = (
- PortableWorkerStorageRoots.from_environment(
- artifact_store_root=session_artifact_gateway.store.root,
- )
+ raise PortableWorkerIntegrationError("central artifact store is unavailable")
+ OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS = PortableWorkerStorageRoots.from_environment(
+ artifact_store_root=session_artifact_gateway.store.root,
)
- OBSERVATORY_PORTABLE_WORKER_INTEGRATION = (
- build_portable_observatory_worker_integration(
- queue=OBSERVATORY_RECORDED_JOB_QUEUE,
- session_store=session_store,
- media_inspector=session_recorded_media_inspector,
- definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
- artifact_store=session_artifact_gateway.store,
- calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
- validators=OBSERVATORY_PORTABLE_RESULT_VALIDATORS,
- source_cas_root=(
- OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.source_cas_root
- ),
- result_staging_root=(
- OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.result_staging_root
- ),
- )
+ OBSERVATORY_PORTABLE_WORKER_INTEGRATION = build_portable_observatory_worker_integration(
+ queue=OBSERVATORY_RECORDED_JOB_QUEUE,
+ session_store=session_store,
+ media_inspector=session_recorded_media_inspector,
+ definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
+ artifact_store=session_artifact_gateway.store,
+ calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
+ validators=OBSERVATORY_PORTABLE_RESULT_VALIDATORS,
+ source_cas_root=(OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.source_cas_root),
+ result_staging_root=(OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.result_staging_root),
)
OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR = None
except (PortableWorkerIntegrationError, OSError, ValueError) as exc:
@@ -487,10 +508,7 @@ except (PortableWorkerIntegrationError, OSError, ValueError) as exc:
OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR = str(exc)
OBSERVATORY_PUBLICATION_RECONCILER = (
None
- if (
- OBSERVATORY_RECORDED_JOB_QUEUE is None
- or OBSERVATORY_PORTABLE_WORKER_INTEGRATION is None
- )
+ if (OBSERVATORY_RECORDED_JOB_QUEUE is None or OBSERVATORY_PORTABLE_WORKER_INTEGRATION is None)
else PortablePublicationReconciler(
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
artifact_transport=OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport,
@@ -499,12 +517,10 @@ OBSERVATORY_PUBLICATION_RECONCILER = (
)
OBSERVATORY_WORKER_API_GATE_ENABLED = OBSERVATORY_WORKER_LOCAL_ENABLED
OBSERVATORY_WORKER_CLAIM_LEASE_READY = (
- OBSERVATORY_WORKER_API_GATE_ENABLED
- and OBSERVATORY_RECORDED_JOB_QUEUE is not None
+ OBSERVATORY_WORKER_API_GATE_ENABLED and OBSERVATORY_RECORDED_JOB_QUEUE is not None
)
OBSERVATORY_WORKER_VERIFIED_RESULT_PUBLISHER_READY = (
- OBSERVATORY_WORKER_API_GATE_ENABLED
- and OBSERVATORY_PORTABLE_WORKER_INTEGRATION is not None
+ OBSERVATORY_WORKER_API_GATE_ENABLED and OBSERVATORY_PORTABLE_WORKER_INTEGRATION is not None
)
OBSERVATORY_WORKER_DISPATCH_READY = (
OBSERVATORY_WORKER_CLAIM_LEASE_READY
@@ -527,17 +543,13 @@ else:
)
if OBSERVATORY_WORKER_AUTHENTICATION_ERROR is not None:
worker_api_errors.append(
- "authentication unavailable: "
- f"{OBSERVATORY_WORKER_AUTHENTICATION_ERROR}"
+ f"authentication unavailable: {OBSERVATORY_WORKER_AUTHENTICATION_ERROR}"
)
if OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR is not None:
worker_api_errors.append(
- "integration unavailable: "
- f"{OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR}"
+ f"integration unavailable: {OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR}"
)
- OBSERVATORY_WORKER_API_ERROR = "Worker pull API is disabled; " + "; ".join(
- worker_api_errors
- )
+ OBSERVATORY_WORKER_API_ERROR = "Worker pull API is disabled; " + "; ".join(worker_api_errors)
OBSERVATORY_PORTABLE_BINDING_SERVICE: PortableRecordedQueueBindingService | None
OBSERVATORY_PORTABLE_SETUP_PROJECTOR: PortableSetupProjector | None
OBSERVATORY_PORTABLE_SETUP_PROJECTOR_ERROR: str | None
@@ -554,7 +566,8 @@ try:
and OBSERVATORY_PORTABLE_CALCULATION_PROFILES is not None
):
OBSERVATORY_PORTABLE_RESULT_CACHE = PortableResultCache(
- sessions=session_store, artifacts=session_artifact_gateway.store,
+ sessions=session_store,
+ artifacts=session_artifact_gateway.store,
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
@@ -566,7 +579,8 @@ try:
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
published_result_available=(
- None if OBSERVATORY_PORTABLE_RESULT_CACHE is None
+ None
+ if OBSERVATORY_PORTABLE_RESULT_CACHE is None
else OBSERVATORY_PORTABLE_RESULT_CACHE.available
),
)
@@ -874,10 +888,19 @@ async def _portable_result_publication_reconciler() -> None:
await asyncio.sleep(15.0)
+async def _recorded_blueprint_resource_reaper() -> None:
+ from k1link.viewer.recorded import recorded_blueprint_sessions
+
+ while True:
+ await asyncio.sleep(30.0)
+ await asyncio.to_thread(recorded_blueprint_sessions.expire)
+
+
@asynccontextmanager
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
reconciler: asyncio.Task[None] | None = None
publication_reconciler: asyncio.Task[None] | None = None
+ blueprint_reaper: asyncio.Task[None] | None = None
try:
configure_scanner_diagnostics(session_store.data_dir / "logs")
session_recording_preparation_manager.start()
@@ -891,11 +914,17 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
# expensive on field captures. Start it immediately in the background
# instead of holding the ASGI startup gate.
reconciler = asyncio.create_task(_recording_preparation_reconciler())
- publication_reconciler = asyncio.create_task(
- _portable_result_publication_reconciler()
- )
+ publication_reconciler = asyncio.create_task(_portable_result_publication_reconciler())
+ blueprint_reaper = asyncio.create_task(_recorded_blueprint_resource_reaper())
yield
finally:
+ from k1link.viewer.recorded import recorded_blueprint_sessions
+
+ if blueprint_reaper is not None:
+ blueprint_reaper.cancel()
+ with suppress(asyncio.CancelledError):
+ await blueprint_reaper
+ await asyncio.to_thread(recorded_blueprint_sessions.close)
await map_gateway_proxy.close()
if reconciler is not None:
reconciler.cancel()
@@ -1066,7 +1095,9 @@ if session_artifact_gateway is not None and _ffmpeg is not None:
media=session_recorded_media_inspector,
recording_source=_canonical_lab_recording_source,
ffmpeg_path=_ffmpeg,
- )
+ ),
+ composition_runs=OBSERVATORY_AI_COMPOSITION_RUNS,
+ queue=OBSERVATORY_RECORDED_JOB_QUEUE,
)
)
@@ -1128,6 +1159,23 @@ app.include_router(
),
)
)
+if (
+ OBSERVATORY_AI_COMPOSITIONS is not None
+ and OBSERVATORY_AI_COMPOSITION_RUNS is not None
+ and OBSERVATORY_DOMAIN_ONTOLOGY is not None
+):
+ app.include_router(
+ build_modular_observatory_router(
+ store=session_store,
+ compositions=OBSERVATORY_AI_COMPOSITIONS,
+ composition_runs=OBSERVATORY_AI_COMPOSITION_RUNS,
+ ontology=OBSERVATORY_DOMAIN_ONTOLOGY,
+ definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
+ binding=OBSERVATORY_PORTABLE_BINDING_SERVICE,
+ queue=OBSERVATORY_RECORDED_JOB_QUEUE,
+ view_profiles=OBSERVATORY_LAB_VIEW_PROFILES,
+ )
+ )
if OBSERVATORY_WORKER_DISPATCH_READY:
assert OBSERVATORY_RECORDED_JOB_QUEUE is not None
assert OBSERVATORY_WORKER_AUTHENTICATION is not None
@@ -1136,12 +1184,8 @@ if OBSERVATORY_WORKER_DISPATCH_READY:
build_observatory_worker_router(
OBSERVATORY_RECORDED_JOB_QUEUE,
authentication=OBSERVATORY_WORKER_AUTHENTICATION,
- artifact_transport=(
- OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport
- ),
- result_publisher=(
- OBSERVATORY_PORTABLE_WORKER_INTEGRATION.result_publisher
- ),
+ artifact_transport=(OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport),
+ result_publisher=(OBSERVATORY_PORTABLE_WORKER_INTEGRATION.result_publisher),
)
)
app.include_router(
diff --git a/src/k1link/web/modular_observatory_api.py b/src/k1link/web/modular_observatory_api.py
new file mode 100644
index 0000000..f5f1ea5
--- /dev/null
+++ b/src/k1link/web/modular_observatory_api.py
@@ -0,0 +1,469 @@
+from __future__ import annotations
+
+import hashlib
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query, Response
+from pydantic import BaseModel, ConfigDict, Field
+
+from k1link.observatory.composition_runs import (
+ CompositionRun,
+ CompositionRunError,
+ CompositionRunStore,
+)
+from k1link.observatory.domain_ontology import ObservatoryDomainOntology, ObservatoryOntologyError
+from k1link.observatory.lab_view_profiles import (
+ PROFILE_SCHEMA as LAB_VIEW_PROFILE_SCHEMA,
+)
+from k1link.observatory.lab_view_profiles import (
+ LabSceneProfile,
+ LabViewProfile,
+ LabViewProfileError,
+ LabViewProfileStore,
+)
+from k1link.observatory.modular_composition import COMPOSITION_SCHEMA, CompositionError
+from k1link.observatory.modular_composition_store import ModularCompositionStore
+from k1link.observatory.portable_queue_binding import (
+ PortableQueueBindingError,
+ PortableRecordedQueueBindingService,
+)
+from k1link.observatory.portable_run_definitions import PortableRunDefinitionRegistry
+from k1link.observatory.recorded_jobs import (
+ ObservatoryRecordedJobQueue,
+ ObservatoryRecordedQueueDuplicateError,
+ ObservatoryRecordedQueueError,
+)
+from k1link.observatory.source_admission import PortableSourceNotPreparedError
+from k1link.sessions import SessionNotFoundError, SessionStore
+
+_EXECUTABLE_SINGLE_MODULE_SETUPS = {
+ "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",
+}
+
+
+def _composition_error_detail(error: CompositionError) -> str:
+ detail = str(error)
+ if detail.startswith("unsupported value for "):
+ return (
+ "Параметры выбранного AI-модуля устарели. "
+ "Закройте окно, откройте его снова и повторите расчёт."
+ )
+ if detail == "module version is not installed":
+ return (
+ "Версия выбранного AI-модуля обновилась. "
+ "Закройте окно, откройте его снова и повторите расчёт."
+ )
+ if detail.startswith("select only one provider for "):
+ return "В одном слое можно выбрать только один AI-модуль."
+ if (
+ detail.startswith("select a module providing ")
+ or detail.startswith("ambiguous provider for ")
+ or detail == "unresolved module dependencies"
+ or detail == "cyclic module dependencies"
+ ):
+ return "Для выбранной конфигурации не хватает обязательного связанного модуля."
+ if detail == "select at least one AI module":
+ return "Выберите хотя бы один AI-модуль."
+ return "Конфигурацию AI-слоя не удалось проверить. Обновите окно и повторите выбор."
+
+
+class _Strict(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class AICompositionRequest(_Strict):
+ schema_version: str
+ source_session_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
+ selections: list[dict[str, Any]] = Field(min_length=1, max_length=6)
+ idempotency_key: str = Field(
+ min_length=1,
+ max_length=160,
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$",
+ )
+
+
+class AICompositionRunRenameRequest(_Strict):
+ schema_version: str
+ display_name: str = Field(min_length=1, max_length=160)
+
+
+class LabSceneProfileRequest(_Strict):
+ point_size: float = Field(ge=0.1, allow_inf_nan=False)
+ accumulation_seconds: float = Field(ge=0, allow_inf_nan=False)
+ color_mode: str = Field(pattern=r"^(intensity|height|distance|rgb|class)$")
+ palette: str = Field(pattern=r"^(turbo|viridis|plasma|grayscale)$")
+ show_grid: bool
+ show_labels: bool
+ show_camera_frustums: bool
+
+
+class LabViewProfileRequest(_Strict):
+ schema_version: str
+ result_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
+ scene_settings: LabSceneProfileRequest
+
+
+def build_modular_observatory_router(
+ *,
+ store: SessionStore,
+ compositions: ModularCompositionStore,
+ composition_runs: CompositionRunStore | None = None,
+ ontology: ObservatoryDomainOntology | None = None,
+ definitions: PortableRunDefinitionRegistry | None = None,
+ binding: PortableRecordedQueueBindingService | None = None,
+ queue: ObservatoryRecordedJobQueue | None = None,
+ view_profiles: LabViewProfileStore | None = None,
+) -> APIRouter:
+ router = APIRouter()
+
+ @router.get("/api/v1/observatory/ai-module-catalog")
+ def catalog() -> dict[str, object]:
+ return {
+ **compositions.registry.catalog(),
+ "authority": {
+ "commands_enabled": False,
+ "actuation_allowed": False,
+ "navigation_or_safety_accepted": False,
+ "production_accepted": False,
+ },
+ }
+
+ @router.get("/api/v1/observatory/lab-view-profiles/{result_id}")
+ def get_lab_view_profile(result_id: str) -> dict[str, object]:
+ if view_profiles is None:
+ raise HTTPException(503, "Профили отображения LAB недоступны.")
+ try:
+ profile = view_profiles.get(result_id)
+ except LabViewProfileError as exc:
+ raise HTTPException(422, "Некорректный профиль отображения LAB.") from exc
+ if profile is None:
+ raise HTTPException(404, "Профиль отображения LAB ещё не сохранён.")
+ return profile.as_dict()
+
+ @router.put("/api/v1/observatory/lab-view-profiles/{result_id}")
+ def put_lab_view_profile(result_id: str, request: LabViewProfileRequest) -> dict[str, object]:
+ if view_profiles is None:
+ raise HTTPException(503, "Профили отображения LAB недоступны.")
+ if request.schema_version != LAB_VIEW_PROFILE_SCHEMA or request.result_id != result_id:
+ raise HTTPException(422, "Профиль отображения относится к другой LAB.")
+ try:
+ settings = request.scene_settings
+ profile = LabViewProfile(
+ result_id=result_id,
+ scene_settings=LabSceneProfile(**settings.model_dump()),
+ updated_at_utc=datetime.now(UTC)
+ .isoformat(timespec="milliseconds")
+ .replace("+00:00", "Z"),
+ )
+ return view_profiles.save(profile).as_dict()
+ except LabViewProfileError as exc:
+ raise HTTPException(422, "Некорректные настройки отображения LAB.") from exc
+
+ @router.post("/api/v1/observatory/ai-compositions")
+ def save(request: AICompositionRequest) -> dict[str, object]:
+ if request.schema_version != COMPOSITION_SCHEMA:
+ raise HTTPException(
+ status_code=422,
+ detail="Версия конфигурации AI-слоя не поддерживается.",
+ )
+ try:
+ source = store.get_session(request.source_session_id).summary
+ except SessionNotFoundError as exc:
+ raise HTTPException(status_code=404, detail="Запись Обсерватории не найдена.") from exc
+ if source.lab is not None:
+ raise HTTPException(
+ status_code=409,
+ detail="AI-слой настраивается для исходной записи.",
+ )
+ selection = {"schema_version": request.schema_version, "selections": request.selections}
+ try:
+ composition, created = compositions.save(selection)
+ except CompositionError as exc:
+ raise HTTPException(status_code=409, detail=_composition_error_detail(exc)) from exc
+ selected_modules = tuple(
+ node.module.module_id
+ for node in composition.nodes
+ if node.module.group != "preparation"
+ )
+ selected = set(selected_modules)
+ for previous in (
+ ()
+ if composition_runs is None
+ else composition_runs.list(source_session_id=source.session_id)
+ ):
+ if previous.composition_sha256 != composition.sha256:
+ continue
+ try:
+ previous_jobs = (
+ tuple(queue.get(job_id) for job_id in previous.job_ids) if queue else ()
+ )
+ except (ObservatoryRecordedQueueError, ValueError):
+ previous_jobs = ()
+ if previous_jobs and all(
+ job.state != "failed" and job.publication_state != "failed" for job in previous_jobs
+ ):
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ "Эта конфигурация уже рассчитана или поставлена в очередь. "
+ "Выберите другую конфигурацию."
+ ),
+ )
+ setup_ids: list[str] = []
+ for module_id in ("ddrnet", "eomt", "tgs"):
+ if module_id in selected:
+ setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS[module_id])
+ if "object-distance" in selected:
+ setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS["object-distance"])
+ elif "rf-detr" in selected:
+ setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS["rf-detr"])
+ jobs = []
+ reason = "Для этой композиции ещё не установлен исполняемый пакет Worker 006."
+ if setup_ids and definitions is not None and binding is not None:
+ try:
+ checked = []
+ existing_by_setup = {}
+ for setup_id in setup_ids:
+ definition = definitions.resolve_setup(setup_id)
+ existing = None
+ if queue is not None:
+ candidates = queue.list_jobs(
+ source_session_id=source.session_id,
+ setup_id=setup_id,
+ definition_sha256=definition.definition_sha256,
+ limit=20,
+ )
+ existing = next(
+ (job for job in candidates if job.state != "failed"),
+ None,
+ )
+ if existing is not None:
+ existing_by_setup[setup_id] = existing
+ continue
+ try:
+ check = binding.check(
+ source_session_id=source.session_id,
+ setup_id=setup_id,
+ definition_sha256=definition.definition_sha256,
+ )
+ except PortableSourceNotPreparedError:
+ check = binding.prepare_check(
+ source_session_id=source.session_id,
+ setup_id=setup_id,
+ definition_sha256=definition.definition_sha256,
+ )
+ checked.append((setup_id, definition, check))
+ for setup_id, definition, check in checked:
+ key = hashlib.sha256(
+ f"{request.idempotency_key}\0{setup_id}".encode()
+ ).hexdigest()
+ job, _created = binding.submit(
+ source_session_id=source.session_id,
+ setup_id=setup_id,
+ definition_sha256=definition.definition_sha256,
+ expected_check_sha256=check.check_sha256,
+ idempotency_key=f"ai-layer:{key}",
+ )
+ existing_by_setup[setup_id] = job
+ jobs = [existing_by_setup[setup_id] for setup_id in setup_ids]
+ if composition_runs is None and not checked:
+ raise ObservatoryRecordedQueueDuplicateError("existing-composition")
+ reason = (
+ "Недостающие модули поставлены в очередь Worker 006; "
+ "готовые результаты использованы повторно."
+ if len(checked) < len(setup_ids)
+ else "Композиция поставлена в очередь Worker 006."
+ )
+ except ObservatoryRecordedQueueDuplicateError as exc:
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ "Эта конфигурация уже рассчитана или поставлена в очередь. "
+ "Выберите другую конфигурацию."
+ ),
+ ) from exc
+ except (PortableQueueBindingError, ObservatoryRecordedQueueError, ValueError) as exc:
+ raise HTTPException(
+ status_code=409,
+ detail="Композицию не удалось поставить в очередь Worker 006.",
+ ) from exc
+ if not jobs and definitions is not None and binding is not None:
+ raise HTTPException(
+ status_code=409,
+ detail="Для этой конфигурации пока нет исполняемых модулей Worker 006.",
+ )
+ run_projection: dict[str, object] | None = None
+ try:
+ if composition_runs is None or ontology is None or not jobs:
+ raise StopIteration
+ run = composition_runs.save(
+ source_session_id=source.session_id,
+ composition=composition,
+ setup_ids=tuple(setup_ids),
+ job_ids=tuple(job.job_id for job in jobs),
+ idempotency_key=request.idempotency_key,
+ created_at_utc=datetime.now(UTC)
+ .isoformat(timespec="milliseconds")
+ .replace("+00:00", "Z"),
+ )
+ presentation = ontology.project_composition(composition)
+ run_projection = {**run.as_dict(), "presentation": presentation}
+ except StopIteration:
+ pass
+ except (CompositionRunError, ObservatoryOntologyError) as exc:
+ raise HTTPException(
+ status_code=409,
+ detail="Связь композиции с результатами не удалось сохранить.",
+ ) from exc
+ return {
+ "schema_version": (
+ "missioncore.observatory-ai-composition-receipt/v3"
+ if run_projection is not None
+ else "missioncore.observatory-ai-composition-receipt/v2"
+ ),
+ "source_session_id": source.session_id,
+ "composition": composition.as_dict(),
+ "composition_sha256": composition.sha256,
+ "created": created,
+ **({"run": run_projection} if run_projection is not None else {}),
+ "dispatch": {
+ "ready": len(jobs) == len(setup_ids) and bool(jobs),
+ "reason": reason,
+ "setup_ids": setup_ids,
+ "jobs": [job.as_dict() for job in jobs],
+ },
+ }
+
+ def project_run(run: CompositionRun) -> dict[str, object]:
+ if ontology is None:
+ raise CompositionRunError("composition ontology is unavailable")
+ try:
+ exact = composition_runs.get(run.run_id)
+ jobs = [queue.get(job_id) for job_id in exact.job_ids] if queue else []
+ except (CompositionRunError, ObservatoryRecordedQueueError, ValueError):
+ raise
+ # The immutable composition document is the authority for projection;
+ # reconstruct the selected module projection from the run's sealed IDs.
+ presentation = ontology.project_module_ids(exact.module_ids)
+ published = bool(jobs) and all(
+ job.state == "succeeded" and job.publication_state == "published" and job.result_id
+ for job in jobs
+ )
+ failed = any(job.state == "failed" or job.publication_state == "failed" for job in jobs)
+ return {
+ **exact.as_dict(),
+ "state": "ready" if published else "failed" if failed else "running",
+ "configuration_label": (
+ f"{store.get_session(exact.source_session_id).summary.display_name} · "
+ f"{presentation['configuration_label']}"
+ ),
+ "display_name": composition_runs.display_name(exact.run_id),
+ "presentation": presentation,
+ "jobs": [job.as_dict() for job in jobs],
+ "result_ids": [job.result_id for job in jobs if job.result_id is not None],
+ }
+
+ @router.get("/api/v1/observatory/ai-composition-runs")
+ def composition_run_list(
+ source_session_id: str = Query(
+ min_length=1,
+ max_length=128,
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
+ ),
+ ) -> dict[str, object]:
+ try:
+ items = (
+ []
+ if composition_runs is None
+ else [
+ project_run(run)
+ for run in composition_runs.list(
+ source_session_id=source_session_id,
+ include_hidden=False,
+ )
+ ]
+ )
+ except (
+ CompositionRunError,
+ ObservatoryRecordedQueueError,
+ ObservatoryOntologyError,
+ ValueError,
+ ) as exc:
+ raise HTTPException(503, "Композиции AI-слоя недоступны.") from exc
+ return {
+ "schema_version": "missioncore.observatory-ai-composition-run-list/v1",
+ "items": items,
+ }
+
+ @router.patch("/api/v1/observatory/ai-composition-runs/{run_id}")
+ def rename_composition_run_projection(
+ run_id: str,
+ request: AICompositionRunRenameRequest,
+ ) -> dict[str, object]:
+ if request.schema_version != "missioncore.observatory-ai-composition-run-rename/v1":
+ raise HTTPException(422, "Версия переименования результата не поддерживается.")
+ if composition_runs is None:
+ raise HTTPException(503, "Композиции AI-слоя недоступны.")
+ try:
+ display_name = composition_runs.rename_projection(run_id, request.display_name)
+ except CompositionRunError as exc:
+ raise HTTPException(404, "Результат AI inference не найден.") from exc
+ return {
+ "schema_version": "missioncore.observatory-ai-composition-run-projection/v1",
+ "run_id": run_id,
+ "display_name": display_name,
+ }
+
+ @router.delete(
+ "/api/v1/observatory/ai-composition-runs/{run_id}",
+ status_code=204,
+ )
+ def delete_composition_run_projection(run_id: str) -> Response:
+ if composition_runs is None:
+ raise HTTPException(503, "Композиции AI-слоя недоступны.")
+ try:
+ composition_runs.delete_projection(run_id)
+ except CompositionRunError as exc:
+ raise HTTPException(404, "Результат AI inference не найден.") from exc
+ return Response(status_code=204)
+
+ @router.get("/api/v1/observatory/ai-runs")
+ def runs(
+ source_session_id: str = Query(
+ min_length=1,
+ max_length=128,
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
+ ),
+ ) -> dict[str, object]:
+ if queue is None:
+ raise HTTPException(status_code=503, detail="Очередь AI-слоёв недоступна.")
+ try:
+ jobs = [
+ job
+ for setup_id in _EXECUTABLE_SINGLE_MODULE_SETUPS.values()
+ for job in queue.list_jobs(
+ source_session_id=source_session_id,
+ setup_id=setup_id,
+ limit=20,
+ )
+ ]
+ except (ObservatoryRecordedQueueError, ValueError) as exc:
+ raise HTTPException(status_code=503, detail="Очередь AI-слоёв недоступна.") from exc
+ jobs.sort(key=lambda job: job.created_at_utc, reverse=True)
+ return {
+ "schema_version": "missioncore.observatory-recorded-job-list/v1",
+ "items": [job.as_dict() for job in jobs[:20]],
+ "authority": {
+ "commands_enabled": False,
+ "actuation_allowed": False,
+ "navigation_or_safety_accepted": False,
+ "production_accepted": False,
+ },
+ }
+
+ return router
diff --git a/src/k1link/web/portable_replay_api.py b/src/k1link/web/portable_replay_api.py
index 6c7bbe9..28fe586 100644
--- a/src/k1link/web/portable_replay_api.py
+++ b/src/k1link/web/portable_replay_api.py
@@ -9,15 +9,33 @@ from fastapi import APIRouter, HTTPException, Path, Query, Response
from fastapi.responses import FileResponse
from k1link.laboratory.canonical_rerun_overlay import CanonicalLabOverlayError
+from k1link.observatory.composition_runs import CompositionRunError, CompositionRunStore
from k1link.observatory.portable_replay import PortableReplayService
from k1link.observatory.portable_result_view import PortableResultViewError
+from k1link.observatory.recorded_jobs import (
+ ObservatoryRecordedJobQueue,
+ ObservatoryRecordedQueueError,
+)
-ResultId = Annotated[str, Path(pattern=r"^m49-tgs-portable-review-[a-f0-9]{64}$")]
+ResultId = Annotated[
+ str,
+ Path(
+ pattern=(
+ r"^(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
+ r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}$"
+ )
+ ),
+]
BaseSha = Annotated[str, Path(pattern=r"^[a-f0-9]{64}$")]
_LOG = logging.getLogger(__name__)
-def build_portable_replay_router(service: PortableReplayService) -> APIRouter:
+def build_portable_replay_router(
+ service: PortableReplayService,
+ *,
+ composition_runs: CompositionRunStore | None = None,
+ queue: ObservatoryRecordedJobQueue | None = None,
+) -> APIRouter:
router = APIRouter(tags=["observatory"])
path = "/api/v1/observatory/portable-results/{result_id}/replays/{base_sha}/recording.rrd"
@@ -72,4 +90,85 @@ def build_portable_replay_router(service: PortableReplayService) -> APIRouter:
},
)
+ composition_path = (
+ "/api/v1/observatory/ai-composition-runs/{run_id}/replays/{base_sha}/recording.rrd"
+ )
+
+ def composition_members(run_id: str) -> tuple[str, ...]:
+ if composition_runs is None or queue is None:
+ raise HTTPException(503, "Составной просмотр AI-слоя недоступен.")
+ try:
+ run = composition_runs.get(run_id)
+ jobs = tuple(queue.get(job_id) for job_id in run.job_ids)
+ except (CompositionRunError, ObservatoryRecordedQueueError, ValueError) as exc:
+ raise HTTPException(409, "Составной запуск AI-слоя недоступен.") from exc
+ if not jobs or any(
+ job.state != "succeeded"
+ or job.publication_state != "published"
+ or job.result_id is None
+ for job in jobs
+ ):
+ raise HTTPException(409, "Расчёт всех модулей этой конфигурации ещё не завершён.")
+ return tuple(job.result_id for job in jobs if job.result_id is not None)
+
+ @router.head(composition_path)
+ def prepare_composition(
+ run_id: Annotated[str, Path(pattern=r"^ai-composition-[a-f0-9]{64}$")],
+ base_sha: BaseSha,
+ ) -> Response:
+ try:
+ artifact = service.prepare_composition(run_id, composition_members(run_id), base_sha)
+ except (
+ ValueError,
+ OSError,
+ KeyError,
+ TypeError,
+ CanonicalLabOverlayError,
+ PortableResultViewError,
+ ) as exc:
+ _LOG.exception("Composition replay packaging rejected")
+ raise HTTPException(
+ 409, "Составной результат не удалось подготовить к просмотру."
+ ) from exc
+ return Response(
+ media_type="application/vnd.rerun.rrd",
+ headers={
+ "Content-Length": str(artifact.byte_length),
+ "ETag": f'"{artifact.sha256}"',
+ "X-Rerun-Format": "RRF2",
+ "Cache-Control": "private, no-store",
+ },
+ )
+
+ @router.get(composition_path)
+ def read_composition(
+ run_id: Annotated[str, Path(pattern=r"^ai-composition-[a-f0-9]{64}$")],
+ base_sha: BaseSha,
+ generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
+ ) -> FileResponse:
+ try:
+ artifact = service.cached_composition(run_id, composition_members(run_id), base_sha)
+ except (
+ ValueError,
+ OSError,
+ KeyError,
+ TypeError,
+ PortableResultViewError,
+ ) as exc:
+ raise HTTPException(409, "Кэш составного результата не прошёл проверку.") from exc
+ if artifact is None:
+ raise HTTPException(409, "Составной просмотр ещё не подготовлен.")
+ if artifact.sha256 != generation:
+ raise HTTPException(412, "Версия составного просмотра изменилась.")
+ return FileResponse(
+ artifact.path,
+ media_type="application/vnd.rerun.rrd",
+ headers={
+ "ETag": f'"{artifact.sha256}"',
+ "X-Rerun-Format": "RRF2",
+ "Cache-Control": "private, max-age=31536000, immutable",
+ "X-Content-Type-Options": "nosniff",
+ },
+ )
+
return router
diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py
index c5b4b4e..226f2da 100644
--- a/src/k1link/web/session_api.py
+++ b/src/k1link/web/session_api.py
@@ -49,7 +49,10 @@ from k1link.viewer.recorded import (
from k1link.viewer.recorded import (
RecordedBlueprintError,
recorded_blueprint_rrd,
+ recorded_blueprint_sessions,
)
+from k1link.viewer.recorded_blueprint_lifecycle import BlueprintSessionReleased
+from k1link.viewer.recorded_camera_bounds import recorded_orbital_radius_limit
from k1link.viewer.rerun_bridge import RerunSceneSettings
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
@@ -102,7 +105,7 @@ class ReplayRequest(StrictApiModel):
loop: bool = False
-class RecordedBlueprintRequest(StrictApiModel):
+class RecordedBlueprintIdentity(StrictApiModel):
application_id: Literal["nodedc_mission_core_recorded"]
recording_id: str = Field(
min_length=1,
@@ -114,25 +117,58 @@ class RecordedBlueprintRequest(StrictApiModel):
max_length=32,
pattern=r"^[a-f0-9]{32}$",
)
- accumulation_seconds: float = Field(strict=True, ge=0.0, le=3600.0)
+
+
+class RecordedBlueprintLifecycleRequest(RecordedBlueprintIdentity):
+ action: Literal["renew", "release"]
+
+
+EyeCoordinate = Annotated[float, Field(strict=True, allow_inf_nan=False)]
+EyeVector = tuple[EyeCoordinate, EyeCoordinate, EyeCoordinate]
+
+
+class RecordedBlueprintRequest(RecordedBlueprintIdentity):
+ accumulation_seconds: float = Field(strict=True, ge=0.0, allow_inf_nan=False)
show_points: StrictBool
show_trajectory: StrictBool
show_grid: StrictBool
- point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
+ point_size: float = Field(default=2.5, strict=True, ge=0.1, allow_inf_nan=False)
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
view_reset_generation: Literal[0, 1] = 0
unified_perception: StrictBool = False
+ unified_camera_share: float = Field(strict=True, ge=0.1, le=0.9, default=0.46)
semantic_layer: Literal["city", "vegetation"] | None = None
plan_view: StrictBool = False
show_detections_2d: StrictBool = False
+ show_camera_image: StrictBool = True
show_segmentation: StrictBool = False
show_cuboids_3d: StrictBool = False
show_costmap: StrictBool = False
reactivate_updates: StrictBool = False
follow_trajectory: StrictBool = False
+ eye_position: EyeVector | None = None
+ eye_look_target: EyeVector | None = None
+ eye_up: EyeVector | None = None
+ current_time_ns: int | None = Field(default=None, strict=True, ge=0, le=MAX_SAFE_INTEGER)
+
+ @model_validator(mode="after")
+ def validate_eye_vectors(self) -> RecordedBlueprintRequest:
+ vectors = (self.eye_position, self.eye_look_target, self.eye_up)
+ if any(vector is None for vector in vectors):
+ if not all(vector is None for vector in vectors):
+ raise ValueError("all eye vectors must be supplied together")
+ return self
+ assert self.eye_position is not None
+ assert self.eye_look_target is not None
+ assert self.eye_up is not None
+ if self.eye_position == self.eye_look_target:
+ raise ValueError("eye position and look target must differ")
+ if sum(value * value for value in self.eye_up) <= 1.0e-12:
+ raise ValueError("eye up vector must be non-zero")
+ return self
class RecordedPerceptionRequest(StrictApiModel):
@@ -365,6 +401,7 @@ def build_session_router(
cursor: str | None = Query(default=None, max_length=128),
scope: Literal["all", "source", "laboratory"] = "all",
lab_contract: Literal["v1", "v2", "v3"] = "v1",
+ pagination: Literal["cursor-v1"] | None = None,
) -> dict[str, Any]:
try:
_refresh_catalog(catalog_refresher)
@@ -375,6 +412,14 @@ def build_session_router(
include_capability_projections=lab_contract in ("v2", "v3"),
)
return {
+ **(
+ {
+ "schema_version": "missioncore.observation-session-page/v1",
+ "next_cursor": page.next_cursor,
+ }
+ if pagination == "cursor-v1"
+ else {}
+ ),
"items": [
{
"id": item.session_id,
@@ -391,9 +436,7 @@ def build_session_router(
else item.capture_attestation.as_dict()
),
**(
- {
- "lab": lab_catalog_document(item, lab_contract)
- }
+ {"lab": lab_catalog_document(item, lab_contract)}
if item.lab is not None
else {}
),
@@ -411,7 +454,7 @@ def build_session_router(
}
for item in page.items
if item.started_at_utc is not None
- ]
+ ],
}
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -870,9 +913,7 @@ def build_session_router(
**response_kwargs,
)
- @router.get(
- "/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
- )
+ @router.get("/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame")
async def get_observation_session_canonical_lab_spatial_frame(
session_id: str,
generation: Annotated[str, Query(min_length=64, max_length=64)],
@@ -937,20 +978,37 @@ def build_session_router(
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": (
- f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:'
- f'{payload["source_time_ns"]}"'
+ f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:{payload["source_time_ns"]}"'
),
"X-Content-Type-Options": "nosniff",
},
)
+ @router.post("/api/v1/observation-sessions/{session_id}/blueprint-lifecycle")
+ async def update_recorded_blueprint_lifecycle(
+ session_id: str,
+ request: RecordedBlueprintLifecycleRequest,
+ ) -> Response:
+ # Releasing an ephemeral owner must still work after source removal.
+ # The random viewport identity authorizes only its own memory resource;
+ # this route never materializes or deletes recordings/artifacts.
+ if not SAFE_SOURCE_ID.fullmatch(session_id):
+ raise HTTPException(status_code=422, detail="Некорректный идентификатор сессии.")
+ key = (request.application_id, request.recording_id, request.blueprint_session_id)
+ if request.action == "release":
+ await run_in_threadpool(recorded_blueprint_sessions.release, key)
+ else:
+ await run_in_threadpool(recorded_blueprint_sessions.renew, key)
+ return Response(status_code=204, headers={"Cache-Control": "no-store"})
+
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
async def get_observation_session_blueprint(
session_id: str,
request: RecordedBlueprintRequest,
) -> Response:
+ camera_max_orbital_radius: float | None = None
try:
- await run_in_threadpool(
+ command = await run_in_threadpool(
_prepare_replay,
store,
catalog_refresher,
@@ -977,19 +1035,55 @@ def build_session_router(
active_view=request.active_view,
view_reset_generation=request.view_reset_generation,
unified_perception=request.unified_perception,
+ unified_camera_share=request.unified_camera_share,
semantic_layer=request.semantic_layer,
plan_view=request.plan_view,
show_detections_2d=request.show_detections_2d,
+ show_camera_image=request.show_camera_image,
show_segmentation=request.show_segmentation,
show_cuboids_3d=request.show_cuboids_3d,
show_costmap=request.show_costmap,
reactivate_updates=request.reactivate_updates,
follow_trajectory=request.follow_trajectory,
+ eye_position=request.eye_position,
+ eye_look_target=request.eye_look_target,
+ eye_up=request.eye_up,
)
+ if request.current_time_ns is not None:
+ camera_recording = None
+ if recording_preparation_manager is not None:
+ snapshot = recording_preparation_manager.status(session_id)
+ if (
+ snapshot is not None
+ and snapshot.state == "ready"
+ and snapshot.recording is not None
+ ):
+ camera_recording = snapshot.recording
+ # A composition replay consumes the immutable base launch but
+ # does not GET its recording. Its short launch reservation can
+ # therefore expire while the combined RRD remains open. Restore
+ # the already-published base descriptor with bounded stat checks
+ # so later layer toggles still receive the native zoom limit.
+ if camera_recording is None and recording_materializer is not None:
+ camera_recording = await run_in_threadpool(
+ recording_materializer.restore_published,
+ command,
+ )
+ if camera_recording is not None:
+ camera_max_orbital_radius = await run_in_threadpool(
+ recorded_orbital_radius_limit,
+ camera_recording.path,
+ current_time_ns=request.current_time_ns,
+ accumulation_seconds=request.accumulation_seconds,
+ show_points=request.show_points,
+ show_trajectory=request.show_trajectory,
+ )
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (SessionNotReplayableError, SessionIntegrityError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except BlueprintSessionReleased as exc:
+ raise HTTPException(status_code=410, detail="Сессия визуализатора закрыта.") from exc
except RecordedBlueprintError as exc:
raise HTTPException(
status_code=500,
@@ -1007,6 +1101,16 @@ def build_session_router(
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Content-Disposition": 'inline; filename="blueprint.rrd"',
+ **(
+ {
+ "X-MissionCore-Camera-Max-Orbital-Radius": format(
+ camera_max_orbital_radius,
+ ".9g",
+ )
+ }
+ if camera_max_orbital_radius is not None
+ else {}
+ ),
},
)
diff --git a/tests/test_composition_runs.py b/tests/test_composition_runs.py
new file mode 100644
index 0000000..15de2df
--- /dev/null
+++ b/tests/test_composition_runs.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from k1link.observatory.composition_runs import CompositionRunStore
+from k1link.observatory.modular_composition import COMPOSITION_SCHEMA, ModuleRegistry
+
+
+def test_operator_projection_can_be_renamed_and_hidden_without_changing_run(tmp_path: Path) -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+ module = next(item for item in registry.modules if item.module_id == "tgs")
+ composition = registry.compose(
+ {
+ "schema_version": COMPOSITION_SCHEMA,
+ "selections": [
+ {
+ "group": module.group,
+ "module_id": module.module_id,
+ "module_sha256": module.sha256,
+ "parameters": {},
+ }
+ ],
+ }
+ )
+ store = CompositionRunStore(tmp_path / "runs")
+ run = store.save(
+ source_session_id="source-1",
+ composition=composition,
+ setup_ids=("setup-1",),
+ job_ids=("job-1",),
+ idempotency_key="run-1",
+ created_at_utc="2026-09-04T12:00:00.000Z",
+ )
+
+ assert store.display_name(run.run_id) is None
+ assert store.list(source_session_id="source-1", include_hidden=False) == (run,)
+ assert store.rename_projection(run.run_id, " Маршрут у школы ") == "Маршрут у школы"
+ assert store.display_name(run.run_id) == "Маршрут у школы"
+
+ store.delete_projection(run.run_id)
+ assert store.list(source_session_id="source-1", include_hidden=False) == ()
+ assert store.list(source_session_id="source-1") == (run,)
+ assert store.get(run.run_id) == run
diff --git a/tests/test_lidar_preparation.py b/tests/test_lidar_preparation.py
new file mode 100644
index 0000000..0e4ff0b
--- /dev/null
+++ b/tests/test_lidar_preparation.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+
+import pytest
+from test_lidar_replay import _capture
+
+from k1link.compute import lidar_replay
+from k1link.compute.lidar_preparation import prepare_lidar_replay_pack_v2
+from k1link.compute.lidar_replay import LidarReplayError
+
+
+def test_warm_preparation_reuses_legacy_identity_without_source_decode(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ source = _capture(tmp_path)
+ producer = Path(lidar_replay.__file__)
+ producer_digest = hashlib.sha256(producer.read_bytes()).hexdigest()
+ original = lidar_replay.build_lidar_replay_pack_v2(source, tmp_path / "packs")
+ manifest_before = (original / "manifest.json").read_bytes()
+ closed = []
+ original_close = lidar_replay.LidarReplayPackV2.close
+
+ def no_decode(*args: object, **kwargs: object) -> None:
+ pytest.fail("cache hit must not decode the original capture")
+
+ def close(pack: lidar_replay.LidarReplayPackV2) -> None:
+ original_close(pack)
+ closed.append(not pack.arrays._arrays)
+
+ monkeypatch.setattr(lidar_replay, "_capture_arrays", no_decode)
+ monkeypatch.setattr(lidar_replay.LidarReplayPackV2, "close", close)
+ assert prepare_lidar_replay_pack_v2(source, tmp_path / "packs") == original
+ assert (original / "manifest.json").read_bytes() == manifest_before
+ assert json.loads(manifest_before)["identity"]["producer_sha256"] == producer_digest
+ assert hashlib.sha256(producer.read_bytes()).hexdigest() == producer_digest
+ assert closed == [True]
+
+
+def test_cold_preparation_calls_existing_builder_once(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ source = _capture(tmp_path)
+ calls = []
+ original = lidar_replay._capture_arrays
+
+ def decode(path: Path):
+ calls.append(path)
+ return original(path)
+
+ monkeypatch.setattr(lidar_replay, "_capture_arrays", decode)
+ result = prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+ assert result.is_dir()
+ assert calls == [source]
+
+
+@pytest.mark.parametrize("changed", ["metadata", "origin", "remove-origin", "raw", "session"])
+def test_changed_source_never_reuses_previous_pack(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ changed: str,
+) -> None:
+ source = _capture(tmp_path)
+ output = tmp_path / "packs"
+ old = prepare_lidar_replay_pack_v2(source, output)
+ if changed == "metadata":
+ path = source.with_name("mqtt.metadata.jsonl")
+ path.write_bytes(path.read_bytes().replace(b"5010000000", b"5009000000"))
+ elif changed == "origin":
+ path = source.with_name("mqtt.timeline.origin.json")
+ path.write_bytes(path.read_bytes().replace(b"5000000000", b"4999999999"))
+ elif changed == "remove-origin":
+ source.with_name("mqtt.timeline.origin.json").unlink()
+ elif changed == "raw":
+ # The cache boundary checks bytes before trying to decode invalid data.
+ source.write_bytes(source.read_bytes()[:-1] + b"x")
+ called = []
+
+ def build(*args: object, **kwargs: object) -> Path:
+ called.append((args, kwargs))
+ return output / "new-test-pack"
+
+ monkeypatch.setattr(lidar_replay, "build_lidar_replay_pack_v2", build)
+ result = prepare_lidar_replay_pack_v2(
+ source,
+ output,
+ session_id="other-session" if changed == "session" else None,
+ )
+ assert result != old
+ assert len(called) == 1
+ assert old.is_dir()
+
+
+@pytest.mark.parametrize(
+ "artifact", ["lidar-replay.npz", "quality-report.json", "equivalence-report.json"]
+)
+def test_corrupt_matching_pack_fails_without_rebuild_or_overwrite(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ artifact: str,
+) -> None:
+ source = _capture(tmp_path)
+ root = prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+ path = root / artifact
+ damaged = b"damaged-evidence"
+ path.write_bytes(damaged)
+
+ def forbidden(*args: object, **kwargs: object) -> None:
+ pytest.fail("must preserve corrupt evidence instead of overwriting")
+
+ monkeypatch.setattr(lidar_replay, "build_lidar_replay_pack_v2", forbidden)
+ with pytest.raises(LidarReplayError, match="artifact identity changed"):
+ prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+ assert path.read_bytes() == damaged
+
+
+def test_report_threshold_collision_is_explicit_and_releases_arrays(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ source = _capture(tmp_path)
+ root = prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+ closed = []
+ original = lidar_replay.LidarReplayPackV2.close
+
+ def close(pack: lidar_replay.LidarReplayPackV2) -> None:
+ original(pack)
+ closed.append(not pack.arrays._arrays)
+
+ monkeypatch.setattr(lidar_replay.LidarReplayPackV2, "close", close)
+ with pytest.raises(LidarReplayError, match="threshold differs"):
+ prepare_lidar_replay_pack_v2(source, tmp_path / "packs", pose_coverage_threshold_ms=50)
+ assert closed == [True]
+ assert root.is_dir()
+
+
+def test_source_mutation_during_cache_validation_is_rejected(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ source = _capture(tmp_path)
+ prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+ original = lidar_replay.LidarReplayPackV2.__init__
+
+ def changed(pack: lidar_replay.LidarReplayPackV2, root: Path) -> None:
+ original(pack, root)
+ source.with_name("mqtt.metadata.jsonl").touch()
+
+ monkeypatch.setattr(lidar_replay.LidarReplayPackV2, "__init__", changed)
+ with pytest.raises(LidarReplayError, match="changed during preparation"):
+ prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+
+
+def test_missing_metadata_never_uses_cache(tmp_path: Path) -> None:
+ source = _capture(tmp_path)
+ prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+ source.with_name("mqtt.metadata.jsonl").unlink()
+ with pytest.raises(LidarReplayError, match="exact host timing"):
+ prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+
+
+def test_symlink_manifest_is_not_followed(tmp_path: Path) -> None:
+ source = _capture(tmp_path)
+ root = prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
+ manifest = root / "manifest.json"
+ saved = root / "saved-manifest.json"
+ manifest.rename(saved)
+ manifest.symlink_to(saved)
+ with pytest.raises(LidarReplayError, match="manifest cannot be a symlink"):
+ prepare_lidar_replay_pack_v2(source, tmp_path / "packs")
diff --git a/tests/test_m49_portable_executor_release.py b/tests/test_m49_portable_executor_release.py
index 65849a5..d6c8a9a 100644
--- a/tests/test_m49_portable_executor_release.py
+++ b/tests/test_m49_portable_executor_release.py
@@ -707,7 +707,7 @@ def test_fixed_worker_stage_consumer_requires_manifested_metadata_member(
built_from.append(capture)
return worker_root
- monkeypatch.setattr(source_module, "build_lidar_replay_pack_v2", fake_build)
+ monkeypatch.setattr(source_module, "prepare_lidar_replay_pack_v2", fake_build)
class _DeliveredSource:
def materialize(self, requested: SealedObservatoryRecordedJob) -> PortableWorkerSourceStage:
diff --git a/tests/test_modular_observatory_api.py b/tests/test_modular_observatory_api.py
new file mode 100644
index 0000000..ac08995
--- /dev/null
+++ b/tests/test_modular_observatory_api.py
@@ -0,0 +1,508 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from k1link.observatory.composition_runs import CompositionRunStore
+from k1link.observatory.lab_view_profiles import PROFILE_SCHEMA, LabViewProfileStore
+from k1link.observatory.modular_composition import COMPOSITION_SCHEMA, ModuleRegistry
+from k1link.observatory.modular_composition_store import ModularCompositionStore
+from k1link.observatory.source_admission import PortableSourceNotPreparedError
+from k1link.web.modular_observatory_api import build_modular_observatory_router
+
+
+def _client(tmp_path: Path) -> tuple[TestClient, dict]:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+
+ class Store:
+ def get_session(self, session_id: str) -> object:
+ return SimpleNamespace(summary=SimpleNamespace(session_id=session_id, lab=None))
+
+ app = FastAPI()
+ app.include_router(
+ build_modular_observatory_router(
+ store=Store(), # type: ignore[arg-type]
+ compositions=ModularCompositionStore(tmp_path / "compositions", registry),
+ view_profiles=LabViewProfileStore(tmp_path / "view-profiles"),
+ )
+ )
+ catalog = TestClient(app).get("/api/v1/observatory/ai-module-catalog").json()
+ return TestClient(app), catalog
+
+
+def test_composition_projection_can_be_renamed_and_removed_through_api(
+ tmp_path: Path,
+) -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+ tgs = next(module for module in registry.modules if module.module_id == "tgs")
+ composition = registry.compose(
+ {
+ "schema_version": COMPOSITION_SCHEMA,
+ "selections": [
+ {
+ "group": tgs.group,
+ "module_id": tgs.module_id,
+ "module_sha256": tgs.sha256,
+ "parameters": {},
+ }
+ ],
+ }
+ )
+ runs = CompositionRunStore(tmp_path / "runs")
+ run = runs.save(
+ source_session_id="source-1",
+ composition=composition,
+ setup_ids=("setup-1",),
+ job_ids=("job-1",),
+ idempotency_key="run-1",
+ created_at_utc="2026-09-04T12:00:00.000Z",
+ )
+
+ class Store:
+ def get_session(self, session_id: str) -> object:
+ return SimpleNamespace(summary=SimpleNamespace(session_id=session_id, lab=None))
+
+ app = FastAPI()
+ app.include_router(
+ build_modular_observatory_router(
+ store=Store(), # type: ignore[arg-type]
+ compositions=ModularCompositionStore(tmp_path / "compositions", registry),
+ composition_runs=runs,
+ )
+ )
+ client = TestClient(app)
+ renamed = client.patch(
+ f"/api/v1/observatory/ai-composition-runs/{run.run_id}",
+ json={
+ "schema_version": "missioncore.observatory-ai-composition-run-rename/v1",
+ "display_name": " Контрольный прогон ",
+ },
+ )
+ assert renamed.status_code == 200
+ assert renamed.json() == {
+ "schema_version": "missioncore.observatory-ai-composition-run-projection/v1",
+ "run_id": run.run_id,
+ "display_name": "Контрольный прогон",
+ }
+ assert runs.display_name(run.run_id) == "Контрольный прогон"
+
+ removed = client.delete(f"/api/v1/observatory/ai-composition-runs/{run.run_id}")
+ assert removed.status_code == 204
+ assert runs.list(source_session_id="source-1", include_hidden=False) == ()
+ assert runs.get(run.run_id) == run
+
+
+def test_lab_view_profile_is_saved_against_the_exact_result(tmp_path: Path) -> None:
+ client, _catalog = _client(tmp_path)
+ result_id = "modular-composition-result-123"
+ endpoint = f"/api/v1/observatory/lab-view-profiles/{result_id}"
+ assert client.get(endpoint).status_code == 404
+
+ document = {
+ "schema_version": PROFILE_SCHEMA,
+ "result_id": result_id,
+ "scene_settings": {
+ "point_size": 128.5,
+ "accumulation_seconds": 50_000,
+ "color_mode": "height",
+ "palette": "viridis",
+ "show_grid": False,
+ "show_labels": True,
+ "show_camera_frustums": False,
+ },
+ }
+ saved = client.put(endpoint, json=document)
+ assert saved.status_code == 200
+ assert saved.json()["scene_settings"] == document["scene_settings"]
+ assert saved.json()["updated_at_utc"].endswith("Z")
+ assert client.get(endpoint).json() == saved.json()
+
+ foreign = {**document, "result_id": "another-result"}
+ rejected = client.put(endpoint, json=foreign)
+ assert rejected.status_code == 422
+ assert rejected.json()["detail"] == "Профиль отображения относится к другой LAB."
+
+
+def test_catalog_and_idempotent_independent_ddrnet_composition(tmp_path: Path) -> None:
+ client, catalog = _client(tmp_path)
+ assert catalog["schema_version"] == "missioncore.observatory-ai-module-catalog/v1"
+ segmenters = next(group for group in catalog["groups"] if group["group"] == "segmentation")
+ assert [module["docker_name"] for module in segmenters["modules"]] == [
+ "ndc-mission-core-ai-module-ddrnet",
+ "ndc-mission-core-ai-module-eomt",
+ ]
+ geometry = next(group for group in catalog["groups"] if group["group"] == "geometry")
+ assert [module["docker_name"] for module in geometry["modules"]] == [
+ "ndc-mission-core-ai-module-tgs"
+ ]
+ detection = next(group for group in catalog["groups"] if group["group"] == "detection")
+ assert [module["docker_name"] for module in detection["modules"]] == [
+ "ndc-mission-core-ai-module-rf-detr"
+ ]
+ ranges = next(group for group in catalog["groups"] if group["group"] == "range")
+ assert [module["docker_name"] for module in ranges["modules"]] == [
+ "ndc-mission-core-ai-module-object-distance"
+ ]
+ ddrnet = segmenters["modules"][0]
+ request = {
+ "schema_version": COMPOSITION_SCHEMA,
+ "source_session_id": "source-1",
+ "idempotency_key": "ai-layer:source-1:ddrnet",
+ "selections": [
+ {
+ "group": "segmentation",
+ "module_id": "ddrnet",
+ "module_sha256": ddrnet["module_sha256"],
+ "parameters": {},
+ }
+ ],
+ }
+ first = client.post("/api/v1/observatory/ai-compositions", json=request)
+ second = client.post("/api/v1/observatory/ai-compositions", json=request)
+ assert first.status_code == second.status_code == 200
+ assert first.json()["created"] is True and second.json()["created"] is False
+ assert first.json()["composition_sha256"] == second.json()["composition_sha256"]
+ assert first.json()["composition"]["outputs"] == ["segmentation.mask"]
+ stored = list((tmp_path / "compositions").glob("*.json"))
+ assert len(stored) == 1
+ assert json.loads(stored[0].read_bytes())["execution"]["max_parallel_nodes"] == 1
+
+
+def test_server_rejects_two_segmentation_providers(tmp_path: Path) -> None:
+ client, catalog = _client(tmp_path)
+ modules = next(group for group in catalog["groups"] if group["group"] == "segmentation")[
+ "modules"
+ ]
+ response = client.post(
+ "/api/v1/observatory/ai-compositions",
+ json={
+ "schema_version": COMPOSITION_SCHEMA,
+ "source_session_id": "source-1",
+ "idempotency_key": "ai-layer:source-1:both",
+ "selections": [
+ {
+ "group": "segmentation",
+ "module_id": module["module_id"],
+ "module_sha256": module["module_sha256"],
+ "parameters": {},
+ }
+ for module in modules
+ ],
+ },
+ )
+ assert response.status_code == 409
+ assert response.json()["detail"] == "В одном слое можно выбрать только один AI-модуль."
+
+
+def test_tgs_browser_numeric_default_is_accepted_and_bad_value_is_explained(
+ tmp_path: Path,
+) -> None:
+ client, catalog = _client(tmp_path)
+ tgs = next(
+ module
+ for group in catalog["groups"]
+ for module in group["modules"]
+ if module["module_id"] == "tgs"
+ )
+
+ def request(value: int) -> dict:
+ return {
+ "schema_version": COMPOSITION_SCHEMA,
+ "source_session_id": "source-1",
+ "idempotency_key": f"ai-layer:source-1:tgs:{value}",
+ "selections": [
+ {
+ "group": "geometry",
+ "module_id": "tgs",
+ "module_sha256": tgs["module_sha256"],
+ "parameters": {"history-seconds": value},
+ }
+ ],
+ }
+
+ accepted = client.post("/api/v1/observatory/ai-compositions", json=request(1))
+ assert accepted.status_code == 200
+
+ rejected = client.post("/api/v1/observatory/ai-compositions", json=request(2))
+ assert rejected.status_code == 409
+ assert rejected.json()["detail"] == (
+ "Параметры выбранного AI-модуля устарели. "
+ "Закройте окно, откройте его снова и повторите расчёт."
+ )
+
+
+def test_tgs_and_object_distance_dispatch_as_two_independent_worker_jobs(tmp_path: Path) -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+
+ class Store:
+ def get_session(self, session_id: str) -> object:
+ return SimpleNamespace(summary=SimpleNamespace(session_id=session_id, lab=None))
+
+ class Definitions:
+ def resolve_setup(self, setup_id: str) -> object:
+ return SimpleNamespace(definition_sha256=(setup_id.encode().hex() + "0" * 64)[:64])
+
+ submitted: list[str] = []
+
+ class Binding:
+ def check(self, **kwargs: object) -> object:
+ return SimpleNamespace(check_sha256="c" * 64)
+
+ def submit(self, **kwargs: object) -> tuple[object, bool]:
+ setup_id = str(kwargs["setup_id"])
+ submitted.append(setup_id)
+ return SimpleNamespace(as_dict=lambda: {"setup_id": setup_id}), True
+
+ app = FastAPI()
+ app.include_router(
+ build_modular_observatory_router(
+ store=Store(), # type: ignore[arg-type]
+ compositions=ModularCompositionStore(tmp_path / "compositions", registry),
+ definitions=Definitions(), # type: ignore[arg-type]
+ binding=Binding(), # type: ignore[arg-type]
+ )
+ )
+ catalog = TestClient(app).get("/api/v1/observatory/ai-module-catalog").json()
+ by_id = {
+ module["module_id"]: {**module, "group": group["group"]}
+ for group in catalog["groups"]
+ for module in group["modules"]
+ }
+ response = TestClient(app).post(
+ "/api/v1/observatory/ai-compositions",
+ json={
+ "schema_version": COMPOSITION_SCHEMA,
+ "source_session_id": "source-1",
+ "idempotency_key": "ai-layer:source-1:tgs-and-range",
+ "selections": [
+ {
+ "group": by_id[module_id]["group"],
+ "module_id": module_id,
+ "module_sha256": by_id[module_id]["module_sha256"],
+ "parameters": {},
+ }
+ for module_id in ("tgs", "rf-detr", "object-distance")
+ ],
+ },
+ )
+ assert response.status_code == 200
+ assert response.json()["dispatch"]["ready"] is True
+ assert submitted == ["m49-tgs-portable-v2", "ai-range-object-distance-v1"]
+ assert response.json()["dispatch"]["setup_ids"] == submitted
+
+
+def test_existing_record_module_is_rejected_before_a_second_worker_submission(
+ tmp_path: Path,
+) -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+ tgs = next(module for module in registry.modules if module.module_id == "tgs")
+
+ class Store:
+ def get_session(self, session_id: str) -> object:
+ return SimpleNamespace(summary=SimpleNamespace(session_id=session_id, lab=None))
+
+ class Definitions:
+ def resolve_setup(self, setup_id: str) -> object:
+ return SimpleNamespace(definition_sha256="d" * 64)
+
+ class Binding:
+ def check(self, **kwargs: object) -> object:
+ raise AssertionError("duplicate must be rejected before source preparation")
+
+ def submit(self, **kwargs: object) -> tuple[object, bool]:
+ raise AssertionError("duplicate must not reach Worker submission")
+
+ class Queue:
+ def list_jobs(self, **kwargs: object) -> list[object]:
+ assert kwargs == {
+ "source_session_id": "source-1",
+ "setup_id": "m49-tgs-portable-v2",
+ "definition_sha256": "d" * 64,
+ "limit": 20,
+ }
+ return [
+ SimpleNamespace(
+ job_id="already-calculated",
+ state="succeeded",
+ )
+ ]
+
+ app = FastAPI()
+ app.include_router(
+ build_modular_observatory_router(
+ store=Store(), # type: ignore[arg-type]
+ compositions=ModularCompositionStore(tmp_path / "compositions", registry),
+ definitions=Definitions(), # type: ignore[arg-type]
+ binding=Binding(), # type: ignore[arg-type]
+ queue=Queue(), # type: ignore[arg-type]
+ )
+ )
+ response = TestClient(app).post(
+ "/api/v1/observatory/ai-compositions",
+ json={
+ "schema_version": COMPOSITION_SCHEMA,
+ "source_session_id": "source-1",
+ "idempotency_key": "ai-layer:source-1:tgs-again",
+ "selections": [
+ {
+ "group": "geometry",
+ "module_id": "tgs",
+ "module_sha256": tgs.sha256,
+ "parameters": {},
+ }
+ ],
+ },
+ )
+ assert response.status_code == 409
+ assert response.json()["detail"] == (
+ "Эта конфигурация уже рассчитана или поставлена в очередь. Выберите другую конфигурацию."
+ )
+
+
+def test_existing_module_is_reused_inside_a_new_composition(tmp_path: Path) -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+
+ class Store:
+ def get_session(self, session_id: str) -> object:
+ return SimpleNamespace(summary=SimpleNamespace(session_id=session_id, lab=None))
+
+ class Definitions:
+ def resolve_setup(self, setup_id: str) -> object:
+ return SimpleNamespace(definition_sha256=(setup_id.encode().hex() + "0" * 64)[:64])
+
+ existing_tgs = SimpleNamespace(
+ job_id="existing-tgs",
+ setup_id="m49-tgs-portable-v2",
+ state="succeeded",
+ as_dict=lambda: {"setup_id": "m49-tgs-portable-v2", "reused": True},
+ )
+ submitted: list[str] = []
+
+ class Queue:
+ def list_jobs(self, **kwargs: object) -> list[object]:
+ if kwargs["setup_id"] == "m49-tgs-portable-v2":
+ return [existing_tgs]
+ return []
+
+ class Binding:
+ def check(self, **kwargs: object) -> object:
+ assert kwargs["setup_id"] == "ai-detection-rf-detr-v1"
+ return SimpleNamespace(check_sha256="c" * 64)
+
+ def submit(self, **kwargs: object) -> tuple[object, bool]:
+ setup_id = str(kwargs["setup_id"])
+ submitted.append(setup_id)
+ return SimpleNamespace(
+ setup_id=setup_id,
+ as_dict=lambda: {"setup_id": setup_id, "reused": False},
+ ), True
+
+ modules = {item.module_id: item for item in registry.modules}
+ app = FastAPI()
+ app.include_router(
+ build_modular_observatory_router(
+ store=Store(), # type: ignore[arg-type]
+ compositions=ModularCompositionStore(tmp_path / "compositions", registry),
+ definitions=Definitions(), # type: ignore[arg-type]
+ binding=Binding(), # type: ignore[arg-type]
+ queue=Queue(), # type: ignore[arg-type]
+ )
+ )
+ response = TestClient(app).post(
+ "/api/v1/observatory/ai-compositions",
+ json={
+ "schema_version": COMPOSITION_SCHEMA,
+ "source_session_id": "source-1",
+ "idempotency_key": "ai-layer:source-1:tgs-and-rf-detr",
+ "selections": [
+ {
+ "group": modules[module_id].group,
+ "module_id": module_id,
+ "module_sha256": modules[module_id].sha256,
+ "parameters": {},
+ }
+ for module_id in ("tgs", "rf-detr")
+ ],
+ },
+ )
+ assert response.status_code == 200
+ assert submitted == ["ai-detection-rf-detr-v1"]
+ assert response.json()["dispatch"]["setup_ids"] == [
+ "m49-tgs-portable-v2",
+ "ai-detection-rf-detr-v1",
+ ]
+ assert [job["reused"] for job in response.json()["dispatch"]["jobs"]] == [True, False]
+ assert "готовые результаты использованы повторно" in response.json()["dispatch"]["reason"]
+
+
+def test_composition_prepares_an_unopened_recording_before_submit(tmp_path: Path) -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+ detector = next(module for module in registry.modules if module.module_id == "rf-detr")
+
+ class Store:
+ def get_session(self, session_id: str) -> object:
+ return SimpleNamespace(summary=SimpleNamespace(session_id=session_id, lab=None))
+
+ class Definitions:
+ def resolve_setup(self, setup_id: str) -> object:
+ return SimpleNamespace(definition_sha256="d" * 64)
+
+ calls: list[str] = []
+
+ class Binding:
+ def check(self, **kwargs: object) -> object:
+ calls.append("check")
+ raise PortableSourceNotPreparedError("camera manifest is not prepared")
+
+ def prepare_check(self, **kwargs: object) -> object:
+ calls.append("prepare-check")
+ return SimpleNamespace(check_sha256="c" * 64)
+
+ def submit(self, **kwargs: object) -> tuple[object, bool]:
+ calls.append("submit")
+ return SimpleNamespace(as_dict=lambda: {"setup_id": kwargs["setup_id"]}), True
+
+ app = FastAPI()
+ app.include_router(
+ build_modular_observatory_router(
+ store=Store(), # type: ignore[arg-type]
+ compositions=ModularCompositionStore(tmp_path / "compositions", registry),
+ definitions=Definitions(), # type: ignore[arg-type]
+ binding=Binding(), # type: ignore[arg-type]
+ )
+ )
+ response = TestClient(app).post(
+ "/api/v1/observatory/ai-compositions",
+ json={
+ "schema_version": COMPOSITION_SCHEMA,
+ "source_session_id": "source-1",
+ "idempotency_key": "ai-layer:source-1:prepare-camera",
+ "selections": [
+ {
+ "group": "detection",
+ "module_id": "rf-detr",
+ "module_sha256": detector.sha256,
+ "parameters": {},
+ }
+ ],
+ },
+ )
+ assert response.status_code == 200
+ assert response.json()["dispatch"]["ready"] is True
+ assert calls == ["check", "prepare-check", "submit"]
diff --git a/tests/test_modular_observatory_composition.py b/tests/test_modular_observatory_composition.py
new file mode 100644
index 0000000..f1c9f67
--- /dev/null
+++ b/tests/test_modular_observatory_composition.py
@@ -0,0 +1,221 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+
+import pytest
+
+from k1link.observatory.modular_composition import (
+ COMPOSITION_SCHEMA,
+ CompositionError,
+ ModuleRegistry,
+ ModuleSpec,
+ node_input_identity,
+)
+from k1link.observatory.modular_node_cache import ModularNodeCache
+
+
+def _digest(value: str) -> str:
+ return hashlib.sha256(value.encode()).hexdigest()
+
+
+def _module(
+ module_id: str,
+ group: str,
+ requires: tuple[str, ...],
+ provides: tuple[str, ...],
+ *,
+ optional_inputs: tuple[str, ...] = (),
+ state_policy: str = "stateless",
+) -> ModuleSpec:
+ return ModuleSpec(
+ module_id=module_id,
+ label=module_id,
+ group=group,
+ image_sha256=_digest(module_id + "-image"),
+ implementation_sha256=_digest(module_id + "-code"),
+ model_sha256=(
+ _digest(module_id + "-model") if group in {"segmentation", "detection"} else None
+ ),
+ contract_sha256=_digest(module_id + "-contract"),
+ requires=requires,
+ provides=provides,
+ optional_inputs=optional_inputs,
+ parameter_choices_json=json.dumps({"cadence": [1, 2]}).encode(),
+ defaults_json=json.dumps({"cadence": 1}).encode(),
+ state_policy=state_policy,
+ )
+
+
+def _registry() -> tuple[ModuleRegistry, ModuleSpec, ModuleSpec]:
+ camera = _module("camera-source", "preparation", ("source.camera",), ("camera.frames",))
+ ddrnet = _module("ddrnet", "segmentation", ("camera.frames",), ("segmentation.mask",))
+ eomt = _module("eomt", "segmentation", ("camera.frames",), ("segmentation.mask",))
+ detector = _module("rf-detr", "detection", ("camera.frames",), ("detections.2d",))
+ distance = _module(
+ "object-distance",
+ "range",
+ ("detections.2d", "source.calibration", "source.lidar", "source.pose"),
+ ("objects.ranged",),
+ )
+ motion = _module(
+ "object-motion",
+ "motion",
+ ("objects.ranged",),
+ ("objects.motion",),
+ state_policy="causal-reset-at-source-start",
+ )
+ policy = _module(
+ "policy-shadow",
+ "policy",
+ ("objects.motion",),
+ ("policy.observation",),
+ optional_inputs=("segmentation.mask",),
+ )
+ return ModuleRegistry((camera, ddrnet, eomt, detector, distance, motion, policy)), ddrnet, eomt
+
+
+def _selection(*modules: ModuleSpec) -> dict:
+ return {
+ "schema_version": COMPOSITION_SCHEMA,
+ "selections": [
+ {
+ "group": module.group,
+ "module_id": module.module_id,
+ "module_sha256": module.sha256,
+ "parameters": {},
+ }
+ for module in modules
+ ],
+ }
+
+
+def test_ddrnet_and_eomt_are_independent_alternative_compositions() -> None:
+ registry, ddrnet, eomt = _registry()
+ ddr = registry.compose(_selection(ddrnet))
+ eom = registry.compose(_selection(eomt))
+ assert [node.module.module_id for node in ddr.nodes] == ["camera-source", "ddrnet"]
+ assert [node.module.module_id for node in eom.nodes] == ["camera-source", "eomt"]
+ assert ddr.sha256 != eom.sha256
+ assert ddr.outputs == eom.outputs == ("segmentation.mask",)
+ assert ddrnet.docker_name == "ndc-mission-core-ai-module-ddrnet"
+ assert eomt.docker_name == "ndc-mission-core-ai-module-eomt"
+
+
+def test_installed_tgs_is_an_independent_point_cloud_composition() -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+ tgs = next(module for module in registry.modules if module.module_id == "tgs")
+ composition = registry.compose(_selection(tgs))
+
+ assert [node.module.module_id for node in composition.nodes] == ["tgs"]
+ assert composition.source_capabilities == (
+ "source.calibration",
+ "source.lidar",
+ "source.pose",
+ )
+ assert composition.outputs == ("geometry.costmap", "geometry.ground")
+ assert tgs.docker_name == "ndc-mission-core-ai-module-tgs"
+
+
+def test_installed_detection_range_and_tgs_keep_explicit_dependencies() -> None:
+ registry = ModuleRegistry.from_file(
+ Path(__file__).parents[1] / "config" / "observatory-ai-modules.json"
+ )
+ modules = {module.module_id: module for module in registry.modules}
+ detector = registry.compose(_selection(modules["rf-detr"]))
+ assert [node.module.module_id for node in detector.nodes] == ["camera-source", "rf-detr"]
+ assert modules["rf-detr"].docker_name == "ndc-mission-core-ai-module-rf-detr"
+ with pytest.raises(CompositionError, match="select a module providing detections.2d"):
+ registry.compose(_selection(modules["object-distance"]))
+ ranged = registry.compose(_selection(modules["rf-detr"], modules["object-distance"]))
+ assert [node.module.module_id for node in ranged.nodes] == [
+ "camera-source",
+ "rf-detr",
+ "object-distance",
+ ]
+ assert modules["object-distance"].docker_name == ("ndc-mission-core-ai-module-object-distance")
+ combined = registry.compose(
+ _selection(modules["tgs"], modules["rf-detr"], modules["object-distance"])
+ )
+ assert [node.module.module_id for node in combined.nodes] == [
+ "camera-source",
+ "tgs",
+ "rf-detr",
+ "object-distance",
+ ]
+ assert combined.source_capabilities == (
+ "source.calibration",
+ "source.camera",
+ "source.lidar",
+ "source.pose",
+ )
+
+
+def test_two_segmenters_and_hidden_analytical_dependency_are_rejected() -> None:
+ registry, ddrnet, eomt = _registry()
+ with pytest.raises(CompositionError, match="only one provider"):
+ registry.compose(_selection(ddrnet, eomt))
+ policy = next(module for module in registry.modules if module.module_id == "policy-shadow")
+ with pytest.raises(CompositionError, match="select a module providing objects.motion"):
+ registry.compose(_selection(policy))
+
+
+def test_detector_distance_motion_policy_graph_is_topological_without_segmentation() -> None:
+ registry, _, _ = _registry()
+ chosen = [
+ module
+ for module in registry.modules
+ if module.module_id in {"rf-detr", "object-distance", "object-motion", "policy-shadow"}
+ ]
+ graph = registry.compose(_selection(*chosen))
+ assert [node.module.module_id for node in graph.nodes] == [
+ "camera-source",
+ "rf-detr",
+ "object-distance",
+ "object-motion",
+ "policy-shadow",
+ ]
+ assert "source.lidar" in graph.source_capabilities
+ assert "segmentation.mask" not in dict(graph.nodes[-1].inputs)
+
+
+def test_node_identity_ignores_other_composition_nodes_but_binds_temporal_state() -> None:
+ registry, ddrnet, _ = _registry()
+ graph = registry.compose(_selection(ddrnet))
+ node = graph.nodes[-1]
+ identity = node_input_identity(node, {"camera.frames": _digest("prepared-camera")})
+ assert "job_id" not in json.dumps(identity)
+ assert "composition" not in json.dumps(identity)
+ assert identity == node_input_identity(node, {"camera.frames": _digest("prepared-camera")})
+ motion = next(module for module in registry.modules if module.module_id == "object-motion")
+ motion_graph = registry.compose(
+ _selection(
+ *(
+ module
+ for module in registry.modules
+ if module.module_id in {"rf-detr", "object-distance", "object-motion"}
+ )
+ )
+ )
+ motion_node = next(node for node in motion_graph.nodes if node.module is motion)
+ with pytest.raises(CompositionError, match="immutable digest"):
+ node_input_identity(motion_node, {"objects.ranged": _digest("ranges")})
+
+
+def test_node_cache_reuses_exact_bytes_and_treats_corruption_as_miss(tmp_path: Path) -> None:
+ output = tmp_path / "output"
+ output.mkdir()
+ (output / "result.json").write_text('{"ok":true}', encoding="utf-8")
+ identity = {"schema_version": "test/v1", "input": _digest("source")}
+ cache = ModularNodeCache(tmp_path / "cache")
+ sealed = cache.seal(identity, output, metadata={"computed": True})
+ hit = cache.lookup(identity)
+ assert hit is not None and hit.result_sha256 == sealed.result_sha256
+ assert hit.root.joinpath("result.json").read_text() == '{"ok":true}'
+ hit.root.joinpath("result.json").chmod(0o644)
+ hit.root.joinpath("result.json").write_text("damaged")
+ assert cache.lookup(identity) is None
+ assert output.joinpath("result.json").read_text() == '{"ok":true}'
diff --git a/tests/test_observatory_domain_ontology.py b/tests/test_observatory_domain_ontology.py
new file mode 100644
index 0000000..bf7f2f8
--- /dev/null
+++ b/tests/test_observatory_domain_ontology.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+
+import pytest
+
+from k1link.observatory.domain_ontology import (
+ ObservatoryDomainOntology,
+ ObservatoryOntologyError,
+)
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_full_observatory_composition_projects_exact_label_and_pane_layers() -> None:
+ ontology = ObservatoryDomainOntology.from_file(
+ REPOSITORY_ROOT / "config" / "observatory-domain-ontology.json"
+ )
+
+ projection = ontology.project_module_ids(("object-distance", "tgs", "rf-detr", "ddrnet"))
+
+ assert projection["configuration_label"] == (
+ "DDRNet-39 · GOOSE · RF-DETR Large · TRAVEL TGS · Дистанция до объектов · K1 LiDAR"
+ )
+ assert [layer["layer_id"] for layer in projection["viewer_layers"]] == [
+ "camera.source",
+ "camera.ddrnet",
+ "camera.detections",
+ "spatial.source-points",
+ "spatial.local-slam",
+ "spatial.tgs",
+ ]
+ entity_ids = {item["id"] for item in ontology.document["entities"]}
+ relation_ids = {item["id"] for item in ontology.document["relations"]}
+ assert {
+ "mission.transport-unit",
+ "mission.equipment-unit",
+ "observatory.equipment-mount",
+ "observatory.capture-profile",
+ "observatory.recorded-session",
+ "observatory.module-version",
+ "observatory.container-image",
+ "observatory.worker-node",
+ "observatory.lab-view-profile",
+ } <= entity_ids
+ assert {
+ "observatory.recorded-session.captured_on_transport",
+ "observatory.recorded-session.captured_with_equipment",
+ "observatory.recorded-session.uses_equipment_mount",
+ "observatory.recorded-session.uses_capture_profile",
+ "observatory.composition-run.uses_recorded_session",
+ "observatory.module-version.implemented_by_image",
+ "observatory.container-image.installed_on_worker",
+ "observatory.recorded-job.executes_on_worker",
+ "observatory.lab-projection.has_view_profile",
+ } <= relation_ids
+
+
+def test_observatory_ontology_rejects_unprojected_module() -> None:
+ ontology = ObservatoryDomainOntology.from_file(
+ REPOSITORY_ROOT / "config" / "observatory-domain-ontology.json"
+ )
+
+ with pytest.raises(ObservatoryOntologyError, match="has no ontology projection"):
+ ontology.project_module_ids(("unknown-module",))
+
+
+def test_single_modules_project_only_the_viewports_they_own() -> None:
+ ontology = ObservatoryDomainOntology.from_file(
+ REPOSITORY_ROOT / "config" / "observatory-domain-ontology.json"
+ )
+
+ ddrnet = ontology.project_module_ids(("ddrnet",))
+ assert [layer["layer_id"] for layer in ddrnet["viewer_layers"]] == [
+ "camera.source",
+ "camera.ddrnet",
+ ]
+ assert {layer["pane_id"] for layer in ddrnet["viewer_layers"]} == {"camera"}
+
+ tgs = ontology.project_module_ids(("tgs",))
+ assert [layer["layer_id"] for layer in tgs["viewer_layers"]] == [
+ "spatial.source-points",
+ "spatial.local-slam",
+ "spatial.tgs",
+ ]
+ assert {layer["pane_id"] for layer in tgs["viewer_layers"]} == {"spatial"}
diff --git a/tests/test_observatory_equipment_registry.py b/tests/test_observatory_equipment_registry.py
index 3a93bdc..05df533 100644
--- a/tests/test_observatory_equipment_registry.py
+++ b/tests/test_observatory_equipment_registry.py
@@ -46,9 +46,7 @@ def test_k1_equipment_and_capture_profiles_have_exact_content_identities() -> No
assert capture.equipment == equipment
for definition in _definition_registry().definitions:
assert (
- registry.compatible_profile_for_requirements(
- definition.source_requirements.as_dict()
- )
+ registry.compatible_profile_for_requirements(definition.source_requirements.as_dict())
== capture
)
@@ -111,12 +109,10 @@ def test_other_equipment_is_blocked_before_any_profile_probe() -> None:
catalog = projector.catalog(source)
assert calls == []
- assert len(catalog["setups"]) == 2
+ assert len(catalog["setups"]) == 6
for setup in catalog["setups"]:
assert setup["source_compatibility"]["compatible"] is False
- assert setup["source_compatibility"]["reason_code"] == (
- "equipment-model-mismatch"
- )
+ assert setup["source_compatibility"]["reason_code"] == ("equipment-model-mismatch")
assert setup["preflight"]["submission_allowed"] is False
diff --git a/tests/test_observatory_heartbeat_install.py b/tests/test_observatory_heartbeat_install.py
new file mode 100644
index 0000000..efc9eda
--- /dev/null
+++ b/tests/test_observatory_heartbeat_install.py
@@ -0,0 +1,73 @@
+"""No model/runtime side effects in installer admission tests."""
+
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+import pytest
+from test_observatory_source_reuse_install import FakeEngine as SourceEngine
+
+SCRIPTS = Path(__file__).parents[1] / "experiments/perception/worker/observatory_portable"
+
+
+@pytest.fixture
+def installer(monkeypatch):
+ monkeypatch.syspath_prepend(str(SCRIPTS))
+ spec = importlib.util.spec_from_file_location(
+ "recorded_heartbeat_installer", SCRIPTS / "install_recorded_heartbeat_recovery.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ monkeypatch.setitem(sys.modules, spec.name, module)
+ spec.loader.exec_module(module)
+ return module
+
+
+class Engine(SourceEngine):
+ busy = False
+
+ def execute_json(self, name, source):
+ if source == self.installer.probe():
+ return self.installer.BEFORE
+ readiness = super().execute_json(name, source)
+ if self.busy:
+ readiness["recorded_jobs_by_state"]["reconciliation-required"] = 1
+ return readiness
+
+
+def test_two_file_payload_is_hashed_and_cannot_gain_paths(installer, tmp_path):
+ root = tmp_path / "payload"
+ installer.pack(Path(__file__).parents[1], root)
+ assert set(installer.payload_files(root)) == {"worker_agent.py", "worker_http_transport.py"}
+ path = root / "payload.json"
+ document = json.loads(path.read_bytes())
+ document["files"]["../outside.py"] = "0" * 64
+ path.write_text(json.dumps(document))
+ with pytest.raises(ValueError, match="file set changed"):
+ installer.payload_files(root)
+
+
+def test_plan_and_exact_cutover_fence_preserve_compute_and_resources(installer, tmp_path):
+ root = tmp_path / "payload"
+ installer.pack(Path(__file__).parents[1], root)
+ engine = Engine(installer)
+ plan = installer.plan(engine, root)
+ assert plan["compute_packages_changed"] is False
+ with pytest.raises(ValueError, match="plan changed"):
+ installer.apply(engine, root, "0" * 64, tmp_path / "release")
+ assert engine.requests == [] and not (tmp_path / "release").exists()
+ target = plan["targets"][0]
+ installer.fence(engine, target)
+ engine.rows[target["name"]]["HostConfig"]["Memory"] = 999
+ with pytest.raises(ValueError, match="changed since plan"):
+ installer.fence(engine, target)
+
+
+def test_quarantined_owner_blocks_agent_replacement(installer, tmp_path):
+ root = tmp_path / "payload"
+ installer.pack(Path(__file__).parents[1], root)
+ engine = Engine(installer)
+ engine.busy = True
+ with pytest.raises(ValueError, match="not idle"):
+ installer.plan(engine, root)
+ assert engine.requests == []
diff --git a/tests/test_observatory_heartbeat_recovery.py b/tests/test_observatory_heartbeat_recovery.py
new file mode 100644
index 0000000..23a6dec
--- /dev/null
+++ b/tests/test_observatory_heartbeat_recovery.py
@@ -0,0 +1,158 @@
+"""Transient control-plane recovery never re-executes a model or changes owner."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from threading import Event, Thread
+
+import httpx
+import pytest
+from test_observatory_worker_agent import (
+ BlockingExecutor,
+ FakeTransport,
+ _enqueue,
+ _heartbeat_agent,
+ _identity,
+ _queue,
+)
+from test_observatory_worker_http_transport import (
+ BEARER_TOKEN,
+ CLAIM_TOKEN,
+ JOB_ID,
+ _cache_claim,
+ _claim_response,
+)
+
+from k1link.observatory.worker_agent import (
+ ObservatoryWorkerClaimRejectedError,
+ ObservatoryWorkerTransientTransportError,
+ _ClaimHeartbeat,
+ _RecordedJobPayload,
+ _seal_job,
+)
+from k1link.observatory.worker_http_transport import (
+ ObservatoryWorkerHttpError,
+ ObservatoryWorkerHttpGateway,
+ ObservatoryWorkerHttpTransientError,
+)
+
+
+@pytest.mark.parametrize("lost_ack", [False, True])
+def test_transient_loss_retries_exact_sequence_without_rerunning_compute(tmp_path, lost_ack):
+ queue = _queue(tmp_path)
+ job_id = _enqueue(queue)
+ attempts = []
+ accepted = Event()
+
+ class Transport(FakeTransport):
+ def renew_claim(self, **kwargs):
+ attempts.append(kwargs.copy())
+ if len(attempts) == 1:
+ if lost_ack:
+ super().renew_claim(**kwargs)
+ raise ObservatoryWorkerTransientTransportError("synthetic network loss")
+ result = super().renew_claim(**kwargs)
+ accepted.set()
+ return result
+
+ entered, release = Event(), Event()
+ transport = Transport(queue)
+ agent = _heartbeat_agent(transport, BlockingExecutor(entered=entered, release=release))
+ reports = []
+ thread = Thread(target=lambda: reports.append(agent.run_once()))
+ thread.start()
+ try:
+ assert entered.wait(2) and accepted.wait(2)
+ finally:
+ release.set()
+ thread.join(2)
+ assert not thread.is_alive()
+ assert reports[0].state == "succeeded"
+ assert attempts[0] == attempts[1]
+ assert transport.starts == [job_id]
+ assert len(transport.successes) == 1
+ assert queue.get(job_id).claim_generation == 1
+
+
+@pytest.mark.parametrize("failure", ["expired", "forbidden", "stopping", "late-ack"])
+def test_uncertain_lease_never_becomes_success(tmp_path, failure):
+ queue = _queue(tmp_path)
+ job_id = _enqueue(queue)
+ transport = FakeTransport(queue)
+ claim = transport.claim_next(
+ claimant_id="worker-006",
+ claim_request_id="heartbeat-budget",
+ supported_executor_identities=(_identity(),),
+ )
+ started = transport.start(
+ claimant_id="worker-006",
+ job_id=job_id,
+ claim_token=claim["claim_token"],
+ )
+ now, calls = [0.0], []
+ heartbeat = _ClaimHeartbeat(
+ transport=transport,
+ job=_seal_job(_RecordedJobPayload.model_validate(started)),
+ claim_token=claim["claim_token"],
+ interval_seconds=0.01,
+ stop_timeout_seconds=1.0,
+ clock=lambda: now[0],
+ )
+
+ class FailingTransport:
+ def renew_claim(self, **kwargs):
+ calls.append(kwargs)
+ if failure == "forbidden":
+ raise ObservatoryWorkerClaimRejectedError("changed generation")
+ if failure == "stopping":
+ heartbeat._stop.set()
+ else:
+ now[0] += 3601
+ if failure == "late-ack":
+ return transport.renew_claim(**kwargs)
+ raise ObservatoryWorkerTransientTransportError("timeout")
+
+ heartbeat._transport = FailingTransport()
+ heartbeat._run()
+ assert heartbeat.failed
+ assert len(calls) == 1
+ assert transport.successes == []
+
+
+@pytest.mark.parametrize("status", [408, 429, 500, 502, 503, 504, "timeout", 401, 403, 409, 302])
+def test_http_renewal_classifies_retryable_failures_and_bounds_io(tmp_path: Path, status):
+ attempts = []
+
+ def handle(request):
+ if not request.url.path.endswith("/lease/renew"):
+ return _claim_response()
+ attempts.append(json.loads(request.content))
+ assert request.extensions["timeout"] == dict.fromkeys(
+ ("connect", "read", "write", "pool"),
+ 5.0,
+ )
+ if status == "timeout":
+ raise httpx.ReadTimeout("fixture", request=request)
+ return httpx.Response(status, json={})
+
+ with ObservatoryWorkerHttpGateway(
+ base_url="http://127.0.0.1:8000",
+ bearer_token=BEARER_TOKEN,
+ work_root=tmp_path,
+ transport=httpx.MockTransport(handle),
+ ) as gateway:
+ _cache_claim(gateway)
+ with pytest.raises(ObservatoryWorkerHttpError) as error:
+ gateway.renew_claim(
+ claimant_id="worker-006",
+ job_id=JOB_ID,
+ claim_token=CLAIM_TOKEN,
+ claim_generation=1,
+ heartbeat_sequence=1,
+ )
+ assert isinstance(error.value, ObservatoryWorkerHttpTransientError) == (
+ status in {408, 429, 500, 502, 503, 504, "timeout"}
+ )
+ # The transport itself must not replay uploads or other mutations.
+ assert len(attempts) == 1
diff --git a/tests/test_observatory_installed_lab_package_runner.py b/tests/test_observatory_installed_lab_package_runner.py
index b03bff6..32d1e20 100644
--- a/tests/test_observatory_installed_lab_package_runner.py
+++ b/tests/test_observatory_installed_lab_package_runner.py
@@ -258,8 +258,7 @@ def test_generic_fixed_stack_runs_topologically_and_returns_verified_package(
]
writer_launch = launches[-1]
assert any(
- mount.container_path == "/missioncore/input/steps/compute-step"
- and mount.read_only
+ mount.container_path == "/missioncore/input/steps/compute-step" and mount.read_only
for mount in writer_launch.mounts
)
assert draft.result_id == "portable-result-generic-runner"
@@ -268,6 +267,16 @@ def test_generic_fixed_stack_runs_topologically_and_returns_verified_package(
for launch in launches:
writable = [mount for mount in launch.mounts if not mount.read_only]
assert [mount.container_path for mount in writable] == ["/missioncore/output"]
+ plan_mount = next(
+ mount for mount in launch.mounts if mount.container_path == "/missioncore/plan"
+ )
+ assert plan_mount.read_only
+ plan_root = Path(plan_mount.engine_path)
+ assert plan_root.is_dir()
+ assert (
+ json.loads((plan_root / "run-plan.json").read_bytes())["runtime_plan"]["job_id"]
+ == plan.job_id
+ )
def test_generic_docker_launcher_uses_hardened_one_shot_contract() -> None:
@@ -298,10 +307,10 @@ def test_generic_docker_launcher_uses_hardened_one_shot_contract() -> None:
package_id=package.package_id,
container=package.containers[0],
mounts=(
- InstalledLabDockerMount("/engine/plan.json", "/missioncore/input/run-plan.json", True),
InstalledLabDockerMount("/engine/source", "/missioncore/input/source", True),
InstalledLabDockerMount("/engine/output", "/missioncore/output", False),
InstalledLabDockerMount("/engine/asset", "/missioncore/package/assets/runner", True),
+ InstalledLabDockerMount("/engine/plan", "/missioncore/plan", True),
),
labels={
"com.nodedc.authority": "observation-only",
@@ -309,8 +318,10 @@ def test_generic_docker_launcher_uses_hardened_one_shot_contract() -> None:
"com.nodedc.definition-sha256": package.definition_sha256,
"com.nodedc.job-id": f"observatory-run-{'1' * 32}",
"com.nodedc.managed-by": "mission-core-worker",
+ "com.nodedc.module-id": package.containers[0].container_id,
"com.nodedc.package-sha256": package.package_sha256,
"com.nodedc.product": "mission-core",
+ "com.nodedc.role": "ai-module",
"com.nodedc.stack": "observatory",
},
name_token="0123456789abcdef",
@@ -324,6 +335,9 @@ def test_generic_docker_launcher_uses_hardened_one_shot_contract() -> None:
"POST",
"DELETE",
]
+ assert requests[1].url.params["name"] == (
+ f"ndc-mission-core-ai-module-{package.containers[0].container_id}-0123456789abcdef"
+ )
assert create_document["Image"] == f"sha256:{package.containers[0].image_sha256}"
assert create_document["NetworkDisabled"] is True
host = cast(dict[str, object], create_document["HostConfig"])
diff --git a/tests/test_observatory_installed_lab_packages.py b/tests/test_observatory_installed_lab_packages.py
index 459d793..ea2aeb7 100644
--- a/tests/test_observatory_installed_lab_packages.py
+++ b/tests/test_observatory_installed_lab_packages.py
@@ -104,7 +104,7 @@ def test_installed_package_binds_exact_ready_definition_and_runtime() -> None:
assert document["container_io"] == {
"schema_version": "missioncore.observatory-installed-lab-container-io/v2",
"source_root": "/missioncore/input/source",
- "plan_path": "/missioncore/input/run-plan.json",
+ "plan_path": "/missioncore/plan/run-plan.json",
"step_input_root": "/missioncore/input/steps",
"result_root": "/missioncore/output",
"work_root": "/missioncore/work",
diff --git a/tests/test_observatory_lab_view_profiles.py b/tests/test_observatory_lab_view_profiles.py
new file mode 100644
index 0000000..571e1c5
--- /dev/null
+++ b/tests/test_observatory_lab_view_profiles.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from k1link.observatory.lab_view_profiles import (
+ PROFILE_SCHEMA,
+ LabSceneProfile,
+ LabViewProfile,
+ LabViewProfileError,
+ LabViewProfileStore,
+)
+
+
+def _profile(result_id: str = "result-1") -> LabViewProfile:
+ return LabViewProfile(
+ result_id=result_id,
+ scene_settings=LabSceneProfile(
+ 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",
+ )
+
+
+def test_profile_store_round_trips_one_mutable_profile_per_result(tmp_path: Path) -> None:
+ store = LabViewProfileStore(tmp_path / "profiles")
+ assert store.get("result-1") is None
+
+ saved = store.save(_profile())
+ assert store.get("result-1") == saved
+ documents = list(store.root.glob("*.json"))
+ assert len(documents) == 1
+ assert documents[0].stat().st_mode & 0o777 == 0o600
+ assert json.loads(documents[0].read_text())["schema_version"] == PROFILE_SCHEMA
+
+ updated = LabViewProfile(
+ result_id="result-1",
+ scene_settings=LabSceneProfile(
+ point_size=2.0,
+ accumulation_seconds=3,
+ color_mode="distance",
+ palette="turbo",
+ show_grid=True,
+ show_labels=False,
+ show_camera_frustums=True,
+ ),
+ updated_at_utc="2026-09-04T09:31:00.000Z",
+ )
+ store.save(updated)
+ assert store.get("result-1") == updated
+ assert len(list(store.root.glob("*.json"))) == 1
+
+
+def test_profile_store_preserves_unbounded_operator_values(tmp_path: Path) -> None:
+ profile = LabViewProfile(
+ result_id="result-extreme",
+ scene_settings=LabSceneProfile(
+ point_size=128.5,
+ accumulation_seconds=50_000,
+ color_mode="height",
+ palette="turbo",
+ show_grid=True,
+ show_labels=False,
+ show_camera_frustums=False,
+ ),
+ updated_at_utc="2026-09-04T09:32:00.000Z",
+ )
+ store = LabViewProfileStore(tmp_path / "profiles")
+ store.save(profile)
+ assert store.get("result-extreme") == profile
+
+
+def test_profile_store_rejects_invalid_identity_and_tampered_document(tmp_path: Path) -> None:
+ store = LabViewProfileStore(tmp_path / "profiles")
+ with pytest.raises(LabViewProfileError, match="identity"):
+ store.get("../foreign")
+
+ store.save(_profile())
+ [document] = store.root.glob("*.json")
+ payload = json.loads(document.read_text())
+ payload["result_id"] = "different-result"
+ document.write_text(json.dumps(payload), encoding="utf-8")
+ with pytest.raises(LabViewProfileError, match="identity"):
+ store.get("result-1")
diff --git a/tests/test_observatory_object_distance_module.py b/tests/test_observatory_object_distance_module.py
new file mode 100644
index 0000000..cc13bef
--- /dev/null
+++ b/tests/test_observatory_object_distance_module.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import runpy
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+_MODULE = runpy.run_path(
+ str(
+ Path(__file__).resolve().parents[1]
+ / "experiments/perception/worker/observatory_portable/run_ai_module_object_distance.py"
+ )
+)
+_aligned_camera_seconds = _MODULE["_aligned_camera_seconds"]
+ObjectDistanceModuleError = _MODULE["ObjectDistanceModuleError"]
+_projection = _MODULE["_projection"]
+_LidarPack = _MODULE["_LidarPack"]
+
+
+def test_distance_joins_repaired_camera_and_lidar_by_frame_index() -> None:
+ seconds = _aligned_camera_seconds(
+ {"session_seconds": 136.002},
+ {"frame_index": 1299, "session_seconds": 175.131263458},
+ frame_index=1299,
+ previous_camera_seconds=175.0,
+ )
+ assert seconds == 175.131263458
+
+
+def test_distance_rejects_frame_or_repaired_clock_regression() -> None:
+ with pytest.raises(ObjectDistanceModuleError, match="frame identities"):
+ _aligned_camera_seconds(
+ {"session_seconds": 136.002},
+ {"frame_index": 1298, "session_seconds": 175.131263458},
+ frame_index=1299,
+ previous_camera_seconds=175.0,
+ )
+
+
+def test_distance_reads_calibration_from_full_e10_pack(tmp_path: Path) -> None:
+ pack = tmp_path / "lidar-pack.npz"
+ np.savez(
+ pack,
+ intrinsic_fx_fy_cx_cy=np.asarray([300.0, 301.0, 400.0, 300.0]),
+ distortion_kb4=np.asarray([0.1, 0.01, 0.001, 0.0001]),
+ t_camera_from_lidar=np.eye(4),
+ cloud_points_map=np.zeros((100_000, 3), dtype=np.float64),
+ )
+ assert pack.stat().st_size > 1024 * 1024
+ projection = _projection(pack)
+ assert projection.width == 800
+ assert projection.height == 600
+ assert projection.intrinsic_fx_fy_cx_cy == (300.0, 301.0, 400.0, 300.0)
+
+
+def test_distance_accepts_sealed_lidar_pack_at_package_mount_name(tmp_path: Path) -> None:
+ root = tmp_path / "lidar-pack"
+ root.mkdir()
+ arrays = root / "lidar-replay.npz"
+ np.savez(
+ arrays,
+ point_offsets=np.asarray([0, 1]),
+ point_xyz_map=np.asarray([[1.0, 2.0, 3.0]]),
+ point_received_monotonic_ns=np.asarray([1], dtype=np.int64),
+ pose_positions_map=np.asarray([[0.0, 0.0, 0.0]]),
+ pose_quaternions_map_from_lidar=np.asarray([[0.0, 0.0, 0.0, 1.0]]),
+ pose_received_monotonic_ns=np.asarray([1], dtype=np.int64),
+ )
+ digest = hashlib.sha256(arrays.read_bytes()).hexdigest()
+ identity = "3" * 64
+ (root / "manifest.json").write_text(
+ json.dumps(
+ {
+ "schema_version": "missioncore.lidar-replay-pack/v2",
+ "pack_id": f"lidar-replay-pack-{identity}",
+ "identity_sha256": identity,
+ "artifacts": [
+ {
+ "kind": "lidar-arrays",
+ "path": "lidar-replay.npz",
+ "byte_length": arrays.stat().st_size,
+ "sha256": digest,
+ }
+ ],
+ }
+ )
+ )
+ pack = _LidarPack(root)
+ try:
+ assert pack.pack_id == f"lidar-replay-pack-{identity}"
+ assert pack.point_frame_count == 1
+ finally:
+ pack.close()
+ with pytest.raises(ObjectDistanceModuleError, match="camera timeline"):
+ _aligned_camera_seconds(
+ {"session_seconds": 136.002},
+ {"frame_index": 1299, "session_seconds": 174.0},
+ frame_index=1299,
+ previous_camera_seconds=175.0,
+ )
diff --git a/tests/test_observatory_outbox_recovery.py b/tests/test_observatory_outbox_recovery.py
new file mode 100644
index 0000000..ee5d36a
--- /dev/null
+++ b/tests/test_observatory_outbox_recovery.py
@@ -0,0 +1,135 @@
+"""Recovery against the durable queue and real sealed-package publisher.
+
+Only source/model payloads are synthetic. No Worker, GPU, or application server
+is started; reopening the same SQLite/CAS roots simulates process restart.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Any, cast
+
+import pytest
+from test_observatory_portable_result_publisher import (
+ NOW,
+ RESULT_ID,
+ _cache_fixture,
+ _retry_intent,
+)
+from test_observatory_recorded_jobs import _intent, _queue, _running_job
+
+from k1link.observatory.portable_publication_reconciler import PortablePublicationReconciler
+from k1link.observatory.portable_result_contract import PortableResultPublisherError
+from k1link.observatory.recorded_jobs import (
+ ObservatoryRecordedJobQueue,
+ ObservatoryRecordedQueueDuplicateError,
+ RecordedRunDefinitionRegistry,
+)
+from k1link.sessions import SessionStore
+
+
+def test_outbox_cursor_survives_publication_and_tied_creation_times(tmp_path: Path) -> None:
+ queue = _queue(tmp_path)
+ for index in range(7):
+ job, claim = _running_job(
+ queue,
+ intent=_intent(idempotency_key=f"page-{index}", source_session_id=f"source-{index}"),
+ claim_request_id=f"page-claim-{index}",
+ )
+ queue.complete_for_publication(
+ job.job_id,
+ claim_token=claim.claim_token,
+ result_id=f"result-{index}",
+ result_sha256="a" * 64,
+ )
+ expected = queue.pending_publications()
+ first = queue.pending_publications(limit=3)
+ assert first == expected[:3]
+ cursor = (first[-1].created_at_utc, first[-1].job_id)
+ for item in first:
+ queue.mark_published(item.job_id)
+ # Cursor need not point at a still-pending row; never use mutable offsets.
+ assert queue.pending_publications(limit=3, after=cursor) == expected[3:6]
+ assert (
+ queue.pending_publications(
+ limit=3, after=(expected[-1].created_at_utc, expected[-1].job_id)
+ )
+ == ()
+ )
+
+
+@pytest.mark.parametrize("limit", [0, 101, True, 1.5])
+def test_outbox_page_rejects_invalid_bounds(tmp_path: Path, limit: Any) -> None:
+ with pytest.raises(ValueError):
+ _queue(tmp_path).pending_publications(limit=limit)
+
+
+@pytest.mark.parametrize("failure_after_publish", [False, True])
+def test_publication_recovers_after_restart_without_new_compute(
+ tmp_path: Path,
+ failure_after_publish: bool,
+) -> None:
+ cache, queue, sessions, _, registry, definition, job, publisher, package = _cache_fixture(
+ tmp_path,
+ published=False,
+ )
+ current = datetime.fromisoformat(NOW.replace("Z", "+00:00"))
+
+ class Transport:
+ def package_root_for_terminal(self, candidate: Any) -> Path:
+ assert candidate.job_id == job.job_id
+ assert candidate.result_sha256 == job.result_sha256
+ return package
+
+ class InterruptedPublisher:
+ def publish(self, **kwargs: Any) -> None:
+ if failure_after_publish:
+ publisher.publish(**kwargs)
+ raise PortableResultPublisherError("temporary publication interruption")
+
+ first = PortablePublicationReconciler(
+ queue=queue,
+ artifact_transport=cast(Any, Transport()),
+ result_publisher=cast(Any, InterruptedPublisher()),
+ clock=lambda: current,
+ ).run_once()
+ assert first.failed == 1
+ failed = queue.get(job.job_id)
+ assert failed.state == "succeeded" and failed.publication_state == "failed"
+ assert failed.result_sha256 == job.result_sha256
+ with pytest.raises(ObservatoryRecordedQueueDuplicateError):
+ queue.submit(_retry_intent(job), reject_duplicate_computation=True)
+
+ restarted = ObservatoryRecordedJobQueue(
+ sessions.data_dir,
+ definitions=RecordedRunDefinitionRegistry(registry.ready_recorded_definitions()),
+ clock=lambda: (current + timedelta(minutes=1)).isoformat().replace("+00:00", "Z"),
+ )
+ second = PortablePublicationReconciler(
+ queue=restarted,
+ artifact_transport=cast(Any, Transport()),
+ result_publisher=publisher,
+ clock=lambda: current + timedelta(minutes=1),
+ ).run_once()
+ assert second.published == 1
+ assert restarted.get(job.job_id).publication_state == "published"
+ assert len(restarted.list_jobs()) == 1
+ assert restarted.pending_publications() == ()
+ assert [row["result_id"] for row in cache.find(job.source_session_id, definition)] == [
+ RESULT_ID
+ ]
+ reopened = SessionStore(sessions.repository_root, data_dir=sessions.data_dir)
+ assert reopened.get_lab_instance(RESULT_ID) == sessions.get_lab_instance(RESULT_ID)
+ # No second publication attempt once the outbox acknowledgement is durable.
+ assert (
+ PortablePublicationReconciler(
+ queue=restarted,
+ artifact_transport=cast(Any, Transport()),
+ result_publisher=publisher,
+ clock=lambda: datetime.now(UTC),
+ )
+ .run_once()
+ .examined
+ == 0
+ )
diff --git a/tests/test_observatory_portable_lab_v1_component_adapters.py b/tests/test_observatory_portable_lab_v1_component_adapters.py
index e0a8cb9..b9f645a 100644
--- a/tests/test_observatory_portable_lab_v1_component_adapters.py
+++ b/tests/test_observatory_portable_lab_v1_component_adapters.py
@@ -471,14 +471,10 @@ def test_component_adapter_accepts_only_exact_legacy_or_installed_package_layout
component="ddrnet",
expectations=expectations,
)
- assert installed.request == Path(
- "/missioncore/input/steps/prepare/ddrnet-request.json"
- )
- assert installed.camera_job_root == Path(
- "/missioncore/input/steps/prepare/camera-job"
- )
+ assert installed.request == Path("/missioncore/input/steps/prepare/ddrnet-request.json")
+ assert installed.camera_job_root == Path("/missioncore/input/steps/prepare/camera-job")
assert installed.output_root == Path("/missioncore/output")
- assert installed.eomt_result_root == Path("/missioncore/input/steps/eomt")
+ assert installed.eomt_result_root == Path("/missioncore/input/steps/camera-source")
with pytest.raises(contract.ComponentAdapterError, match="accepts only"):
contract.resolve_runtime_layout(
@@ -969,10 +965,66 @@ def test_eomt_adapter_uses_only_fixed_legacy_argv_and_publishes_frames(
def test_eomt_default_disk_floor_retains_large_post_run_reserve() -> None:
- assert eomt.DISK_FLOOR_BYTES == 350 * 1024**3
+ assert eomt.DISK_FLOOR_BYTES == 250 * 1024**3
full_record_working_set = 6_830 * 800 * 600 * 7 + 556_912_640
assert full_record_working_set < 22 * 1024**3
- assert (eomt.DISK_FLOOR_BYTES + full_record_working_set) < 372 * 1024**3
+ assert (eomt.DISK_FLOOR_BYTES + full_record_working_set) < 272 * 1024**3
+
+
+@pytest.mark.parametrize("floor", [-1, True, 250.5, eomt.DISK_FLOOR_BYTES])
+def test_eomt_disk_rejection_precedes_heavy_input_and_asset_validation(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ floor: int,
+) -> None:
+ output = tmp_path / "output"
+ output.mkdir()
+ source = SimpleNamespace(frame_count=6_830, input_byte_length=556_912_640)
+ reserve = source.frame_count * 800 * 600 * 7 + source.input_byte_length
+ monkeypatch.setattr(
+ eomt, "load_component_request", lambda *a, **kw: SimpleNamespace(source=source)
+ )
+ monkeypatch.setattr(eomt, "available_bytes", lambda _: eomt.DISK_FLOOR_BYTES + reserve - 1)
+
+ def heavy_work(*args: object, **kwargs: object) -> None:
+ pytest.fail("disk admission must precede input hashing, asset hashing and inference")
+
+ monkeypatch.setattr(eomt, "validate_camera_compute_job", heavy_work)
+ monkeypatch.setattr(eomt, "_validate_release_assets", heavy_work)
+ layout = cast(contract.RuntimeLayout, SimpleNamespace(output_root=output))
+ with pytest.raises(contract.ComponentAdapterError, match="disk reserve"):
+ eomt.execute_eomt_component(
+ request_path=tmp_path / "request.json",
+ layout=layout,
+ command_runner=heavy_work,
+ disk_floor_bytes=floor,
+ )
+ assert not list(output.iterdir())
+
+
+def test_eomt_exact_disk_budget_still_requires_source_validation(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ output = tmp_path / "output"
+ output.mkdir()
+ source = SimpleNamespace(frame_count=6_830, input_byte_length=556_912_640)
+ reserve = source.frame_count * 800 * 600 * 7 + source.input_byte_length
+ monkeypatch.setattr(
+ eomt, "load_component_request", lambda *a, **kw: SimpleNamespace(source=source)
+ )
+ monkeypatch.setattr(eomt, "available_bytes", lambda _: eomt.DISK_FLOOR_BYTES + reserve)
+
+ def invalid_source(*args: object) -> None:
+ raise contract.ComponentAdapterError("source validation remains mandatory")
+
+ monkeypatch.setattr(eomt, "validate_camera_compute_job", invalid_source)
+ layout = cast(
+ contract.RuntimeLayout, SimpleNamespace(output_root=output, camera_job_root=tmp_path)
+ )
+ with pytest.raises(contract.ComponentAdapterError, match="source validation remains mandatory"):
+ eomt.execute_eomt_component(request_path=tmp_path / "request.json", layout=layout)
+ assert not list(output.iterdir())
def _effective_ddrnet_config(
diff --git a/tests/test_observatory_portable_run_definitions.py b/tests/test_observatory_portable_run_definitions.py
index 2167683..4a764b8 100644
--- a/tests/test_observatory_portable_run_definitions.py
+++ b/tests/test_observatory_portable_run_definitions.py
@@ -308,7 +308,7 @@ def test_duplicate_definition_and_incomplete_ready_executor_are_rejected(
PortableRunDefinitionRegistry.from_file(_write(tmp_path, incomplete))
-def test_production_definitions_are_both_ready_and_convertible() -> None:
+def test_production_definitions_are_ready_and_convertible() -> None:
registry = _registry()
definition = registry.definitions[0]
@@ -325,6 +325,10 @@ def test_production_definitions_are_both_ready_and_convertible() -> None:
assert tuple(row.setup_id for row in ready) == (
"lab-v1-eomt-ddrnet-portable-v1",
"m49-tgs-portable-v2",
+ "ai-segmentation-ddrnet-v1",
+ "ai-segmentation-eomt-v1",
+ "ai-detection-rf-detr-v1",
+ "ai-range-object-distance-v1",
)
assert registry.to_recorded_registry().definitions == ready
diff --git a/tests/test_observatory_portable_setup_projection.py b/tests/test_observatory_portable_setup_projection.py
index ee326b6..40401ac 100644
--- a/tests/test_observatory_portable_setup_projection.py
+++ b/tests/test_observatory_portable_setup_projection.py
@@ -140,7 +140,7 @@ class _GenericProbe:
)
-def test_generic_catalog_projects_lab_v1_and_model_free_m49_independently() -> None:
+def test_generic_catalog_projects_every_registered_portable_setup_independently() -> None:
registry = _registry()
catalog = PortableSetupProjector(
registry=registry,
@@ -151,6 +151,10 @@ def test_generic_catalog_projects_lab_v1_and_model_free_m49_independently() -> N
assert set(setups) == {
"lab-v1-eomt-ddrnet-portable-v1",
"m49-tgs-portable-v2",
+ "ai-segmentation-ddrnet-v1",
+ "ai-segmentation-eomt-v1",
+ "ai-detection-rf-detr-v1",
+ "ai-range-object-distance-v1",
}
m49 = setups["m49-tgs-portable-v2"]
assert m49["display_name"] == PORTABLE_M49_DISPLAY_NAME
diff --git a/tests/test_observatory_portable_worker_integration.py b/tests/test_observatory_portable_worker_integration.py
index 3eae55e..d082d94 100644
--- a/tests/test_observatory_portable_worker_integration.py
+++ b/tests/test_observatory_portable_worker_integration.py
@@ -11,6 +11,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 (
PortableObservatoryArtifactTransport,
)
@@ -26,6 +30,14 @@ from k1link.observatory.portable_run_definitions import (
PortableRunDefinitionRegistry,
)
from k1link.observatory.portable_setup_projection import (
+ AI_DDRNET_DISPLAY_NAME,
+ AI_DDRNET_SETUP_ID,
+ AI_EOMT_DISPLAY_NAME,
+ AI_EOMT_SETUP_ID,
+ AI_OBJECT_DISTANCE_DISPLAY_NAME,
+ AI_OBJECT_DISTANCE_SETUP_ID,
+ AI_RF_DETR_DISPLAY_NAME,
+ AI_RF_DETR_SETUP_ID,
PORTABLE_LAB_V1_DISPLAY_NAME,
PORTABLE_LAB_V1_SETUP_ID,
PORTABLE_M49_DISPLAY_NAME,
@@ -53,30 +65,28 @@ def _definitions() -> PortableRunDefinitionRegistry:
return PortableRunDefinitionRegistry.from_file(REGISTRY_PATH)
-def test_exact_validator_registry_covers_both_portable_profiles() -> None:
+def test_exact_validator_registry_covers_every_distinct_portable_contract() -> None:
definitions = _definitions()
validators = portable_result_validator_registry(definitions)
assert validators.resolve(PORTABLE_LAB_V1_RESULT_CONTRACT_SHA256) is validate_lab_v1_result_v2
assert validators.resolve(M49_PORTABLE_RESULT_CONTRACT_SHA256) is validate_m49_portable_result
+ assert validators.resolve(MODULAR_RESULT_CONTRACT_SHA256) is validate_modular_result
+ assert len(validators.registrations) == 3
def test_local_worker_gate_is_fail_closed_and_accepts_only_exact_one() -> None:
assert observatory_worker_local_enabled({}) is False
assert observatory_worker_local_enabled({OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: ""}) is False
- assert observatory_worker_local_enabled(
- {OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: "1"}
- ) is True
+ assert observatory_worker_local_enabled({OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: "1"}) is True
for value in ("0", "true", " 1", "1 "):
with pytest.raises(
PortableWorkerIntegrationError,
match="must be exactly 1 when enabled",
):
- observatory_worker_local_enabled(
- {OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: value}
- )
+ observatory_worker_local_enabled({OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: value})
def test_validator_registry_selects_only_contracts_present_in_definitions() -> None:
@@ -117,6 +127,10 @@ def test_server_integration_constructs_dormant_transport_and_publisher(
assert integration.supported_setup_ids == (
PORTABLE_LAB_V1_SETUP_ID,
PORTABLE_M49_SETUP_ID,
+ AI_DDRNET_SETUP_ID,
+ AI_EOMT_SETUP_ID,
+ AI_RF_DETR_SETUP_ID,
+ AI_OBJECT_DISTANCE_SETUP_ID,
)
assert isinstance(
integration.artifact_transport,
@@ -132,6 +146,10 @@ def test_server_integration_constructs_dormant_transport_and_publisher(
assert profiles == {
PORTABLE_LAB_V1_SETUP_ID: PORTABLE_LAB_V1_DISPLAY_NAME,
PORTABLE_M49_SETUP_ID: PORTABLE_M49_DISPLAY_NAME,
+ AI_DDRNET_SETUP_ID: AI_DDRNET_DISPLAY_NAME,
+ AI_EOMT_SETUP_ID: AI_EOMT_DISPLAY_NAME,
+ AI_RF_DETR_SETUP_ID: AI_RF_DETR_DISPLAY_NAME,
+ AI_OBJECT_DISTANCE_SETUP_ID: AI_OBJECT_DISTANCE_DISPLAY_NAME,
}
@@ -241,9 +259,7 @@ def test_explicit_volumes_storage_boundary_still_requires_a_mount(
PortableWorkerIntegrationError,
match="central artifact volume is not mounted: /Volumes/nodedc",
):
- integration_module._require_mounted_volume(
- Path("/Volumes/nodedc/mission-core")
- )
+ integration_module._require_mounted_volume(Path("/Volumes/nodedc/mission-core"))
assert mount_checks == [Path("/Volumes/nodedc")]
diff --git a/tests/test_observatory_portable_worker_runtime.py b/tests/test_observatory_portable_worker_runtime.py
index fec3394..2d87c57 100644
--- a/tests/test_observatory_portable_worker_runtime.py
+++ b/tests/test_observatory_portable_worker_runtime.py
@@ -53,8 +53,7 @@ def _runtime() -> PortableWorkerRuntimeRegistry:
def _blocked_candidate(candidate):
phases = tuple(
- PortableWorkerRuntimePhase(phase.phase_id, "missing")
- for phase in candidate.phases
+ PortableWorkerRuntimePhase(phase.phase_id, "missing") for phase in candidate.phases
)
blockers = ("executor-release-unsealed",)
identity = candidate.identity_document()
@@ -80,14 +79,25 @@ def _all_keys(value: object) -> set[str]:
return set()
-def test_production_candidates_bind_exact_definitions_and_both_are_ready() -> None:
+def test_production_candidates_bind_every_exact_definition_and_are_ready() -> None:
registry = _runtime()
assert {candidate.setup_id for candidate in registry.candidates} == {
"lab-v1-eomt-ddrnet-portable-v1",
"m49-tgs-portable-v2",
+ "ai-segmentation-ddrnet-v1",
+ "ai-segmentation-eomt-v1",
+ "ai-detection-rf-detr-v1",
+ "ai-range-object-distance-v1",
}
by_setup = {candidate.setup_id: candidate for candidate in registry.candidates}
+ assert all(candidate.ready for candidate in registry.candidates)
+ assert all(candidate.executor is not None for candidate in registry.candidates)
+ assert all(candidate.blockers == () for candidate in registry.candidates)
+ assert all(
+ all(phase.state == "implemented" for phase in candidate.phases)
+ for candidate in registry.candidates
+ )
lab_v1 = by_setup["lab-v1-eomt-ddrnet-portable-v1"]
assert lab_v1.ready is True
assert lab_v1.executor is not None
@@ -228,10 +238,7 @@ def test_ready_lab_candidate_still_requires_complete_local_asset_admission() ->
"ddrnet-portable-config": PortableWorkerLocalAssetBinding(
asset_id="ddrnet-portable-config",
file_path=(
- REPOSITORY_ROOT
- / "config"
- / "perception"
- / "lab-v1-eomt-ddrnet-portable-v2.json"
+ REPOSITORY_ROOT / "config" / "perception" / "lab-v1-eomt-ddrnet-portable-v2.json"
),
),
"ddrnet-step-image": PortableWorkerLocalAssetBinding(
@@ -370,9 +377,7 @@ def test_sealed_local_tree_uses_manifest_receipt_and_member_metadata(
"model-bin": {
"relative_path": "model.bin",
"byte_length": 5,
- "sha256": (
- "9372c470eeadd5ec5f36cb0b9adf10545c93c5132503830bf1465fe7654b117b"
- ),
+ "sha256": ("9372c470eeadd5ec5f36cb0b9adf10545c93c5132503830bf1465fe7654b117b"),
}
},
}
@@ -473,10 +478,12 @@ def test_ready_local_adapter_composes_only_local_ports_and_exact_job(
definition_sha256=canonical_sha256(definition_identity),
)
- blocked = _blocked_candidate(_runtime().resolve(
- "lab-v1-eomt-ddrnet-portable-v1",
- base_definition.definition_sha256,
- ))
+ blocked = _blocked_candidate(
+ _runtime().resolve(
+ "lab-v1-eomt-ddrnet-portable-v1",
+ base_definition.definition_sha256,
+ )
+ )
executor_seal = PortableWorkerExecutorSeal(
release_id="lab-v1-portable-executor-v1",
release_sha256="1" * 64,
diff --git a/tests/test_observatory_publication_reconciler.py b/tests/test_observatory_publication_reconciler.py
index 7dfd27e..a929fa7 100644
--- a/tests/test_observatory_publication_reconciler.py
+++ b/tests/test_observatory_publication_reconciler.py
@@ -5,6 +5,9 @@ from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
+import pytest
+
+from k1link.observatory.portable_artifact_transport import PortableArtifactTransportError
from k1link.observatory.portable_publication_reconciler import (
PortablePublicationReconciler,
)
@@ -18,8 +21,17 @@ class _Queue:
self.published: list[str] = []
self.failed: list[str] = []
- def pending_publications(self) -> tuple[SimpleNamespace, ...]:
- return self.jobs
+ def pending_publications(
+ self,
+ *,
+ limit: int,
+ after: tuple[str, str] | None = None,
+ ) -> tuple[SimpleNamespace, ...]:
+ return tuple(
+ job
+ for job in sorted(self.jobs, key=lambda job: (job.created_at_utc, job.job_id))
+ if after is None or (job.created_at_utc, job.job_id) > after
+ )[:limit]
def mark_published(self, job_id: str) -> None:
self.published.append(job_id)
@@ -103,7 +115,67 @@ def _job(
) -> SimpleNamespace:
return SimpleNamespace(
job_id=f"observatory-run-{attempts:032x}",
+ created_at_utc=updated_at,
publication_state=publication_state,
publication_attempts=attempts,
updated_at_utc=updated_at,
)
+
+
+@pytest.mark.parametrize("skip_kind", ["exhausted", "backoff"])
+def test_old_outbox_prefix_cannot_starve_ready_results_across_pages(
+ tmp_path: Path,
+ skip_kind: str,
+) -> None:
+ jobs = []
+ for index in range(70):
+ skipped = index < 66
+ job = _job(
+ "failed" if skipped else "pending",
+ attempts=(5 if skip_kind == "exhausted" else 1) if skipped else 0,
+ updated_at="2026-09-01T11:59:50Z" if skipped else "2026-09-01T11:00:00Z",
+ )
+ job.created_at_utc = "2026-09-01T10:00:00Z"
+ job.job_id = f"observatory-run-{index:032x}"
+ jobs.append(job)
+ queue = _Queue(tuple(jobs))
+ publisher = _Publisher()
+ reconciler = PortablePublicationReconciler(
+ queue=cast(Any, queue),
+ artifact_transport=cast(Any, _Transport(tmp_path)),
+ result_publisher=cast(Any, publisher),
+ clock=lambda: NOW,
+ )
+
+ result = reconciler.run_once(limit=2)
+
+ assert result.examined == 68
+ assert result.published == 2
+ assert getattr(result, "exhausted" if skip_kind == "exhausted" else "deferred") == 66
+ assert publisher.calls == [jobs[66].job_id, jobs[67].job_id]
+ assert queue.failed == []
+
+
+def test_one_missing_package_does_not_block_the_next_publication(tmp_path: Path) -> None:
+ first = _job("pending", attempts=0, updated_at="2026-09-01T10:00:00Z")
+ second = _job("pending", attempts=0, updated_at="2026-09-01T11:00:00Z")
+ second.job_id = f"observatory-run-{1:032x}"
+ queue = _Queue((first, second))
+ publisher = _Publisher()
+
+ class MissingFirst(_Transport):
+ def package_root_for_terminal(self, job: SimpleNamespace) -> Path:
+ if job.job_id == first.job_id:
+ raise PortableArtifactTransportError("sealed package temporarily unavailable")
+ return super().package_root_for_terminal(job)
+
+ result = PortablePublicationReconciler(
+ queue=cast(Any, queue),
+ artifact_transport=cast(Any, MissingFirst(tmp_path)),
+ result_publisher=cast(Any, publisher),
+ clock=lambda: NOW,
+ ).run_once(limit=2)
+
+ assert result.published == 1 and result.failed == 1
+ assert queue.failed == [first.job_id]
+ assert publisher.calls == [second.job_id]
diff --git a/tests/test_observatory_recorded_jobs.py b/tests/test_observatory_recorded_jobs.py
index 75726a4..8ca046a 100644
--- a/tests/test_observatory_recorded_jobs.py
+++ b/tests/test_observatory_recorded_jobs.py
@@ -99,6 +99,7 @@ def _queue(
) -> ObservatoryRecordedJobQueue:
preemptor = None
if with_non_checkpointable_preemptor:
+
def preemptor(request):
return ObservatoryNonCheckpointableCancellationReceipt.sealed(
request,
@@ -154,9 +155,7 @@ def _running_job(
job, created = queue.submit(intent or _intent())
assert created is True
queue.enqueue(job.job_id)
- claim = queue.claim_next(
- claimant_id="recorded-worker", claim_request_id=claim_request_id
- )
+ claim = queue.claim_next(claimant_id="recorded-worker", claim_request_id=claim_request_id)
assert claim is not None
running = queue.start(job.job_id, claim_token=claim.claim_token)
assert running.state == "running"
@@ -207,7 +206,8 @@ def test_list_filters_definition_before_page_limit(tmp_path: Path) -> None:
current = replace(previous, definition_version=2, definition_sha256="0" * 64)
now = NOW
queue = ObservatoryRecordedJobQueue(
- tmp_path, definitions=RecordedRunDefinitionRegistry((previous, current)),
+ tmp_path,
+ definitions=RecordedRunDefinitionRegistry((previous, current)),
clock=lambda: now,
)
first, _ = queue.submit(_intent())
@@ -220,8 +220,10 @@ def test_list_filters_definition_before_page_limit(tmp_path: Path) -> None:
)
assert queue.list_jobs(limit=1)[0].definition_sha256 == current.definition_sha256
assert queue.list_jobs(
- source_session_id=first.source_session_id, setup_id=previous.setup_id,
- definition_sha256=previous.definition_sha256, limit=1,
+ source_session_id=first.source_session_id,
+ setup_id=previous.setup_id,
+ definition_sha256=previous.definition_sha256,
+ limit=1,
) == (first,)
with pytest.raises(ValueError):
queue.list_jobs(definition_sha256="not-a-digest")
@@ -266,9 +268,6 @@ def test_portable_duplicate_guard_preserves_original_request(
"field",
[
"source_session_id",
- "source_catalog_sha256",
- "source_bundle_sha256",
- "source_capability_manifest_sha256",
"setup_id",
],
)
@@ -280,9 +279,6 @@ def test_duplicate_guard_keeps_distinct_sources_and_profiles(
queue.submit(_intent(), reject_duplicate_computation=True)
values = {
"source_session_id": "another-source",
- "source_catalog_sha256": "0" * 64,
- "source_bundle_sha256": "0" * 64,
- "source_capability_manifest_sha256": "0" * 64,
"setup_id": "legacy-monolith-v1",
}
changes = {field: values[field]}
@@ -293,6 +289,28 @@ def test_duplicate_guard_keeps_distinct_sources_and_profiles(
assert created and len(queue.list_jobs()) == 2
+@pytest.mark.parametrize(
+ "field",
+ ["source_catalog_sha256", "source_bundle_sha256", "source_capability_manifest_sha256"],
+)
+def test_duplicate_guard_does_not_recalculate_same_record_after_source_repair(
+ tmp_path: Path,
+ field: str,
+) -> None:
+ queue = _queue(tmp_path)
+ first, _ = queue.submit(_intent(), reject_duplicate_computation=True)
+ with pytest.raises(ObservatoryRecordedQueueDuplicateError) as duplicate:
+ queue.submit(
+ replace(
+ _intent(idempotency_key=f"repaired-{field}"),
+ **{field: "0" * 64},
+ ),
+ reject_duplicate_computation=True,
+ )
+ assert duplicate.value.job_id == first.job_id
+ assert len(queue.list_jobs()) == 1
+
+
@pytest.mark.parametrize("publication_failed", [False, True])
def test_duplicate_guard_never_recomputes_pending_publication(
tmp_path: Path,
@@ -321,7 +339,9 @@ def test_duplicate_guard_never_recomputes_pending_publication(
assert len(queue.list_jobs()) == 1
-def test_duplicate_guard_allows_retry_after_computation_failure(tmp_path: Path) -> None:
+def test_duplicate_guard_allows_a_fresh_attempt_after_computation_failure(
+ tmp_path: Path,
+) -> None:
queue = _queue(tmp_path)
job, claim = _running_job(queue)
queue.fail(
@@ -336,6 +356,8 @@ def test_duplicate_guard_allows_retry_after_computation_failure(tmp_path: Path)
reject_duplicate_computation=True,
)
assert created and retried.job_id != job.job_id
+ assert retried.state == "queued"
+ assert len(queue.list_jobs()) == 2
def test_duplicate_guard_is_atomic_across_two_queue_instances(tmp_path: Path) -> None:
@@ -383,12 +405,14 @@ def test_duplicate_guard_keeps_new_version_of_same_setup(tmp_path: Path) -> None
def _capabilities() -> tuple[RecordedExecutorIdentity, ...]:
definition = _definitions().definitions[0]
- return (RecordedExecutorIdentity(
- release_sha256=definition.executor_release_sha256,
- image_sha256=definition.executor_image_sha256,
- model_manifest_sha256=definition.model_manifest_sha256,
- resource_profile_sha256=definition.resource_profile_sha256,
- ),)
+ return (
+ RecordedExecutorIdentity(
+ release_sha256=definition.executor_release_sha256,
+ image_sha256=definition.executor_image_sha256,
+ model_manifest_sha256=definition.model_manifest_sha256,
+ resource_profile_sha256=definition.resource_profile_sha256,
+ ),
+ )
def _claim_rows(queue: ObservatoryRecordedJobQueue, table: str) -> list[tuple]:
@@ -399,8 +423,11 @@ def _claim_rows(queue: ObservatoryRecordedJobQueue, table: str) -> list[tuple]:
def test_v3_idle_does_not_consume_grant_quota_or_modify_legacy_receipts(tmp_path: Path) -> None:
queue = ObservatoryRecordedJobQueue(
- tmp_path, definitions=_definitions(), clock=lambda: NOW,
- max_claim_receipts=1, max_v3_claim_receipts=1,
+ tmp_path,
+ definitions=_definitions(),
+ clock=lambda: NOW,
+ max_claim_receipts=1,
+ max_v3_claim_receipts=1,
)
args = {"claimant_id": "recorded-worker", "supported_executor_identities": _capabilities()}
assert queue.claim_next(**args, claim_request_id="legacy-empty") is None
@@ -411,9 +438,14 @@ def test_v3_idle_does_not_consume_grant_quota_or_modify_legacy_receipts(tmp_path
with pytest.raises(ObservatoryRecordedQueueCapacityError):
queue.claim_next(**args, claim_request_id="legacy-full")
for number in range(8):
- assert queue.claim_next(
- **args, claim_request_id=f"idle-v3-{number}", protocol_version=3,
- ) is None
+ assert (
+ queue.claim_next(
+ **args,
+ claim_request_id=f"idle-v3-{number}",
+ protocol_version=3,
+ )
+ is None
+ )
assert _claim_rows(queue, "observatory_recorded_claim_grants_v3") == []
assert _claim_rows(queue, "observatory_recorded_claim_receipts") == legacy
@@ -425,23 +457,30 @@ def test_v3_idle_does_not_consume_grant_quota_or_modify_legacy_receipts(tmp_path
assert len(_claim_rows(queue, "observatory_recorded_claim_grants_v3")) == 1
assert _claim_rows(queue, "observatory_recorded_claim_receipts") == legacy
reopened = ObservatoryRecordedJobQueue(
- tmp_path, definitions=_definitions(), clock=lambda: NOW,
- max_claim_receipts=1, max_v3_claim_receipts=1,
+ tmp_path,
+ definitions=_definitions(),
+ clock=lambda: NOW,
+ max_claim_receipts=1,
+ max_v3_claim_receipts=1,
)
assert reopened.claim_next(**args, claim_request_id="idle-v3-0", protocol_version=3) == claim
# No second grant even if a client retries a v3 grant through legacy v2.
assert reopened.claim_next(**args, claim_request_id="idle-v3-0") == claim
with pytest.raises(ObservatoryRecordedQueueConflictError):
reopened.claim_next(
- claimant_id="other-worker", supported_executor_identities=_capabilities(),
- claim_request_id="idle-v3-0", protocol_version=3,
+ claimant_id="other-worker",
+ supported_executor_identities=_capabilities(),
+ claim_request_id="idle-v3-0",
+ protocol_version=3,
)
assert reopened.claim_next(**args, claim_request_id="busy-v3", protocol_version=3) is None
reopened.start(job.job_id, claim_token=claim.claim_token)
reopened.succeed(
- job.job_id, claim_token=claim.claim_token,
- result_id="result-first", result_sha256=RESULT_SHA,
+ job.job_id,
+ claim_token=claim.claim_token,
+ result_id="result-first",
+ result_sha256=RESULT_SHA,
)
with pytest.raises(ObservatoryRecordedQueueStaleClaimError):
reopened.claim_next(**args, claim_request_id="idle-v3-0", protocol_version=3)
@@ -457,15 +496,25 @@ def test_v3_idle_does_not_consume_grant_quota_or_modify_legacy_receipts(tmp_path
def test_v3_empty_capabilities_and_live_lease_cannot_claim_work(tmp_path: Path) -> None:
queue = _queue(tmp_path)
job, _ = queue.submit(_intent(), enqueue=True)
- assert queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="no-capabilities",
- supported_executor_identities=(), protocol_version=3,
- ) is None
+ assert (
+ queue.claim_next(
+ claimant_id="recorded-worker",
+ claim_request_id="no-capabilities",
+ supported_executor_identities=(),
+ protocol_version=3,
+ )
+ is None
+ )
queue.request_live(_live_intent())
- assert queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="live-has-priority",
- supported_executor_identities=_capabilities(), protocol_version=3,
- ) is None
+ assert (
+ queue.claim_next(
+ claimant_id="recorded-worker",
+ claim_request_id="live-has-priority",
+ supported_executor_identities=_capabilities(),
+ protocol_version=3,
+ )
+ is None
+ )
assert queue.get(job.job_id).state == "queued"
assert _claim_rows(queue, "observatory_recorded_claim_grants_v3") == []
@@ -478,8 +527,10 @@ def test_v3_two_simultaneous_retries_receive_one_grant(tmp_path: Path) -> None:
def claim(index: int):
barrier.wait(timeout=5)
return queues[index].claim_next(
- claimant_id="recorded-worker", claim_request_id="same-v3-id",
- supported_executor_identities=_capabilities(), protocol_version=3,
+ claimant_id="recorded-worker",
+ claim_request_id="same-v3-id",
+ supported_executor_identities=_capabilities(),
+ protocol_version=3,
)
with ThreadPoolExecutor(max_workers=2) as executor:
@@ -491,16 +542,26 @@ def test_v3_two_simultaneous_retries_receive_one_grant(tmp_path: Path) -> None:
def test_v3_expired_grant_cannot_reclaim_or_change_capabilities(tmp_path: Path) -> None:
clock = [NOW]
queue = ObservatoryRecordedJobQueue(
- tmp_path, definitions=_definitions(), clock=lambda: clock[0], claim_lease_seconds=10,
+ tmp_path,
+ definitions=_definitions(),
+ clock=lambda: clock[0],
+ claim_lease_seconds=10,
)
job, _ = queue.submit(_intent(), enqueue=True)
- args = {"claimant_id": "recorded-worker", "supported_executor_identities": _capabilities(),
- "protocol_version": 3}
+ args = {
+ "claimant_id": "recorded-worker",
+ "supported_executor_identities": _capabilities(),
+ "protocol_version": 3,
+ }
first = queue.claim_next(**args, claim_request_id="original-v3")
assert first is not None
with pytest.raises(ObservatoryRecordedQueueConflictError):
- queue.claim_next(claimant_id="recorded-worker", claim_request_id="original-v3",
- supported_executor_identities=(), protocol_version=3)
+ queue.claim_next(
+ claimant_id="recorded-worker",
+ claim_request_id="original-v3",
+ supported_executor_identities=(),
+ protocol_version=3,
+ )
clock[0] = "2026-08-30T21:00:11.000Z"
with pytest.raises(ObservatoryRecordedQueueStaleClaimError):
queue.claim_next(**args, claim_request_id="original-v3")
@@ -515,8 +576,12 @@ def test_v3_expired_grant_cannot_reclaim_or_change_capabilities(tmp_path: Path)
@pytest.mark.parametrize("version", [True, False, 1, 4, "3"])
def test_claim_protocol_rejects_ambiguous_versions(tmp_path: Path, version) -> None:
with pytest.raises(ValueError, match="protocol version"):
- _queue(tmp_path).claim_next(claimant_id="recorded-worker", claim_request_id="invalid",
- supported_executor_identities=(), protocol_version=version)
+ _queue(tmp_path).claim_next(
+ claimant_id="recorded-worker",
+ claim_request_id="invalid",
+ supported_executor_identities=(),
+ protocol_version=version,
+ )
def test_submission_is_durable_exactly_idempotent_and_path_free(tmp_path: Path) -> None:
@@ -548,15 +613,11 @@ def test_submission_is_durable_exactly_idempotent_and_path_free(tmp_path: Path)
assert not hasattr(_intent(), "command")
assert not hasattr(_intent(), "image")
assert projection["source"]["bundle_sha256"] == SOURCE_BUNDLE_SHA
- assert projection["source"]["capability_manifest_sha256"] == (
- SOURCE_CAPABILITIES_SHA
- )
+ assert projection["source"]["capability_manifest_sha256"] == (SOURCE_CAPABILITIES_SHA)
assert projection["executor"]["release_sha256"] == EXECUTOR_RELEASE_SHA
assert projection["executor"]["image_sha256"] == EXECUTOR_IMAGE_SHA
assert projection["executor"]["model_manifest_sha256"] == MODEL_MANIFEST_SHA
- assert projection["executor"]["resource_profile_sha256"] == (
- RESOURCE_PROFILE_SHA
- )
+ assert projection["executor"]["resource_profile_sha256"] == (RESOURCE_PROFILE_SHA)
assert queue.database_path.name == RECORDED_JOB_DATABASE_NAME
assert queue.database_path.stat().st_mode & 0o777 == 0o600
@@ -684,34 +745,23 @@ def test_success_requires_running_and_cannot_publish_during_preemption(
def test_claim_is_exactly_idempotent_including_empty_result(tmp_path: Path) -> None:
queue = _queue(tmp_path)
- empty = queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="empty-poll-001"
- )
+ empty = queue.claim_next(claimant_id="recorded-worker", claim_request_id="empty-poll-001")
assert empty is None
job, _ = queue.submit(_intent())
queue.enqueue(job.job_id)
assert (
- queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="empty-poll-001"
- )
- is None
+ queue.claim_next(claimant_id="recorded-worker", claim_request_id="empty-poll-001") is None
)
- claim = queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="non-empty-poll-001"
- )
- retry = queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="non-empty-poll-001"
- )
+ claim = queue.claim_next(claimant_id="recorded-worker", claim_request_id="non-empty-poll-001")
+ retry = queue.claim_next(claimant_id="recorded-worker", claim_request_id="non-empty-poll-001")
assert claim is not None
assert retry is not None
assert retry.claim_token == claim.claim_token
assert retry.job.job_id == job.job_id
with pytest.raises(ObservatoryRecordedQueueConflictError, match="claim"):
- queue.claim_next(
- claimant_id="another-worker", claim_request_id="non-empty-poll-001"
- )
+ queue.claim_next(claimant_id="another-worker", claim_request_id="non-empty-poll-001")
def test_capability_aware_claim_skips_incompatible_queued_job(tmp_path: Path) -> None:
@@ -764,11 +814,14 @@ def test_capability_claim_identity_binds_snapshot_and_empty_snapshot_claims_noth
queue = _queue(tmp_path)
job, _ = queue.submit(_intent(), enqueue=True)
- assert queue.claim_next(
- claimant_id="recorded-worker",
- claim_request_id="capability-empty-poll",
- supported_executor_identities=(),
- ) is None
+ assert (
+ queue.claim_next(
+ claimant_id="recorded-worker",
+ claim_request_id="capability-empty-poll",
+ supported_executor_identities=(),
+ )
+ is None
+ )
capability = RecordedExecutorIdentity(
release_sha256=EXECUTOR_RELEASE_SHA,
image_sha256=EXECUTOR_IMAGE_SHA,
@@ -1037,9 +1090,7 @@ def test_operator_reconciliation_is_durable_idempotent_and_unblocks_queue(
assert restored == receipt
assert restored_queue.get_reconciliation(running.job_id) == receipt
assert receipt.resource_release_attestation.resources_released is True
- assert receipt.resource_release_attestation.evidence_sha256 == (
- RESOURCE_RELEASE_EVIDENCE_SHA
- )
+ assert receipt.resource_release_attestation.evidence_sha256 == (RESOURCE_RELEASE_EVIDENCE_SHA)
assert receipt.expected_terminal_code == "claim-lease-expired"
assert receipt.quarantined_terminal_message == (
"Worker claim lease expired after execution started; "
@@ -1089,14 +1140,19 @@ def test_cache_indexes_follow_legacy_publication_column_migration(tmp_path: Path
connection.execute("DROP INDEX observatory_recorded_jobs_published_source")
connection.execute("DROP INDEX observatory_recorded_jobs_computation")
for column in (
- "publication_state", "publication_attempts", "publication_error", "published_at_utc",
+ "publication_state",
+ "publication_attempts",
+ "publication_error",
+ "published_at_utc",
):
connection.execute(f"ALTER TABLE observatory_recorded_jobs DROP COLUMN {column}")
migrated = _queue(tmp_path)
assert migrated.get(job.job_id) == job
assert not migrated.published_results(
- source_session_id=job.source_session_id, source_catalog_sha256=job.source_catalog_sha256,
- setup_id=job.setup_id, definition_sha256=job.definition_sha256,
+ source_session_id=job.source_session_id,
+ source_catalog_sha256=job.source_catalog_sha256,
+ setup_id=job.setup_id,
+ definition_sha256=job.definition_sha256,
)
with sqlite3.connect(queue.database_path) as connection:
names = {
@@ -1123,9 +1179,7 @@ def test_legacy_sqlite_claim_schema_migrates_without_reusing_old_token(
"claim_heartbeat_at_utc",
"claim_renewal_count",
):
- connection.execute(
- f"ALTER TABLE observatory_recorded_jobs DROP COLUMN {column}"
- )
+ connection.execute(f"ALTER TABLE observatory_recorded_jobs DROP COLUMN {column}")
connection.commit()
migrated_queue = _queue(tmp_path)
@@ -1140,9 +1194,7 @@ def test_legacy_sqlite_claim_schema_migrates_without_reusing_old_token(
with sqlite3.connect(migrated_queue.database_path) as connection:
columns = {
row[1]
- for row in connection.execute(
- "PRAGMA table_info(observatory_recorded_jobs)"
- ).fetchall()
+ for row in connection.execute("PRAGMA table_info(observatory_recorded_jobs)").fetchall()
}
assert {
"claimed_at_utc",
@@ -1171,9 +1223,7 @@ def test_legacy_sqlite_running_owner_migrates_to_reconciliation(
"claim_heartbeat_at_utc",
"claim_renewal_count",
):
- connection.execute(
- f"ALTER TABLE observatory_recorded_jobs DROP COLUMN {column}"
- )
+ connection.execute(f"ALTER TABLE observatory_recorded_jobs DROP COLUMN {column}")
connection.commit()
migrated_queue = _queue(tmp_path)
@@ -1201,15 +1251,10 @@ def test_single_worker_resource_has_only_one_recorded_owner(tmp_path: Path) -> N
)
queue.enqueue(first.job_id)
queue.enqueue(second.job_id)
- first_claim = queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="owner-poll-001"
- )
+ first_claim = queue.claim_next(claimant_id="recorded-worker", claim_request_id="owner-poll-001")
assert first_claim is not None
assert (
- queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="owner-poll-002"
- )
- is None
+ queue.claim_next(claimant_id="recorded-worker", claim_request_id="owner-poll-002") is None
)
completed_job_id = first_claim.job.job_id
@@ -1262,10 +1307,7 @@ def test_live_lease_cooperatively_pauses_and_resumes_recorded_job(tmp_path: Path
)
queue.enqueue(another.job_id)
assert (
- queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="blocked-poll-001"
- )
- is None
+ queue.claim_next(claimant_id="recorded-worker", claim_request_id="blocked-poll-001") is None
)
completed = queue.finish_live(
@@ -1319,9 +1361,7 @@ def test_live_request_pauses_claimed_job_before_execution(tmp_path: Path) -> Non
queue = _queue(tmp_path)
job, _ = queue.submit(_intent())
queue.enqueue(job.job_id)
- claim = queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="worker-claim-001"
- )
+ claim = queue.claim_next(claimant_id="recorded-worker", claim_request_id="worker-claim-001")
assert claim is not None
lease, _ = queue.request_live(_live_intent())
@@ -1377,9 +1417,7 @@ def test_non_checkpointable_job_is_cancelled_and_restarts_from_zero_for_live(
assert resumed.state == "queued"
assert resumed.restart_from_zero is True
- new_claim = queue.claim_next(
- claimant_id="recorded-worker", claim_request_id="worker-claim-002"
- )
+ new_claim = queue.claim_next(claimant_id="recorded-worker", claim_request_id="worker-claim-002")
assert new_claim is not None
assert new_claim.job.job_id == running.job_id
assert new_claim.job.restart_from_zero is True
@@ -1520,15 +1558,16 @@ def test_reconciliation_required_quarantines_recorded_and_live_ownership(
reason_code="worker-outcome-unknown",
message="Worker ownership cannot be proven released.",
)
- waiting, _ = queue.submit(
- _intent(idempotency_key="recorded-request-002")
- )
+ waiting, _ = queue.submit(_intent(idempotency_key="recorded-request-002"))
queue.enqueue(waiting.job_id)
- assert queue.claim_next(
- claimant_id="recorded-worker",
- claim_request_id="worker-claim-after-reconciliation",
- ) is None
+ assert (
+ queue.claim_next(
+ claimant_id="recorded-worker",
+ claim_request_id="worker-claim-after-reconciliation",
+ )
+ is None
+ )
lease, _ = queue.request_live(_live_intent())
with pytest.raises(ObservatoryRecordedQueueBusyError, match="recorded work"):
queue.activate_live(lease.lease_id)
@@ -1558,8 +1597,7 @@ def test_queue_detects_mutated_immutable_identity(tmp_path: Path) -> None:
job, _ = queue.submit(_intent())
with sqlite3.connect(queue.database_path) as connection:
connection.execute(
- "UPDATE observatory_recorded_jobs SET source_catalog_sha256 = ? "
- "WHERE job_id = ?",
+ "UPDATE observatory_recorded_jobs SET source_catalog_sha256 = ? WHERE job_id = ?",
("0" * 64, job.job_id),
)
connection.commit()
diff --git a/tests/test_observatory_source_reuse_install.py b/tests/test_observatory_source_reuse_install.py
new file mode 100644
index 0000000..bc00654
--- /dev/null
+++ b/tests/test_observatory_source_reuse_install.py
@@ -0,0 +1,109 @@
+"""Source-reuse installation admission only; no Docker/model runs."""
+
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+SCRIPTS = Path(__file__).parents[1] / "experiments/perception/worker/observatory_portable"
+
+
+@pytest.fixture
+def installer(monkeypatch):
+ monkeypatch.syspath_prepend(str(SCRIPTS))
+ spec = importlib.util.spec_from_file_location(
+ "recorded_source_reuse_installer", SCRIPTS / "install_recorded_source_reuse.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ monkeypatch.setitem(sys.modules, spec.name, module)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_pack_preserves_producer_and_rejects_payload_changes(installer, tmp_path):
+ output = tmp_path / "payload"
+ installer.pack(Path(__file__).parents[1], output)
+ assert installer.PRODUCER not in installer.payload_files(output)
+ assert len(installer.payload_files(output)) == 5
+ path = output / "observatory/worker_source_cache.py"
+ path.write_bytes(path.read_bytes() + b"\n# changed\n")
+ with pytest.raises(ValueError, match="payload changed"):
+ installer.payload_files(output)
+
+
+def test_source_reuse_manifest_cannot_add_paths(installer, tmp_path):
+ output = tmp_path / "payload"
+ installer.pack(Path(__file__).parents[1], output)
+ path = output / "payload.json"
+ manifest = json.loads(path.read_bytes())
+ manifest["files"]["../../outside.py"] = "0" * 64
+ path.write_text(json.dumps(manifest))
+ with pytest.raises(ValueError, match="file set changed"):
+ installer.payload_files(output)
+
+
+class FakeEngine:
+ def __init__(self, installer):
+ self.installer = installer
+ self.requests = []
+ self.volumes = []
+ self.rows = {
+ name: {
+ "Id": str(index) * 64,
+ "Name": "/" + name,
+ "Image": "sha256:" + image,
+ "State": {"Running": True},
+ "Mounts": [],
+ "HostConfig": {"ReadonlyRootfs": True, "NetworkMode": "bridge"},
+ "Config": {"Labels": {"com.nodedc.authority": "observation-only"}, "Env": []},
+ }
+ for index, (name, image) in enumerate(installer.TARGETS.items(), 1)
+ }
+
+ def inspect(self, name):
+ return self.rows[name]
+
+ def request(self, method, path, body=None):
+ self.requests.append((method, path))
+ assert method == "GET"
+ return {"Volumes": self.volumes}
+
+ def execute_json(self, name, source):
+ if source == self.installer.probe():
+ return {self.installer.PRODUCER: self.installer.PRODUCER_SHA, **self.installer.BEFORE}
+ return {
+ "schema_version": "missioncore.observatory-claim-readiness/v1",
+ "open_live_lease_count": 0,
+ "recorded_jobs_by_state": {"succeeded": 2},
+ "protocols": [{}, {"grant_capacity_available": True}],
+ }
+
+
+def test_plan_is_read_only_and_wrong_hash_cannot_cut_over(installer, tmp_path):
+ output = tmp_path / "payload"
+ installer.pack(Path(__file__).parents[1], output)
+ engine = FakeEngine(installer)
+ plan = installer.plan(engine, output)
+ assert plan["compute_packages_changed"] is False
+ assert plan["shared_cache"]["name"] == "ndc-observatory-source-cas-v1"
+ assert plan["producer_sha256"] == installer.PRODUCER_SHA
+ with pytest.raises(ValueError, match="plan changed"):
+ installer.apply(engine, output, "0" * 64, tmp_path / "evidence")
+ assert not (tmp_path / "evidence").exists()
+ assert all(method == "GET" for method, _ in engine.requests)
+
+
+def test_fence_and_existing_volume_ownership_are_required(installer):
+ engine = FakeEngine(installer)
+ name = next(iter(installer.TARGETS))
+ row = engine.inspect(name)
+ target = {"name": name, "id": row["Id"], "create_sha256": installer.create_hash(row)}
+ installer.fence(engine, target)
+ row["HostConfig"]["Memory"] = 1024
+ with pytest.raises(ValueError, match="changed since plan"):
+ installer.fence(engine, target)
+ engine.volumes = [{"Name": installer.VOLUME, "Driver": "local", "Labels": {}}]
+ with pytest.raises(ValueError, match="another owner"):
+ installer.volume_state(engine)
diff --git a/tests/test_portable_object_replay.py b/tests/test_portable_object_replay.py
new file mode 100644
index 0000000..17d9860
--- /dev/null
+++ b/tests/test_portable_object_replay.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+import rerun as rr
+
+from k1link.artifact_gateway import CentralArtifactStore
+from k1link.observatory.portable_object_replay import load_object_data, log_objects
+from k1link.observatory.portable_tgs_replay import PortableReplayError
+from k1link.perception.contracts import (
+ BoundingRegion2D,
+ EvidenceBasis,
+ EvidenceCurrentness,
+ MetricGeometry,
+ ObjectProposal2D,
+ ObstacleObservation,
+)
+
+BUNDLE = "b" * 64
+
+
+def _fixture(tmp_path: Path, module_id: str, damage: str | None = None):
+ store = CentralArtifactStore(tmp_path / "store", create=True)
+ members: list[dict[str, object]] = []
+
+ def member(role: str, payload: object, media: str = "application/json") -> dict[str, object]:
+ path = tmp_path / role
+ encoded = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
+ path.write_bytes(encoded)
+ published = store.publish_file(path)
+ row = {
+ "role": role,
+ "sha256": published.sha256,
+ "byte_length": published.byte_length,
+ "media_type": media,
+ }
+ members.append(row)
+ return row
+
+ proposals = []
+ for index in range(2):
+ proposal = ObjectProposal2D(
+ proposal_id=f"proposal-{index}",
+ source_id="recorded-k1",
+ frame_id=f"frame-{index + 1:06d}",
+ region=BoundingRegion2D(10.0, 20.0, 110.0, 220.0),
+ objectness=0.9,
+ provider_id="rf-detr-native",
+ model_id="rf-detr-large",
+ preprocess_id="kb4-native",
+ semantic_hint="car",
+ )
+ proposals.append(proposal)
+ detection_rows = [
+ {
+ "schema_version": "missioncore.observatory-ai-module-rf-detr-frame/v1",
+ "frame_index": index,
+ "session_seconds": 10.0 + index + (0.25 if damage == "clock" else 0),
+ "proposals": [proposal.to_dict()],
+ }
+ for index, proposal in enumerate(proposals)
+ ]
+ detections = member(
+ "rf-detr-frame-detections",
+ b"".join(json.dumps(row).encode() + b"\n" for row in detection_rows),
+ "application/x-ndjson",
+ )
+ rf = member(
+ "rf-detr-result-document",
+ {
+ "schema_version": "missioncore.observatory-ai-module-rf-detr-result/v1",
+ "module_id": "rf-detr",
+ "source": {"session_id": "source", "source_id": "recorded-k1"},
+ "frame_count": 2,
+ "detections_sha256": detections["sha256"],
+ },
+ )
+ component = rf
+ if module_id == "object-distance":
+ observations = []
+ for index, proposal in enumerate(proposals):
+ observation = ObstacleObservation(
+ observation_id=f"observation-{index}",
+ occupancy_key=f"occupancy-{index}",
+ source_id="recorded-k1",
+ frame_id=proposal.frame_id,
+ evidence_time_ns=(10 + index) * 1_000_000_000,
+ basis=EvidenceBasis.FUSED,
+ currentness=EvidenceCurrentness.CURRENT,
+ occupied_support=True,
+ source_point_ids=(index + 1,),
+ metric_geometry=MetricGeometry(
+ "camera", (0.0, 0.0, 12.5 + index), 12.5 + index, (0.1, 0.1, 0.1)
+ ),
+ proposal_ids=(proposal.proposal_id,),
+ semantic_hint="car",
+ reason_codes=("qualified",),
+ )
+ observations.append(
+ {
+ "schema_version": "missioncore.observatory-ai-module-object-distance-frame/v1",
+ "frame_index": index,
+ "session_seconds": 10.0 + index,
+ "observations": [observation.to_dict()],
+ }
+ )
+ distance_rows = member(
+ "object-distance-frame-observations",
+ b"".join(json.dumps(row).encode() + b"\n" for row in observations),
+ "application/x-ndjson",
+ )
+ component = member(
+ "object-distance-result-document",
+ {
+ "schema_version": "missioncore.observatory-ai-module-object-distance-result/v1",
+ "module_id": "object-distance",
+ "source_session_id": "source",
+ "frame_count": 2,
+ "object_distances_sha256": distance_rows["sha256"],
+ },
+ )
+ result_id = f"ai-layer-{module_id}-{'a' * 64}"
+ view = {
+ "result_id": result_id,
+ "source_session_id": "source",
+ "artifacts": members,
+ "result_document": {
+ "schema_version": "missioncore.recorded-ai-layer-review/v1",
+ "result_id": result_id,
+ "source": {
+ "session_id": "source",
+ "bundle_sha256": BUNDLE,
+ "frame_count": 2,
+ "timeline_start_seconds": 10.0,
+ "timeline_end_seconds": 12.0,
+ },
+ "module": {
+ "module_id": module_id,
+ "component_result_sha256": component["sha256"],
+ },
+ },
+ }
+ return view, store
+
+
+@pytest.mark.parametrize("module_id", ["rf-detr", "object-distance"])
+def test_object_replay_projects_boxes_and_optional_ranges(tmp_path: Path, module_id: str) -> None:
+ view, store = _fixture(tmp_path, module_id)
+ data = load_object_data(
+ view, store, source_bundle_sha256=BUNDLE, starts=[10.0, 11.0], end_seconds=12.0
+ )
+ assert data.include_ranges is (module_id == "object-distance")
+ assert data.frames[0].ranges_m == (
+ {"proposal-0": 12.5} if module_id == "object-distance" else {}
+ )
+ path = tmp_path / "objects.rrd"
+ recording = rr.RecordingStream("nodedc_mission_core_recorded", recording_id=module_id)
+ recording.set_sinks(rr.FileSink(path, write_footer=True))
+ calls: list[tuple[str, object]] = []
+ native_log = recording.log
+
+ def capture(entity: str, value: object, **kwargs: object) -> None:
+ calls.append((entity, value))
+ native_log(entity, value, **kwargs)
+
+ recording.log = capture
+ log_objects(recording, data)
+ recording.flush()
+ recording.disconnect()
+ assert path.read_bytes().startswith(b"RRF2")
+ assert sum(isinstance(value, rr.Boxes2D) for _, value in calls) == 2
+ assert isinstance(calls[-1][1], rr.Clear)
+ assert {entity for entity, _ in calls} == {"/perception/camera/detections"}
+
+
+@pytest.mark.parametrize("damage", ["clock", "bundle"])
+def test_object_replay_rejects_changed_source_or_clock(tmp_path: Path, damage: str) -> None:
+ view, store = _fixture(tmp_path, "object-distance", damage)
+ with pytest.raises(PortableReplayError):
+ load_object_data(
+ view,
+ store,
+ source_bundle_sha256="c" * 64 if damage == "bundle" else BUNDLE,
+ starts=[10.0, 11.0],
+ end_seconds=12.0,
+ )
diff --git a/tests/test_portable_replay.py b/tests/test_portable_replay.py
index 2b69968..da5f927 100644
--- a/tests/test_portable_replay.py
+++ b/tests/test_portable_replay.py
@@ -210,7 +210,8 @@ def test_camera_proxy_keeps_demux_time_base_and_reuses_its_own_sealed_cache(tmp_
assert len(calls) == 1
-def test_replay_api_get_never_prepares_and_range_is_generation_fenced(tmp_path):
+@pytest.mark.parametrize("prefix", ["m49-tgs-portable-review", "lab-v1-eomt-ddrnet"])
+def test_replay_api_get_never_prepares_and_range_is_generation_fenced(tmp_path, prefix):
path = tmp_path / "replay.rrd"
path.write_bytes(b"RRF2test")
sha = hashlib.sha256(path.read_bytes()).hexdigest()
@@ -228,7 +229,8 @@ def test_replay_api_get_never_prepares_and_range_is_generation_fenced(tmp_path):
)
)
with TestClient(app) as client:
- url = f"/api/v1/observatory/portable-results/{RESULT}/replays/{BASE}/recording.rrd"
+ result_id = prefix + "-" + "a" * 64
+ url = f"/api/v1/observatory/portable-results/{result_id}/replays/{BASE}/recording.rrd"
assert client.get(url, params={"generation": sha}).status_code == 409
assert not prepared
response = client.head(url)
diff --git a/tests/test_portable_semantic_replay.py b/tests/test_portable_semantic_replay.py
new file mode 100644
index 0000000..cdcf914
--- /dev/null
+++ b/tests/test_portable_semantic_replay.py
@@ -0,0 +1,263 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import tarfile
+import zipfile
+from pathlib import Path
+
+import numpy as np
+import pytest
+import rerun as rr
+from PIL import Image
+
+from k1link.artifact_gateway import CentralArtifactStore
+from k1link.observatory.portable_semantic_replay import (
+ EOMT_LABELS,
+ EOMT_PROFILE_SHA,
+ RESULT_SCHEMA,
+ load_semantic_data,
+ log_semantics,
+)
+from k1link.observatory.portable_tgs_replay import PortableReplayError
+
+RESULT = "lab-v1-eomt-ddrnet-" + "a" * 64
+BUNDLE = "b" * 64
+
+
+def fixture(tmp_path: Path, damage: str | None = None):
+ store = CentralArtifactStore(tmp_path / "store", create=True)
+ members = []
+
+ def member(role, payload, media="application/json"):
+ path = tmp_path / role
+ path.write_bytes(payload if isinstance(payload, bytes) else json.dumps(payload).encode())
+ published = store.publish_file(path)
+ value = {
+ "role": role,
+ "sha256": published.sha256,
+ "byte_length": published.byte_length,
+ "media_type": media,
+ }
+ members.append(value)
+ return value
+
+ png = io.BytesIO()
+ Image.fromarray(np.ones((600, 800), dtype=np.uint8)).save(png, format="PNG")
+ archive = io.BytesIO()
+ with tarfile.open(fileobj=archive, mode="w:gz") as stream:
+ for index in (1, 0):
+ if damage == "missing-mask" and index == 0:
+ continue
+ name = f"semantic-masks/frame-{index + 1:06d}.png"
+ if damage == "unsafe-mask" and index == 0:
+ name = "../frame-000001.png"
+ if damage == "wrong-suffix" and index == 0:
+ name = "semantic-masks/frame-000001Xpng"
+ item = tarfile.TarInfo(name)
+ item.size = len(png.getvalue())
+ if damage == "oversized-mask" and index == 0:
+ item.size = 1024 * 1024 + 1
+ stream.addfile(item, io.BytesIO(b"x" * item.size))
+ else:
+ stream.addfile(item, io.BytesIO(png.getvalue()))
+ member("eomt-panoptic-mask-archive", archive.getvalue(), "application/gzip")
+ archive = io.BytesIO()
+ with zipfile.ZipFile(archive, "w") as stream:
+ for index in range(2):
+ stream.writestr(f"masks/frame-{index + 1:06d}.png", png.getvalue())
+ ddr_archive = member("ddrnet-semantic-mask-archive", archive.getvalue(), "application/zip")
+ eomt = member(
+ "eomt-result-document",
+ {
+ "result_id": "eomt-result",
+ "frames_processed": 2,
+ "session_id": "source",
+ "input_sha256": "camera",
+ "timestamp_basis": "session-time-seconds",
+ "identity": {
+ "configuration": {
+ "profile_sha256": "d" * 64 if damage == "profile" else EOMT_PROFILE_SHA
+ }
+ },
+ },
+ )
+ ddrnet = member(
+ "ddrnet-result-document",
+ {
+ "result_id": "ddrnet-result",
+ "video_semantics": {
+ "mask_archive": {
+ **ddr_archive,
+ "frame_count": 2,
+ "width": 800,
+ "height": 600,
+ "encoding": "uint8-class-id-png",
+ },
+ "taxonomy": {
+ "schema_version": "missioncore.lab-v1-vegetation-taxonomy/v1",
+ "classes": [
+ {"class_id": i, "label": str(i), "color_rgb": [64, 128, 192]}
+ for i in range(64)
+ ],
+ },
+ },
+ },
+ )
+ frames = [
+ {
+ "frame_index": i,
+ "sequence": i + 1,
+ "session_seconds": 10 + i + (0.1 if damage == "clock" else 0),
+ "semantic_classes": [{"id": 1, "label": "car" if damage == "label" else "person"}],
+ }
+ for i in range(2)
+ ]
+ if damage == "extra-frame":
+ frames.append(frames[-1])
+ member(
+ "eomt-panoptic-frame-metadata",
+ b"".join(json.dumps(row).encode() + b"\n" for row in frames),
+ "application/x-ndjson",
+ )
+ view = {
+ "result_id": RESULT,
+ "source_session_id": "source",
+ "artifacts": members,
+ "result_document": {
+ "schema_version": RESULT_SCHEMA,
+ "result_id": RESULT,
+ "source": {
+ "session_id": "source",
+ "bundle_sha256": BUNDLE,
+ "camera_input_sha256": "camera",
+ "frame_count": 2,
+ "timeline_start_seconds": 10.0,
+ "timeline_end_seconds": 12.0,
+ },
+ "components": {
+ "eomt": {
+ "result_id": "eomt-result",
+ "frames_processed": 2,
+ "result_document_sha256": eomt["sha256"],
+ },
+ "ddrnet": {
+ "result_id": "ddrnet-result",
+ "frames_processed": 2,
+ "result_document_sha256": ddrnet["sha256"],
+ },
+ },
+ },
+ }
+ return view, store
+
+
+def test_target_labels_match_the_exact_checked_in_producer_profile():
+ path = Path(__file__).parents[1] / "experiments/perception/worker/e3_k1_camera1_profile.json"
+ payload = path.read_bytes()
+ assert hashlib.sha256(payload).hexdigest() == EOMT_PROFILE_SHA
+ assert tuple(json.loads(payload)["target_taxonomy"].values()) == EOMT_LABELS
+
+
+@pytest.mark.parametrize("damage", ["clock", "profile", "label", "extra-frame", "bundle"])
+def test_mask_review_rejects_foreign_profile_source_or_clock(tmp_path, damage):
+ view, store = fixture(tmp_path, damage)
+ with pytest.raises(PortableReplayError):
+ load_semantic_data(
+ view,
+ store,
+ starts=[10.0, 11.0],
+ end_seconds=12.0,
+ source_bundle_sha256="c" * 64 if damage == "bundle" else BUNDLE,
+ )
+
+
+@pytest.mark.parametrize(
+ "damage", [None, "missing-mask", "unsafe-mask", "oversized-mask", "wrong-suffix"]
+)
+def test_native_projection_is_complete_compressed_and_cleared_at_coverage_end(tmp_path, damage):
+ view, store = fixture(tmp_path, damage)
+ data = load_semantic_data(
+ view, store, starts=[10.0, 11.0], end_seconds=12.0, source_bundle_sha256=BUNDLE
+ )
+ path = tmp_path / "recording.rrd"
+ recording = rr.RecordingStream("nodedc_mission_core_recorded", recording_id="fixture")
+ recording.set_sinks(rr.FileSink(path, write_footer=True))
+ calls, times = [], []
+ original_log, original_time = recording.log, recording.set_time
+
+ def log(entity, value, **kwargs):
+ calls.append((entity, value))
+ original_log(entity, value, **kwargs)
+
+ def timestamp(name, *, duration):
+ times.append(int(duration.astype("timedelta64[ns]").astype(np.int64)))
+ original_time(name, duration=duration)
+
+ recording.log, recording.set_time = log, timestamp
+ try:
+ if damage:
+ with pytest.raises(PortableReplayError):
+ log_semantics(recording, data)
+ else:
+ log_semantics(recording, data)
+ recording.flush()
+ assert path.read_bytes().startswith(b"RRF2")
+ assert sum(isinstance(value, rr.EncodedImage) for _, value in calls) == 4
+ assert times == [
+ 11_000_000_000,
+ 10_000_000_000,
+ 10_000_000_000,
+ 11_000_000_000,
+ 12_000_000_000,
+ ]
+ assert isinstance(calls[-1][1], rr.Clear)
+ assert all(entity.startswith("/perception/camera/segmentation") for entity, _ in calls)
+ finally:
+ recording.disconnect()
+
+
+@pytest.mark.parametrize(
+ ("module_id", "result_role", "expected_layers"),
+ [
+ ("eomt", "eomt-result-document", {"city"}),
+ ("ddrnet", "ddrnet-result-document", {"vegetation"}),
+ ],
+)
+def test_independent_ai_module_result_opens_only_its_own_semantic_layer(
+ tmp_path: Path,
+ module_id: str,
+ result_role: str,
+ expected_layers: set[str],
+) -> None:
+ view, store = fixture(tmp_path)
+ result_id = f"ai-layer-{module_id}-{'c' * 64}"
+ result_artifact = next(item for item in view["artifacts"] if item["role"] == result_role)
+ view["result_id"] = result_id
+ view["result_document"] = {
+ "schema_version": "missioncore.recorded-ai-layer-review/v1",
+ "result_id": result_id,
+ "source": {
+ "session_id": "source",
+ "bundle_sha256": BUNDLE,
+ "camera_input_sha256": "camera",
+ "frame_count": 2,
+ "timeline_start_seconds": 10.0,
+ "timeline_end_seconds": 12.0,
+ },
+ "module": {
+ "module_id": module_id,
+ "component_result_sha256": result_artifact["sha256"],
+ },
+ }
+ data = load_semantic_data(
+ view,
+ store,
+ starts=[10.0, 11.0],
+ end_seconds=12.0,
+ source_bundle_sha256=BUNDLE,
+ )
+ assert set(data.classes) == expected_layers
+ assert (data.city_masks is not None) is (module_id == "eomt")
+ assert (data.vegetation_masks is not None) is (module_id == "ddrnet")
diff --git a/tests/test_recorded_blueprint_lifecycle.py b/tests/test_recorded_blueprint_lifecycle.py
new file mode 100644
index 0000000..5d51026
--- /dev/null
+++ b/tests/test_recorded_blueprint_lifecycle.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import gc
+import threading
+import weakref
+
+import pytest
+
+from k1link.viewer.recorded_blueprint_lifecycle import (
+ BlueprintSessionReleased,
+ RecordedBlueprintSessions,
+)
+
+KEY = ("app", "recording", "a" * 32)
+
+
+class Resource:
+ def __init__(self) -> None:
+ self.closes = 0
+
+ def close(self) -> None:
+ self.closes += 1
+
+
+def test_release_drops_resource_and_fences_late_updates() -> None:
+ sessions = RecordedBlueprintSessions[Resource]()
+ resource = Resource()
+ reference = weakref.ref(resource)
+ assert (
+ sessions.use(KEY, 0, lambda resource=resource: resource, lambda _: b"result") == b"result"
+ )
+ sessions.release(KEY)
+ sessions.release(KEY)
+ assert resource.closes == 1
+ assert not sessions.renew(KEY)
+ with pytest.raises(BlueprintSessionReleased):
+ sessions.use(KEY, 0, Resource, lambda _: b"late")
+ del resource
+ gc.collect()
+ assert reference() is None
+
+
+def test_renew_keeps_paused_viewport_but_abandoned_owner_expires() -> None:
+ now = [0.0]
+ sessions = RecordedBlueprintSessions[Resource](clock=lambda: now[0], ttl_seconds=300)
+ resource = Resource()
+ sessions.use(KEY, 0, lambda: resource, lambda _: None)
+ for timestamp in [200, 400, 600]:
+ now[0] = timestamp
+ assert sessions.renew(KEY)
+ sessions.expire()
+ assert resource.closes == 0
+ now[0] = 901
+ sessions.expire()
+ assert resource.closes == 1
+ assert not sessions.renew(KEY)
+ # An expired blueprint is rebuildable; this is not a recording/live stop.
+ sessions.use(KEY, 0, Resource, lambda _: None)
+ sessions.close()
+
+
+def test_failed_render_releases_partial_resource_and_can_retry() -> None:
+ sessions = RecordedBlueprintSessions[Resource]()
+ resource = Resource()
+
+ def fail(_: Resource) -> None:
+ raise ValueError("failed serialization")
+
+ with pytest.raises(ValueError, match="failed serialization"):
+ sessions.use(KEY, 0, lambda: resource, fail)
+ assert resource.closes == 1
+ assert not sessions.renew(KEY)
+ sessions.use(KEY, 0, Resource, lambda _: None)
+ sessions.close()
+
+
+def test_reset_lru_and_shutdown_release_resources_exactly_once() -> None:
+ sessions = RecordedBlueprintSessions[Resource](max_entries=1)
+ first, replacement, other = Resource(), Resource(), Resource()
+ sessions.use(KEY, 0, lambda: first, lambda _: None)
+ sessions.use(KEY, 1, lambda: replacement, lambda _: None)
+ assert first.closes == 1
+ sessions.use(("app", "other", "b" * 32), 0, lambda: other, lambda _: None)
+ assert replacement.closes == 1
+ sessions.close()
+ sessions.close()
+ assert other.closes == 1
+
+
+def test_release_waits_for_render_and_prevents_resurrection() -> None:
+ sessions = RecordedBlueprintSessions[Resource]()
+ resource = Resource()
+ started, finish, released = threading.Event(), threading.Event(), threading.Event()
+
+ def render(_: Resource) -> None:
+ started.set()
+ assert finish.wait(2)
+ assert resource.closes == 0
+
+ def release() -> None:
+ sessions.release(KEY)
+ released.set()
+
+ worker = threading.Thread(target=lambda: sessions.use(KEY, 0, lambda: resource, render))
+ worker.start()
+ assert started.wait(2)
+ closer = threading.Thread(target=release)
+ closer.start()
+ assert not released.is_set()
+ finish.set()
+ worker.join(2)
+ closer.join(2)
+ assert not worker.is_alive() and not closer.is_alive()
+ assert released.is_set() and resource.closes == 1
+ with pytest.raises(BlueprintSessionReleased):
+ sessions.use(KEY, 0, Resource, lambda _: None)
diff --git a/tests/test_recorded_camera_bounds.py b/tests/test_recorded_camera_bounds.py
new file mode 100644
index 0000000..52c284d
--- /dev/null
+++ b/tests/test_recorded_camera_bounds.py
@@ -0,0 +1,86 @@
+from pathlib import Path
+
+import numpy as np
+import pytest
+import rerun as rr
+
+import k1link.viewer.recorded_camera_bounds as camera_bounds
+from k1link.viewer.recorded_camera_bounds import recorded_orbital_radius_limit
+
+
+def _recording(path: Path) -> None:
+ recording = rr.RecordingStream("camera-bounds-test", recording_id="camera-bounds")
+ recording.set_sinks(rr.FileSink(path, write_footer=True))
+ try:
+ recording.set_time("session_time", duration=np.timedelta64(10, "s"))
+ recording.log("/world/points", rr.Points3D([[0, 0, 0], [3, 4, 0]]))
+ recording.set_time("session_time", duration=np.timedelta64(20, "s"))
+ recording.log("/world/points", rr.Points3D([[10, 0, 0], [10, 0, 12]]))
+ recording.flush(timeout_sec=5)
+ finally:
+ recording.disconnect()
+
+
+def test_recorded_orbital_radius_uses_visible_accumulation_window(tmp_path: Path) -> None:
+ path = tmp_path / "recording.rrd"
+ _recording(path)
+
+ accumulated = recorded_orbital_radius_limit(
+ path,
+ current_time_ns=20_000_000_000,
+ accumulation_seconds=15,
+ show_points=True,
+ show_trajectory=False,
+ )
+ latest = recorded_orbital_radius_limit(
+ path,
+ current_time_ns=20_000_000_000,
+ accumulation_seconds=0,
+ show_points=True,
+ show_trajectory=False,
+ )
+
+ assert accumulated == pytest.approx(5 * np.sqrt(10**2 + 4**2 + 12**2), rel=1e-6)
+ assert latest == pytest.approx(60, rel=1e-6)
+
+
+def test_recorded_orbital_radius_is_absent_without_mapping_layers(tmp_path: Path) -> None:
+ path = tmp_path / "recording.rrd"
+ _recording(path)
+ assert (
+ recorded_orbital_radius_limit(
+ path,
+ current_time_ns=20_000_000_000,
+ accumulation_seconds=15,
+ show_points=False,
+ show_trajectory=False,
+ )
+ is None
+ )
+
+
+def test_recorded_camera_queries_reuse_the_generation_bounds_index(tmp_path: Path) -> None:
+ path = tmp_path / "recording.rrd"
+ _recording(path)
+ camera_bounds._recorded_spatial_bounds_index.cache_clear()
+
+ first = recorded_orbital_radius_limit(
+ path,
+ current_time_ns=10_000_000_000,
+ accumulation_seconds=0,
+ show_points=True,
+ show_trajectory=False,
+ )
+ second = recorded_orbital_radius_limit(
+ path,
+ current_time_ns=20_000_000_000,
+ accumulation_seconds=0,
+ show_points=True,
+ show_trajectory=False,
+ )
+
+ cache = camera_bounds._recorded_spatial_bounds_index.cache_info()
+ assert first == pytest.approx(25, rel=1e-6)
+ assert second == pytest.approx(60, rel=1e-6)
+ assert cache.misses == 1
+ assert cache.hits == 1
diff --git a/tests/test_rrd_export.py b/tests/test_rrd_export.py
index a3046a2..d80763d 100644
--- a/tests/test_rrd_export.py
+++ b/tests/test_rrd_export.py
@@ -499,6 +499,30 @@ def test_recorded_follow_mode_tracks_sensor_pose_with_the_same_orbital_eye() ->
}
+def test_recorded_layer_blueprint_can_carry_the_operator_eye() -> None:
+ blueprint = viewer_recorded_blueprint(
+ RerunSceneSettings(),
+ include_initial_playback_state=False,
+ unified_perception=True,
+ update_eye_controls=False,
+ eye_position=(3.0, 4.0, 5.0),
+ eye_look_target=(1.0, 2.0, 0.0),
+ eye_up=(0.0, 0.0, 1.0),
+ )
+ eye = blueprint.root_container.contents[1].properties["EyeControls3D"]
+ components = {
+ str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
+ for batch in eye.as_component_batches()
+ }
+ assert components == {
+ "EyeControls3D:kind": [2],
+ "EyeControls3D:position": [[3.0, 4.0, 5.0]],
+ "EyeControls3D:look_target": [[1.0, 2.0, 0.0]],
+ "EyeControls3D:eye_up": [[0.0, 0.0, 1.0]],
+ "EyeControls3D:tracking_entity": [""],
+ }
+
+
@pytest.mark.parametrize("reactivate_updates", [False, True])
def test_recorded_blueprint_updates_reuse_source_store_and_opt_in_to_activation(
monkeypatch: pytest.MonkeyPatch,
@@ -601,9 +625,10 @@ def test_recorded_blueprint_updates_reuse_source_store_and_opt_in_to_activation(
assert len(reset_store_ids) == 1
assert reset_store_ids.isdisjoint(initial_store_ids)
assert eye_control_updates == [True, False, True, False, True]
- assert explicit_presets == [
- False, reactivate_updates, reactivate_updates, reactivate_updates, False
- ]
+ # Reactivating a blueprint clone is required for visible layer changes, but
+ # those changes must not write position/look-target/eye-up and reset the
+ # operator's camera. Only the follow transition writes a spatial preset.
+ assert explicit_presets == [False, False, True, False, False]
assert [activation[1:] for activation in activations] == [
(True, False),
(reactivate_updates, False),
@@ -644,18 +669,21 @@ def test_viewer_blueprint_unifies_original_video_and_independent_ai_layers() ->
RerunSceneSettings(accumulation_seconds=12.0),
include_initial_playback_state=False,
unified_perception=True,
+ unified_camera_share=0.73,
+ show_camera_image=False,
show_detections_2d=True,
show_segmentation=True,
show_cuboids_3d=False,
)
assert type(blueprint.root_container).__name__ == "Horizontal"
+ assert blueprint.root_container.column_shares == pytest.approx([0.73, 0.27])
camera_view, spatial_view = blueprint.root_container.contents
assert camera_view.origin == "/perception/camera"
assert spatial_view.origin == "/world"
assert camera_view.visualizer_overrides[
"/perception/camera/image"
- ].visible.as_arrow_array().to_pylist() == [True]
+ ].visible.as_arrow_array().to_pylist() == [False]
assert camera_view.visualizer_overrides[
"/perception/camera/detections"
].visible.as_arrow_array().to_pylist() == [True]
diff --git a/tests/test_session_api.py b/tests/test_session_api.py
index b506f2a..62354b5 100644
--- a/tests/test_session_api.py
+++ b/tests/test_session_api.py
@@ -9,6 +9,7 @@ import time
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
+from types import SimpleNamespace
from typing import Any
import pytest
@@ -41,6 +42,7 @@ from k1link.web.camera_archive import CameraArchiveWriter
from k1link.web.observatory_api import build_observatory_router
from k1link.web.session_api import (
LayoutPutRequest,
+ RecordedBlueprintLifecycleRequest,
RecordedBlueprintRequest,
RecordedPerceptionRequest,
RecordedPointColorsRequest,
@@ -441,20 +443,15 @@ def test_session_router_versions_capability_and_calculation_profile_projections(
)["items"]
v3_by_id = {item["id"]: item for item in v3_labs}
assert v3_by_id[legacy.session_id]["lab"]["calculation_profile"] is None
- assert (
- v3_by_id[canonical.session_id]["lab"]["calculation_profile"]
- == calculation_profile
- )
- assert (
- v3_by_id[canonical.session_id]["lab"]["replay_capability"]
- == capability.as_dict()
- )
+ assert v3_by_id[canonical.session_id]["lab"]["calculation_profile"] == calculation_profile
+ assert v3_by_id[canonical.session_id]["lab"]["replay_capability"] == capability.as_dict()
application = FastAPI()
application.include_router(router)
- assert TestClient(application).get(
- "/api/v1/observation-sessions?lab_contract=v4"
- ).status_code == 422
+ assert (
+ TestClient(application).get("/api/v1/observation-sessions?lab_contract=v4").status_code
+ == 422
+ )
def test_observatory_projection_api_renames_alias_and_deletes_only_projection(
@@ -508,28 +505,37 @@ def test_observatory_projection_api_renames_alias_and_deletes_only_projection(
client = TestClient(application)
url = f"/api/v1/observatory/lab-projections/{projection.session_id}"
- assert client.patch(
- url,
- json={
- "schema_version": "missioncore.observatory-lab-projection-rename/v0",
- "display_name": "Неверная версия",
- },
- ).status_code == 422
- assert client.patch(
- url,
- json={
- "schema_version": "missioncore.observatory-lab-projection-rename/v1",
- "display_name": "Новый разбор",
- "unexpected": True,
- },
- ).status_code == 422
- assert client.patch(
- url,
- json={
- "schema_version": "missioncore.observatory-lab-projection-rename/v1",
- "display_name": " ",
- },
- ).status_code == 422
+ assert (
+ client.patch(
+ url,
+ json={
+ "schema_version": "missioncore.observatory-lab-projection-rename/v0",
+ "display_name": "Неверная версия",
+ },
+ ).status_code
+ == 422
+ )
+ assert (
+ client.patch(
+ url,
+ json={
+ "schema_version": "missioncore.observatory-lab-projection-rename/v1",
+ "display_name": "Новый разбор",
+ "unexpected": True,
+ },
+ ).status_code
+ == 422
+ )
+ assert (
+ client.patch(
+ url,
+ json={
+ "schema_version": "missioncore.observatory-lab-projection-rename/v1",
+ "display_name": " ",
+ },
+ ).status_code
+ == 422
+ )
renamed = client.patch(
url,
@@ -785,16 +791,16 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording(
"GET",
)
try:
- response = asyncio.run(spatial_route(
- session_id=session.name,
- generation=generation,
- time_ns=500_000_000,
- profile="source-paced-ground-v3",
- ))
- assert json.loads(response.body) == expected
- assert response.headers["etag"] == (
- f'"{generation}:source-paced-ground-v3:499000000"'
+ response = asyncio.run(
+ spatial_route(
+ session_id=session.name,
+ generation=generation,
+ time_ns=500_000_000,
+ profile="source-paced-ground-v3",
+ )
)
+ assert json.loads(response.body) == expected
+ assert response.headers["etag"] == (f'"{generation}:source-paced-ground-v3:499000000"')
assert response.headers["cache-control"].endswith("immutable")
finally:
manager.close()
@@ -1561,6 +1567,89 @@ def test_production_router_never_materializes_recording_inline(tmp_path: Path) -
assert calls == 0
+def test_blueprint_lifecycle_releases_without_repreparing_source(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls: list[tuple[str, tuple[str, str, str]]] = []
+
+ class Sessions:
+ def release(self, key: tuple[str, str, str]) -> None:
+ calls.append(("release", key))
+
+ def renew(self, key: tuple[str, str, str]) -> bool:
+ calls.append(("renew", key))
+ return True
+
+ def forbidden_prepare(*args: object, **kwargs: object) -> None:
+ pytest.fail("lifecycle must not reprepare a recording")
+
+ monkeypatch.setattr("k1link.web.session_api.recorded_blueprint_sessions", Sessions())
+ monkeypatch.setattr("k1link.web.session_api._prepare_replay", forbidden_prepare)
+ store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
+ route = endpoint(
+ build_session_router(store),
+ "/api/v1/observation-sessions/{session_id}/blueprint-lifecycle",
+ "POST",
+ )
+ for action in ("renew", "release"):
+ request = RecordedBlueprintLifecycleRequest.model_validate(
+ {
+ "action": action,
+ "application_id": "nodedc_mission_core_recorded",
+ "recording_id": "recording",
+ "blueprint_session_id": "f" * 32,
+ }
+ )
+ response = asyncio.run(route(session_id="removed-source", request=request))
+ assert response.status_code == 204
+ assert response.headers["cache-control"] == "no-store"
+ assert calls == [
+ (action, ("nodedc_mission_core_recorded", "recording", "f" * 32))
+ for action in ("renew", "release")
+ ]
+ with pytest.raises(HTTPException) as error:
+ asyncio.run(route(session_id="../unsafe", request=request))
+ assert error.value.status_code == 422
+ with pytest.raises(ValueError):
+ RecordedBlueprintLifecycleRequest.model_validate({**request.model_dump(), "path": "/tmp"})
+
+
+def test_released_blueprint_update_returns_gone(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from k1link.viewer.recorded_blueprint_lifecycle import BlueprintSessionReleased
+
+ def released(*args: object, **kwargs: object) -> bytes:
+ raise BlueprintSessionReleased("closed")
+
+ monkeypatch.setattr("k1link.web.session_api._prepare_replay", lambda *args: None)
+ monkeypatch.setattr("k1link.web.session_api.recorded_blueprint_rrd", released)
+ store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
+ route = endpoint(
+ build_session_router(store),
+ "/api/v1/observation-sessions/{session_id}/blueprint.rrd",
+ "POST",
+ )
+ with pytest.raises(HTTPException) as error:
+ asyncio.run(
+ route(
+ session_id="source",
+ request=RecordedBlueprintRequest(
+ application_id="nodedc_mission_core_recorded",
+ recording_id="recording",
+ blueprint_session_id="f" * 32,
+ accumulation_seconds=0.0,
+ show_points=True,
+ show_trajectory=False,
+ show_grid=True,
+ ),
+ )
+ )
+ assert error.value.status_code == 410
+
+
def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -1607,10 +1696,14 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
active_view="perception",
view_reset_generation=1,
unified_perception=True,
+ unified_camera_share=0.73,
show_detections_2d=True,
show_segmentation=True,
show_cuboids_3d=True,
follow_trajectory=True,
+ eye_position=(3.0, 4.0, 5.0),
+ eye_look_target=(1.0, 2.0, 0.0),
+ eye_up=(0.0, 0.0, 1.0),
),
)
)
@@ -1629,10 +1722,14 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
assert observed_kwargs[0]["active_view"] == "perception"
assert observed_kwargs[0]["view_reset_generation"] == 1
assert observed_kwargs[0]["unified_perception"] is True
+ assert observed_kwargs[0]["unified_camera_share"] == pytest.approx(0.73)
assert observed_kwargs[0]["show_detections_2d"] is True
assert observed_kwargs[0]["show_segmentation"] is True
assert observed_kwargs[0]["show_cuboids_3d"] is True
assert observed_kwargs[0]["follow_trajectory"] is True
+ assert observed_kwargs[0]["eye_position"] == (3.0, 4.0, 5.0)
+ assert observed_kwargs[0]["eye_look_target"] == (1.0, 2.0, 0.0)
+ assert observed_kwargs[0]["eye_up"] == (0.0, 0.0, 1.0)
with pytest.raises(ValueError):
RecordedBlueprintRequest.model_validate(
@@ -1644,6 +1741,32 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
"source_url": "https://outside.invalid/recording.rrd",
}
)
+ with pytest.raises(ValueError):
+ RecordedBlueprintRequest.model_validate(
+ {
+ "application_id": "nodedc_mission_core_recorded",
+ "recording_id": "recording-001",
+ "blueprint_session_id": "a" * 32,
+ "accumulation_seconds": 12.0,
+ "show_points": True,
+ "show_trajectory": True,
+ "show_grid": True,
+ "unified_camera_share": 1.2,
+ }
+ )
+ with pytest.raises(ValueError):
+ RecordedBlueprintRequest.model_validate(
+ {
+ "application_id": "nodedc_mission_core_recorded",
+ "recording_id": "recording-001",
+ "blueprint_session_id": "a" * 32,
+ "accumulation_seconds": 12.0,
+ "show_points": True,
+ "show_trajectory": True,
+ "show_grid": True,
+ "eye_position": [3.0, 4.0, 5.0],
+ }
+ )
with pytest.raises(HTTPException) as missing:
asyncio.run(
blueprint_route(
@@ -1662,6 +1785,68 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
assert missing.value.status_code == 404
+def test_recorded_blueprint_restores_camera_bounds_after_base_launch_lease_expires(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ repository = tmp_path / "repo"
+ sessions = repository / "sessions"
+ session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
+ store = SessionStore(repository, data_dir=tmp_path / "data")
+ store.reconcile_archive(xgrids_k1_archive_source(sessions))
+ recording_path = tmp_path / "published.rrd"
+ calls: list[tuple[Path, dict[str, object]]] = []
+
+ class PublishedMaterializer:
+ def restore_published(self, command: ReplayCommand) -> object:
+ assert command.session_id == session.name
+ return SimpleNamespace(path=recording_path)
+
+ def camera_bounds(path: Path, **kwargs: object) -> float:
+ calls.append((path, kwargs))
+ return 123.5
+
+ monkeypatch.setattr(
+ "k1link.web.session_api.recorded_blueprint_rrd",
+ lambda *_args, **_kwargs: b"RRF2",
+ )
+ monkeypatch.setattr("k1link.web.session_api.recorded_orbital_radius_limit", camera_bounds)
+ route = endpoint(
+ build_session_router(store, recording_materializer=PublishedMaterializer()), # type: ignore[arg-type]
+ "/api/v1/observation-sessions/{session_id}/blueprint.rrd",
+ "POST",
+ )
+
+ response = asyncio.run(
+ route(
+ session_id=session.name,
+ request=RecordedBlueprintRequest(
+ application_id="nodedc_mission_core_recorded",
+ recording_id="recording-camera-bounds",
+ blueprint_session_id="b" * 32,
+ accumulation_seconds=305.0,
+ show_points=True,
+ show_trajectory=False,
+ show_grid=True,
+ current_time_ns=39_215_000_000,
+ ),
+ )
+ )
+
+ assert response.headers["x-missioncore-camera-max-orbital-radius"] == "123.5"
+ assert calls == [
+ (
+ recording_path,
+ {
+ "current_time_ns": 39_215_000_000,
+ "accumulation_seconds": 305.0,
+ "show_points": True,
+ "show_trajectory": False,
+ },
+ )
+ ]
+
+
def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
tmp_path: Path,
) -> None:
diff --git a/tests/test_session_catalog_pagination.py b/tests/test_session_catalog_pagination.py
new file mode 100644
index 0000000..0209fab
--- /dev/null
+++ b/tests/test_session_catalog_pagination.py
@@ -0,0 +1,76 @@
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from test_session_api import lab_method, make_legacy_session
+
+from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
+from k1link.sessions import SessionStore
+from k1link.web.session_api import build_session_router
+
+
+def test_cursor_pages_keep_legacy_response_and_all_catalog_records(tmp_path: Path) -> None:
+ repository = tmp_path / "repo"
+ sessions = repository / "sessions"
+ for index in range(102):
+ make_legacy_session(sessions, f"source-{index:03}_viewer_live")
+ store = SessionStore(repository, data_dir=tmp_path / "data")
+ store.reconcile_archive(xgrids_k1_archive_source(sessions))
+ for index in range(102):
+ store.publish_lab_instance(
+ session_id=f"lab-{index:03}",
+ source_session_id="source-000_viewer_live",
+ display_name=f"Historical LAB {index}",
+ lab_id=f"LAB E{index}",
+ result_kind="historical-evidence",
+ result_id=f"result-{index:03}",
+ run_created_at_utc="2026-07-23T15:55:15.548Z",
+ provenance={"method": lab_method()},
+ )
+ app = FastAPI()
+ app.include_router(build_session_router(store))
+ with TestClient(app) as client:
+ for scope in ("source", "laboratory"):
+ base = {"scope": scope, "limit": 100, "lab_contract": "v3"}
+ legacy = client.get("/api/v1/observation-sessions", params=base)
+ assert legacy.status_code == 200
+ assert set(legacy.json()) == {"items"}
+ first = client.get(
+ "/api/v1/observation-sessions", params={**base, "pagination": "cursor-v1"}
+ ).json()
+ assert first["schema_version"] == "missioncore.observation-session-page/v1"
+ assert first["items"] == legacy.json()["items"]
+ assert first["next_cursor"] == first["items"][-1]["id"]
+ second = client.get(
+ "/api/v1/observation-sessions",
+ params={**base, "pagination": "cursor-v1", "cursor": first["next_cursor"]},
+ ).json()
+ assert len(second["items"]) == 2
+ assert second["next_cursor"] is None
+ assert len({item["id"] for item in first["items"] + second["items"]}) == 102
+ # Neither paging nor the Observatory projection mutates the Legacy endpoint.
+ assert client.get("/api/v1/observation-sessions", params=base).json() == legacy.json()
+ assert client.get("/api/v1/observation-sessions?pagination=cursor-v2").status_code == 422
+ assert (
+ client.get(
+ "/api/v1/observation-sessions?scope=source&pagination=cursor-v1&cursor=lab-099"
+ ).status_code
+ == 404
+ )
+
+
+def test_empty_and_exact_pages_end_explicitly(tmp_path: Path) -> None:
+ repository = tmp_path / "repo"
+ store = SessionStore(repository, data_dir=tmp_path / "data")
+ app = FastAPI()
+ app.include_router(build_session_router(store))
+ with TestClient(app) as client:
+ url = "/api/v1/observation-sessions?scope=source&pagination=cursor-v1&limit=1"
+ empty = client.get(url).json()
+ assert empty["items"] == []
+ assert empty["next_cursor"] is None
+ make_legacy_session(repository / "sessions", "source-one_viewer_live")
+ store.reconcile_archive(xgrids_k1_archive_source(repository / "sessions"))
+ full = client.get(url).json()
+ assert len(full["items"]) == 1
+ assert full["next_cursor"] is None
diff --git a/tests/test_vegetation_shadow_lab.py b/tests/test_vegetation_shadow_lab.py
index de4e36b..be8ff2b 100644
--- a/tests/test_vegetation_shadow_lab.py
+++ b/tests/test_vegetation_shadow_lab.py
@@ -21,6 +21,7 @@ import k1link.laboratory.vegetation_shadow_lab as vegetation_lab_module
import k1link.web.vegetation_shadow_lab_api as vegetation_api_module
from k1link.laboratory import LaboratoryEvidenceRegistry
from k1link.laboratory.canonical_rerun_overlay import (
+ CANONICAL_REPLAY_RESULT_ID,
CanonicalLabOverlayArtifact,
CanonicalLabReplayArtifact,
_artifact_is_regular,
@@ -69,10 +70,13 @@ def test_canonical_overlay_localizes_current_taxonomies() -> None:
def test_canonical_video_references_hold_only_missing_source_samples() -> None:
session_times = np.arange(10, dtype=np.int64) * 100_000_000 + 39_000_000_000
- video_times = np.array(
- [0, 100, 200, 300, 400, 500, 600, 700, 900],
- dtype=np.int64,
- ) * 1_000_000
+ video_times = (
+ np.array(
+ [0, 100, 200, 300, 400, 500, 600, 700, 900],
+ dtype=np.int64,
+ )
+ * 1_000_000
+ )
references = _video_reference_timestamps(video_times, session_times)
@@ -219,6 +223,22 @@ def test_canonical_replay_merges_base_and_overlay_once(
assert calls == 1
+def test_canonical_replay_accepts_each_published_portable_result_family() -> None:
+ suffix = "a" * 64
+ accepted = {
+ "lab-v1-vegetation-shadow",
+ "m49-tgs-portable-review",
+ "lab-v1-eomt-ddrnet",
+ "ai-layer-ddrnet",
+ "ai-layer-eomt",
+ "ai-layer-rf-detr",
+ "ai-layer-object-distance",
+ }
+
+ assert all(CANONICAL_REPLAY_RESULT_ID.fullmatch(f"{prefix}-{suffix}") for prefix in accepted)
+ assert CANONICAL_REPLAY_RESULT_ID.fullmatch(f"ai-layer-unknown-{suffix}") is None
+
+
def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
tmp_path: Path,
monkeypatch,
@@ -341,9 +361,7 @@ def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
)
assert stale_overlay.status_code == 412
- replay_endpoint = (
- f"/api/v1/laboratory/vegetation-shadow/{result_id}/canonical-replay.rrd"
- )
+ replay_endpoint = f"/api/v1/laboratory/vegetation-shadow/{result_id}/canonical-replay.rrd"
replay_descriptor = client.head(
replay_endpoint,
params={"base_generation": generation},
@@ -496,7 +514,9 @@ def _worker_result(root: Path, *, candidate: str, mode: str, vegetation_iou: flo
"truth_pixels": 16384,
"truth_fraction": 0.0625,
"stratum_rank": index + 1,
- } if mode == "goose" else None,
+ }
+ if mode == "goose"
+ else None,
"files": files,
}
)
@@ -726,8 +746,7 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(
},
]
(full_root / "result.json").write_text(
- json.dumps(full_manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
- + "\n",
+ json.dumps(full_manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n",
encoding="utf-8",
)
for layer, sequence, expected in (
@@ -735,27 +754,24 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(
("vegetation", 1, full_archive_payloads[1]),
):
response = client.get(
- f"/api/v1/laboratory/vegetation-shadow/{full_result_id}"
- f"/route-masks/{layer}/{sequence}"
+ f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-masks/{layer}/{sequence}"
)
assert response.status_code == 200
assert response.content == expected
assert response.headers["cache-control"].endswith("immutable")
- assert client.get(
- f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-masks/city/2"
- ).status_code == 404
- timeline = client.get(
- f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-timeline"
+ assert (
+ client.get(
+ f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-masks/city/2"
+ ).status_code
+ == 404
)
+ timeline = client.get(f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-timeline")
assert timeline.status_code == 200
assert timeline.content == full_timeline_payload
assert timeline.headers["cache-control"].endswith("immutable")
(result_root / asset_path).write_bytes(b"tampered")
- assert (
- client.get(f"/api/v1/laboratory/vegetation-shadow/{result_root.name}").status_code
- == 503
- )
+ assert client.get(f"/api/v1/laboratory/vegetation-shadow/{result_root.name}").status_code == 503
def test_policy_review_reuses_sealed_video_and_links_yolox_tgs(
@@ -837,9 +853,7 @@ def test_policy_review_reuses_sealed_video_and_links_yolox_tgs(
assert route["linked_tgs_result_id"] == tgs_result_id
assert route["fusion"]["pixel_raster_fusion"] is False
assert route["fusion"]["camera_semantic_temporal_filter"] == "none"
- assert route["taxonomy"]["schema_version"] == (
- "missioncore.lab-v1-terrain-policy-taxonomy/v1"
- )
+ assert route["taxonomy"]["schema_version"] == ("missioncore.lab-v1-terrain-policy-taxonomy/v1")
assert len(route["taxonomy"]["classes"]) == 10
assert route["valid_fov"]["outside_valid_fov_class_id"] == 9
assert len(manifest["artifacts"]) == 80
diff --git a/tests/test_worker_source_cache.py b/tests/test_worker_source_cache.py
new file mode 100644
index 0000000..433b9ec
--- /dev/null
+++ b/tests/test_worker_source_cache.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+import errno
+import hashlib
+import os
+from pathlib import Path
+
+import pytest
+
+from k1link.observatory.worker_source_cache import WorkerSourceCache, WorkerSourceCacheError
+
+
+def test_cache_reuses_read_only_bytes_without_copy_or_retained_buffers(tmp_path: Path) -> None:
+ source = tmp_path / "first-job-input"
+ source.write_bytes(b"source-bytes")
+ digest = hashlib.sha256(source.read_bytes()).hexdigest()
+ cache = WorkerSourceCache(tmp_path / "source-cache")
+ assert cache.retain(source, sha256=digest, byte_length=12)
+ destination = tmp_path / "second-job-input"
+ assert cache.restore(destination, sha256=digest, byte_length=12)
+ assert destination.read_bytes() == b"source-bytes"
+ assert destination.stat().st_ino == source.stat().st_ino
+ assert destination.stat().st_mode & 0o222 == 0
+ assert set(vars(cache)) == {"root"}
+ assert not list(tmp_path.rglob(".source-cache-*"))
+
+
+def test_bad_cache_is_miss_and_original_evidence_is_preserved(tmp_path: Path) -> None:
+ good = b"good-source"
+ digest = hashlib.sha256(good).hexdigest()
+ cache = WorkerSourceCache(tmp_path / "source-cache")
+ damaged = cache.root / digest
+ damaged.write_bytes(b"bad--source")
+ assert not cache.restore(tmp_path / "new-job", sha256=digest, byte_length=len(good))
+ new_source = tmp_path / "fresh-download"
+ new_source.write_bytes(good)
+ assert not cache.retain(new_source, sha256=digest, byte_length=len(good))
+ assert damaged.read_bytes() == b"bad--source"
+ assert new_source.read_bytes() == good
+ assert not (tmp_path / "new-job").exists()
+ assert not list(tmp_path.rglob(".source-cache-*"))
+
+
+@pytest.mark.parametrize("kind", ["symlink", "fifo", "directory"])
+def test_unsafe_cached_object_is_not_read(tmp_path: Path, kind: str) -> None:
+ digest = hashlib.sha256(b"data").hexdigest()
+ cache = WorkerSourceCache(tmp_path / "cache")
+ cached = cache.root / digest
+ if kind == "symlink":
+ private = tmp_path / "not-source-evidence"
+ private.write_bytes(b"data")
+ cached.symlink_to(private)
+ elif kind == "fifo":
+ os.mkfifo(cached)
+ else:
+ cached.mkdir()
+ assert not cache.restore(tmp_path / "new-job", sha256=digest, byte_length=4)
+ assert not (tmp_path / "new-job").exists()
+
+
+def test_wrong_source_bytes_do_not_enter_cache(tmp_path: Path) -> None:
+ source = tmp_path / "download"
+ source.write_bytes(b"bad")
+ cache = WorkerSourceCache(tmp_path / "cache")
+ digest = hashlib.sha256(b"yes").hexdigest()
+ assert not cache.retain(source, sha256=digest, byte_length=3)
+ assert list(cache.root.iterdir()) == []
+ assert source.read_bytes() == b"bad"
+
+
+def test_existing_destination_and_unsafe_identity_are_rejected(tmp_path: Path) -> None:
+ source = tmp_path / "download"
+ source.write_bytes(b"data")
+ cache = WorkerSourceCache(tmp_path / "cache")
+ digest = hashlib.sha256(b"data").hexdigest()
+ cache.retain(source, sha256=digest, byte_length=4)
+ with pytest.raises(WorkerSourceCacheError, match="not empty"):
+ cache.restore(source, sha256=digest, byte_length=4)
+ with pytest.raises(WorkerSourceCacheError, match="identity is invalid"):
+ cache.restore(tmp_path / "other", sha256="../outside", byte_length=4)
+
+
+def test_unsupported_hardlinks_are_only_cache_misses(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ source = tmp_path / "download"
+ source.write_bytes(b"data")
+ cache = WorkerSourceCache(tmp_path / "cache")
+
+ def unsupported(*args: object, **kwargs: object) -> None:
+ raise OSError("different filesystem")
+
+ monkeypatch.setattr(os, "link", unsupported)
+ assert not cache.retain(source, sha256=hashlib.sha256(b"data").hexdigest(), byte_length=4)
+ assert list(cache.root.iterdir()) == []
+
+
+@pytest.mark.parametrize("corrupt", [False, True])
+def test_shared_cache_across_filesystems_copies_bounded_and_cleans_failures(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ corrupt: bool,
+) -> None:
+ source = tmp_path / "download"
+ payload = b"abcd" * 300_000
+ source.write_bytes(payload)
+ cache = WorkerSourceCache(tmp_path / "shared-cache")
+ real_link = os.link
+ real_read = os.read
+ read_sizes = []
+
+ def link(src: Path, dst: Path, **kwargs: object) -> None:
+ if Path(src) == source:
+ raise OSError(errno.EXDEV, "separate agent mount")
+ real_link(src, dst, **kwargs)
+
+ def read(fd: int, size: int) -> bytes:
+ read_sizes.append(size)
+ return real_read(fd, size)
+
+ monkeypatch.setattr(os, "link", link)
+ monkeypatch.setattr(os, "read", read)
+ digest = hashlib.sha256(b"other" if corrupt else payload).hexdigest()
+ assert cache.retain(source, sha256=digest, byte_length=len(payload)) is not corrupt
+ assert max(read_sizes) <= 1024 * 1024
+ assert not list(tmp_path.rglob(".source-cache-*"))
+ if not corrupt:
+ assert (cache.root / digest).read_bytes() == payload
+ assert (cache.root / digest).stat().st_ino != source.stat().st_ino
diff --git a/tests/test_worker_source_cache_transport.py b/tests/test_worker_source_cache_transport.py
new file mode 100644
index 0000000..39d594b
--- /dev/null
+++ b/tests/test_worker_source_cache_transport.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import replace
+from pathlib import Path
+
+import httpx
+import pytest
+from test_observatory_worker_http_transport import (
+ BEARER_TOKEN,
+ CLAIM_TOKEN,
+ _cache_claim,
+ _job,
+ _source_contract,
+)
+
+from k1link.observatory.worker_http_transport import (
+ ObservatoryWorkerHttpError,
+ ObservatoryWorkerHttpGateway,
+)
+
+
+@pytest.mark.parametrize(
+ "next_run", ["new-generation", "different-profile", "changed-camera", "bad-cache"]
+)
+def test_other_claim_reuses_bytes_but_obtains_its_own_manifest(
+ tmp_path: Path, next_run: str
+) -> None:
+ first = _job(
+ bundle_sha256=hashlib.sha256(b"source-bundle").hexdigest(),
+ capability_sha256=hashlib.sha256(b"source-capability").hexdigest(),
+ )
+ if next_run == "new-generation":
+ second = replace(first, claim_generation=2)
+ else:
+ second = replace(
+ first,
+ job_id="observatory-run-" + "8" * 32,
+ identity_sha256="9" * 64,
+ setup_id="another-profile",
+ definition_sha256="0" * 64,
+ )
+ requests: list[str] = []
+ active = first
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ manifest, payloads = _source_contract(
+ active,
+ camera_segment_count=2 if active == second and next_run == "changed-camera" else 1,
+ )
+ if request.url.path.endswith("/claims"):
+ return httpx.Response(
+ 200,
+ json={
+ "claim_token": CLAIM_TOKEN,
+ "job": {
+ "job_id": active.job_id,
+ "claim_generation": active.claim_generation,
+ },
+ },
+ )
+ assert request.headers["x-mission-core-claim-generation"] == str(active.claim_generation)
+ assert active.job_id in request.url.path
+ requests.append(request.url.path)
+ if request.url.path.endswith("/source-materialization"):
+ return httpx.Response(200, json=manifest)
+ if request.url.path.endswith("/source-camera-epoch-archive"):
+ return httpx.Response(404)
+ payload = payloads[request.url.path.rsplit("/", 1)[-1]]
+ return httpx.Response(
+ 200,
+ content=payload,
+ headers={
+ "X-Mission-Core-Content-Sha256": hashlib.sha256(payload).hexdigest(),
+ },
+ )
+
+ arguments = dict(
+ base_url="http://127.0.0.1:18080",
+ bearer_token=BEARER_TOKEN,
+ work_root=tmp_path / "worker",
+ source_cache_root=tmp_path / "shared-source-cache",
+ transport=httpx.MockTransport(handler),
+ )
+ with ObservatoryWorkerHttpGateway(**arguments) as gateway:
+ _cache_claim(gateway)
+ first_stage = gateway.materialize(first)
+ if next_run == "bad-cache":
+ cached = (
+ tmp_path / "shared-source-cache" / hashlib.sha256(b"sealed-camera-segment").hexdigest()
+ )
+ cached.chmod(0o600)
+ cached.write_bytes(b"corrupt-cache")
+ requests.clear()
+ active = second
+ arguments["work_root"] = tmp_path / "other-agent-work"
+ # Fresh process/client instance: no in-memory cache or claim is inherited.
+ with ObservatoryWorkerHttpGateway(**arguments) as gateway:
+ with pytest.raises(ObservatoryWorkerHttpError):
+ gateway.materialize(second)
+ _cache_claim(gateway)
+ second_stage = gateway.materialize(second)
+ assert second_stage.root != first_stage.root
+ assert requests[0].endswith("/source-materialization")
+ assert len(requests) == (2 if next_run in {"changed-camera", "bad-cache"} else 1)
+ assert not any(path.endswith("/source-camera-epoch-archive") for path in requests)
+ persisted = json.loads((second_stage.root / "materialization-manifest.json").read_bytes())
+ assert persisted["job_id"] == second.job_id
+ assert persisted["job_identity_sha256"] == second.identity_sha256
+ assert persisted["claim_generation"] == second.claim_generation
+ assert (
+ second_stage.root / "camera/epoch-1/segments/1.m4s"
+ ).read_bytes() == b"sealed-camera-segment"
+ assert not list(tmp_path.rglob(".source-cache-*"))
+
+
+def test_replayed_old_manifest_cannot_use_cached_source(tmp_path: Path) -> None:
+ first = _job(bundle_sha256="1" * 64, capability_sha256="2" * 64)
+ manifest, _ = _source_contract(first)
+ second = replace(first, claim_generation=2)
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("/claims"):
+ return httpx.Response(
+ 200,
+ json={
+ "claim_token": CLAIM_TOKEN,
+ "job": {
+ "job_id": second.job_id,
+ "claim_generation": 2,
+ },
+ },
+ )
+ assert request.url.path.endswith("/source-materialization")
+ return httpx.Response(200, json=manifest)
+
+ with ObservatoryWorkerHttpGateway(
+ base_url="http://127.0.0.1:18080",
+ bearer_token=BEARER_TOKEN,
+ work_root=tmp_path / "worker",
+ transport=httpx.MockTransport(handler),
+ ) as gateway:
+ _cache_claim(gateway)
+ with pytest.raises(ObservatoryWorkerHttpError):
+ gateway.materialize(second)
+ assert not (tmp_path / "worker/sources").exists()