feat(lab): separate evidence catalogs and record methods

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 17:56:23 +03:00
parent e9ff29297b
commit 4db00b53f7
17 changed files with 670 additions and 24 deletions
@@ -122,6 +122,7 @@ export function ObservationSessionSelect({
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: "source",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
@@ -321,14 +322,13 @@ export function ObservationSessionArchive({
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: labsOnly ? "laboratory" : "source",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
});
const items = labsOnly
? sessions.items.filter((session) => session.lab !== null)
: sessions.items;
const items = sessions.items;
return <>
<section
@@ -15,6 +15,8 @@ export interface E29EvidenceResult {
frameCount: number;
timelineStartSeconds: number;
timelineEndSeconds: number;
profileId: string;
producerSha256: string;
};
metrics: {
frames: {
@@ -208,6 +210,7 @@ function parseReviewFrame(value: unknown): E29ReviewFrame {
function parseEvidenceResult(value: unknown): E29EvidenceResult {
const source = record(value, "E29 result");
const identity = record(source.identity, "E29 identity");
const profile = record(identity.profile, "E29 identity.profile");
const metrics = record(source.metrics, "E29 metrics");
const frames = record(metrics.frames, "E29 metrics.frames");
const semantic = record(
@@ -253,6 +256,11 @@ function parseEvidenceResult(value: unknown): E29EvidenceResult {
identity.timeline_end_seconds,
"E29 identity.timeline_end_seconds",
),
profileId: stringValue(profile.profile_id, "E29 identity.profile.profile_id"),
producerSha256: stringValue(
identity.producer_sha256,
"E29 identity.producer_sha256",
),
},
metrics: {
frames: {
@@ -22,6 +22,12 @@ export interface LidarLocalSurfaceModel {
sessionId: string;
sourcePackId: string;
status: "diagnostic-only";
method: {
executionClass: "deterministic";
pipelineId: string;
algorithm: string;
producerSha256: string;
};
source: {
frameCount: number;
availableLidarFrames: number;
@@ -562,6 +568,8 @@ function temporalQualification(
function model(value: unknown): LidarLocalSurfaceModel {
const source = record(value, "LiDAR local-surface model");
const sourceEvidence = record(source.source, "source");
const surfaceModel = record(source.surface_model, "surface_model");
const surfaceProfile = record(surfaceModel.profile, "surface_model.profile");
const metrics = record(source.metrics, "metrics");
const frames = record(metrics.frames, "metrics.frames");
if (
@@ -611,6 +619,19 @@ function model(value: unknown): LidarLocalSurfaceModel {
sessionId: text(source.session_id, "session_id", SAFE_ID),
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
status: "diagnostic-only",
method: {
executionClass: "deterministic",
pipelineId: text(
surfaceProfile.profile_id,
"surface_model.profile.profile_id",
),
algorithm: text(surfaceModel.kind, "surface_model.kind"),
producerSha256: text(
source.producer_sha256,
"producer_sha256",
/^[a-f0-9]{64}$/,
),
},
source: {
frameCount: integer(sourceEvidence.frame_count, "source.frame_count"),
availableLidarFrames: integer(
@@ -9,6 +9,8 @@ export type ObservationSessionStatus =
| "interrupted"
| "failed";
export type ObservationSessionScope = "all" | "source" | "laboratory";
export interface ObservationLabInstance {
labId: string;
sourceSessionId: string;
@@ -1077,15 +1079,21 @@ function requirePreparationEtag(
export async function fetchObservationSessionCatalog({
signal,
limit,
scope = "all",
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
limit?: number;
scope?: ObservationSessionScope;
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservationSessionCatalog> {
const query = Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= 100
? `?limit=${Number(limit)}`
: "";
const queryParameters = new URLSearchParams();
if (Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= 100) {
queryParameters.set("limit", String(Number(limit)));
}
if (scope !== "all") queryParameters.set("scope", scope);
const serializedQuery = queryParameters.toString();
const query = serializedQuery ? `?${serializedQuery}` : "";
let response: Response;
try {
response = await fetcher(`/api/v1/observation-sessions${query}`, {
@@ -9,6 +9,7 @@ import {
type ObservationSessionFetch,
type ObservationSessionPreparation,
type ObservationSessionReplayLaunch,
type ObservationSessionScope,
type ObservationSessionSummary,
} from "./sessionArchive";
@@ -369,12 +370,14 @@ export function clearObservationReplayPreparation(
export function useObservationSessions({
limit = 100,
scope = "all",
replayEnabled = true,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
scope?: ObservationSessionScope;
replayEnabled?: boolean;
/** Called only after the archive is ready, immediately before replacing the old viewer. */
onReplayBegin?: (
@@ -425,7 +428,10 @@ export function useObservationSessions({
const sequence = ++catalogSequence.current;
if (foreground) setState("loading");
try {
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
const catalog = await fetchObservationSessionCatalog({
limit: safeLimit,
scope,
});
if (!mounted.current || sequence !== catalogSequence.current) return false;
setItems(catalog.items.slice(0, safeLimit));
setState("ready");
@@ -437,7 +443,7 @@ export function useObservationSessions({
setError(errorMessage(loadError));
return false;
}
}, [safeLimit]);
}, [safeLimit, scope]);
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
@@ -2466,6 +2466,7 @@
}
.laboratory-task,
.laboratory-method,
.laboratory-result-summary {
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
@@ -2473,6 +2474,7 @@
}
.laboratory-task > header,
.laboratory-method > header,
.laboratory-result-summary > header {
display: flex;
align-items: flex-start;
@@ -2483,12 +2485,16 @@
.laboratory-task h2,
.laboratory-task p,
.laboratory-task dl,
.laboratory-method h2,
.laboratory-method p,
.laboratory-method ul,
.laboratory-result-summary h2,
.laboratory-result-summary p {
margin: 0;
}
.laboratory-task h2,
.laboratory-method h2,
.laboratory-result-summary h2 {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
@@ -2497,6 +2503,7 @@
}
.laboratory-task p,
.laboratory-method p,
.laboratory-result-summary > p {
max-width: 66rem;
margin-top: 0.38rem;
@@ -2505,6 +2512,75 @@
line-height: 1.55;
}
.laboratory-method__summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.4rem;
margin-top: 0.9rem;
}
.laboratory-method__summary > div {
display: grid;
gap: 0.25rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.035);
padding: 0.7rem;
}
.laboratory-method__summary span,
.laboratory-method li > span,
.laboratory-method small {
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.laboratory-method__summary strong {
color: var(--nodedc-text-primary);
font-size: 0.7rem;
}
.laboratory-method ul {
display: grid;
gap: 0.35rem;
margin-top: 0.55rem;
padding: 0;
list-style: none;
}
.laboratory-method li {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.025);
padding: 0.62rem 0.7rem;
}
.laboratory-method li > span {
text-transform: uppercase;
}
.laboratory-method li > div {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.laboratory-method li strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.66rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.laboratory-method code {
color: var(--nodedc-text-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.54rem;
}
.laboratory-task dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1204,6 +1204,117 @@ interface LaboratoryOption<T extends string> {
label: string;
}
type LaboratoryExecutionClass = "deterministic" | "ai-inference" | "hybrid";
type LaboratoryMethodCompleteness = "complete" | "legacy-partial";
type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model";
interface LaboratoryMethodComponent {
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
name: string;
version: string;
role: string;
identitySha256: string | null;
}
interface LaboratoryMethod {
completeness: LaboratoryMethodCompleteness;
executionClass: LaboratoryExecutionClass;
pipelineId: string;
components: readonly LaboratoryMethodComponent[];
}
function digestFromContentId(value: string | null | undefined): string | null {
const digest = value?.split("-").at(-1) ?? "";
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
}
function publishedLaboratoryMethod(
session: ObservationSessionSummary,
): LaboratoryMethod {
const method = session.lab?.provenance.method;
if (method && typeof method === "object" && !Array.isArray(method)) {
const value = method as Record<string, unknown>;
const rawComponents = Array.isArray(value.components) ? value.components : [];
const components: LaboratoryMethodComponent[] = rawComponents.flatMap((component) => {
if (!component || typeof component !== "object" || Array.isArray(component)) return [];
const item = component as Record<string, unknown>;
const kind = item.kind;
if (
kind !== "source"
&& kind !== "tool"
&& kind !== "model"
&& kind !== "algorithm"
&& kind !== "runtime"
) return [];
if (
typeof item.name !== "string"
|| typeof item.version !== "string"
|| typeof item.role !== "string"
) return [];
return [{
kind: kind as LaboratoryMethodComponent["kind"],
name: item.name,
version: item.version,
role: item.role,
identitySha256: typeof item.identity_sha256 === "string"
? item.identity_sha256
: null,
}];
});
const executionClass = value.execution_class;
const completeness = value.completeness;
if (
components.length
&& typeof value.pipeline_id === "string"
&& (
executionClass === "deterministic"
|| executionClass === "ai-inference"
|| executionClass === "hybrid"
)
&& (completeness === "complete" || completeness === "legacy-partial")
) {
return {
completeness,
executionClass,
pipelineId: value.pipeline_id,
components,
};
}
}
const resultKind = session.lab?.resultKind ?? "unknown";
const algorithmNames: Record<string, string> = {
"e10-integrated-perception": "Camera semantics + LiDAR metric fusion",
"e21-realtime-envelope": "Bounded real-time perception replay",
"e22-temporal-stability": "Temporal 2D/3D/semantic stabilization",
"e23-inline-temporal-stability": "Inline warm-worker stabilization",
"e24-world-motion": "World-frame motion tracking",
"e25-persistent-support-motion": "Persistent occupied-support tracking",
"e26-camera-ego-motion-fusion": "KB4 ego-motion + persistent LiDAR support",
};
return {
completeness: "legacy-partial",
executionClass: "hybrid",
pipelineId: resultKind,
components: [
{
kind: "source",
name: session.lab?.sourceResultId ?? session.lab?.sourceSessionId ?? session.id,
version: "immutable source evidence",
role: "read-only input",
identitySha256: digestFromContentId(session.lab?.sourceResultId),
},
{
kind: "algorithm",
name: algorithmNames[resultKind] ?? resultKind,
version: resultKind,
role: "laboratory derivative",
identitySha256: session.lab?.configSha256 ?? null,
},
],
};
}
function LaboratorySelector<T extends string>({
eyebrow,
title,
@@ -1285,17 +1396,20 @@ function LaboratoryTask({
function LaboratoryEvidence({
eyebrow,
title,
kind,
resizable = false,
children,
}: {
eyebrow: string;
title: string;
kind: LaboratoryEvidenceKind;
resizable?: boolean;
children: ReactNode;
}) {
return (
<section
className="lab-result-surface"
data-evidence-kind={kind}
data-resizable={resizable ? "true" : undefined}
>
<header>
@@ -1309,13 +1423,69 @@ function LaboratoryEvidence({
);
}
function LaboratoryMethodCard({ method }: { method: LaboratoryMethod }) {
const complete = method.completeness === "complete";
const executionLabels: Record<LaboratoryExecutionClass, string> = {
deterministic: "Детерминированный",
"ai-inference": "AI inference",
hybrid: "Гибридный",
};
return (
<section className="laboratory-method">
<header>
<div>
<span className="section-eyebrow">МЕТОД И ВОСПРОИЗВОДИМОСТЬ</span>
<h2>{method.pipelineId}</h2>
<p>
Зафиксированы вычислительный класс, инструменты, модели и алгоритмы.
{complete
? " Идентичности достаточны для повторного запуска."
: " Это legacy-прогон: отсутствующие исторические версии не восстановлены задним числом."}
</p>
</div>
<StatusBadge tone={complete ? "success" : "warning"}>
{complete ? "Метод полный" : "Legacy · частично"}
</StatusBadge>
</header>
<div className="laboratory-method__summary">
<div>
<span>Класс вычисления</span>
<strong>{executionLabels[method.executionClass]}</strong>
</div>
<div>
<span>Компонентов</span>
<strong>{method.components.length}</strong>
</div>
</div>
<ul>
{method.components.map((component, index) => (
<li key={`${component.kind}:${component.name}:${index}`}>
<span>{component.kind}</span>
<div>
<strong>{component.name}</strong>
<small>{component.role} · {component.version}</small>
</div>
<code>
{component.identitySha256
? component.identitySha256.slice(0, 12)
: "identity не зафиксирована"}
</code>
</li>
))}
</ul>
</section>
);
}
function LaboratoryWorkTemplate({
task,
method,
evidence,
result = null,
details = null,
}: {
task: ReactNode;
method: ReactNode;
evidence: ReactNode;
result?: ReactNode;
details?: ReactNode;
@@ -1323,6 +1493,7 @@ function LaboratoryWorkTemplate({
return (
<div className="laboratory-work-template">
{task}
{method}
{evidence}
{result}
{details}
@@ -1406,10 +1577,45 @@ function E29LaboratoryResult({
]}
/>
)}
method={(
<LaboratoryMethodCard
method={{
completeness: "complete",
executionClass: "hybrid",
pipelineId: result.identity.profileId,
components: [
{
kind: "source",
name: result.linkedEvidence.sourceResultId,
version: "camera-first semantic observations",
role: "semantic identity and class",
identitySha256: digestFromContentId(result.linkedEvidence.sourceResultId),
},
{
kind: "algorithm",
name: "Camera/LiDAR local-surface validation",
version: result.identity.profileId,
role: "range, occupied support and conflict classification",
identitySha256: result.identity.producerSha256,
},
{
kind: "model",
name: result.linkedEvidence.localSurfaceModelId,
version: "L2.6 local surface",
role: "independent metric geometry",
identitySha256: digestFromContentId(
result.linkedEvidence.localSurfaceModelId,
),
},
],
}}
/>
)}
evidence={(
<LaboratoryEvidence
eyebrow="ИСХОДНЫЕ ДАННЫЕ"
title="LiDAR, траектория и камера RAVNOVES00"
kind="recorded-replay"
resizable
>
{replayReady ? (
@@ -1592,10 +1798,14 @@ function PublishedLaboratoryResult({
]}
/>
)}
method={(
<LaboratoryMethodCard method={publishedLaboratoryMethod(session)} />
)}
evidence={(
<LaboratoryEvidence
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
title="Исходная запись выбранной лабораторной работы"
kind="recorded-replay"
resizable
>
{replayReady ? (
@@ -1900,10 +2110,43 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
]}
/>
)}
method={(
<LaboratoryMethodCard
method={{
completeness: "complete",
executionClass: e28Model?.method.executionClass ?? "deterministic",
pipelineId: e28Model?.method.pipelineId ?? "local-surface/unavailable",
components: [
{
kind: "source",
name: e28Model?.sourcePackId ?? "Источник не загружен",
version: "immutable vendor MAP + pose",
role: "read-only LiDAR evidence",
identitySha256: digestFromContentId(e28Model?.sourcePackId),
},
{
kind: "algorithm",
name: e28Model?.method.algorithm ?? "Rolling local surface",
version: e28Model?.method.pipelineId ?? "—",
role: "robust local plane, occupancy and temporal residuals",
identitySha256: e28Model?.method.producerSha256 ?? null,
},
{
kind: "runtime",
name: "Mission Core worker D",
version: "recorded-source-paced shadow",
role: "bounded passive replay",
identitySha256: null,
},
],
}}
/>
)}
evidence={(
<LaboratoryEvidence
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
title="Диагностическая поверхность и кадры LAB E28"
kind="diagnostic-model"
>
<LidarQualityWorkspace
embedded
@@ -21,6 +21,10 @@ function result(overrides = {}) {
frame_count: 4489,
timeline_start_seconds: 35.4,
timeline_end_seconds: 484.0,
profile: {
profile_id: "camera-first-local-surface-validation/v1",
},
producer_sha256: "e".repeat(64),
},
metrics: {
frames: {
@@ -57,7 +57,13 @@ function model(overrides = {}) {
timeline_start_seconds: 1,
timeline_end_seconds: 2,
},
surface_model: {},
surface_model: {
kind: "time-varying-rolling-local-plane",
profile: {
profile_id: "k1-vendor-map-dynamic-local-surface/v1",
},
},
producer_sha256: "e".repeat(64),
occupancy_policy: policy(),
metrics: {
frames: {
@@ -197,6 +197,25 @@ test("data recordings keep the compact session dropdown and laboratory results s
assert.match(workspaceSource, /e29-camera-geometry/);
});
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
const calls = [];
const fetcher = async (input) => {
calls.push(String(input));
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
await fetchObservationSessionCatalog({ limit: 100, scope: "source", fetcher });
await fetchObservationSessionCatalog({ limit: 100, scope: "laboratory", fetcher });
assert.deepEqual(calls, [
"/api/v1/observation-sessions?limit=100&scope=source",
"/api/v1/observation-sessions?limit=100&scope=laboratory",
]);
});
test("session catalog exposes authoritative background preparation state", () => {
const catalog = decodeObservationSessionCatalog({
items: [session({
@@ -45,6 +45,9 @@ test("every laboratory result uses the shared evidence template", async () => {
assert.match(source, /function LaboratoryWorkTemplate\(/);
assert.match(source, /function LaboratoryEvidence\(/);
assert.match(source, /function LaboratoryMethodCard\(/);
assert.match(source, /method:\s*ReactNode/);
assert.match(source, /data-evidence-kind=\{kind\}/);
assert.match(source, /data-viewer-focused=/);
assert.match(css, /height:\s*clamp\(42rem,\s*68vh,\s*58rem\)/);
assert.match(css, /resize:\s*vertical/);
+96
View File
@@ -83,6 +83,53 @@ class PublishedCameraEgoMotionLabInstance:
build: CameraEgoMotionBuild
def _laboratory_method(
*,
pipeline_id: str,
execution_class: str,
algorithm: str,
profile_sha256: str | None,
source_result_id: str,
) -> dict[str, object]:
source_identity: str | None = source_result_id.rsplit("-", 1)[-1]
if len(source_identity) != 64 or any(
character not in "0123456789abcdef" for character in source_identity
):
source_identity = None
return {
"schema_version": "missioncore.laboratory-method/v1",
# E19-E26 predate the method manifest. The publisher now records the
# exact known identities, but does not invent historical model/runtime
# versions that were absent from their original accepted evidence.
"completeness": "legacy-partial",
"execution_class": execution_class,
"pipeline_id": pipeline_id,
"components": [
{
"kind": "source",
"name": "immutable accepted upstream result",
"version": "content-addressed",
"role": "read-only input evidence",
"identity_sha256": source_identity,
},
{
"kind": "algorithm",
"name": algorithm,
"version": pipeline_id,
"role": "laboratory derivative",
"identity_sha256": profile_sha256,
},
{
"kind": "tool",
"name": "Mission Core LAB publisher",
"version": "missioncore.lab-instance/v1",
"role": "immutable catalog projection",
"identity_sha256": _sha256(Path(__file__).resolve(strict=True)),
},
],
}
def publish_integrated_lab_instance(
*,
repository_root: Path,
@@ -156,6 +203,13 @@ def publish_integrated_lab_instance(
run_created_at_utc=source.created_at_utc,
provenance={
"schema_version": "missioncore.integrated-lab-publication/v1",
"method": _laboratory_method(
pipeline_id="integrated-perception/v1",
execution_class="hybrid",
algorithm="camera semantics + LiDAR metric fusion",
profile_sha256=profile_sha256,
source_result_id=source.result_id,
),
"storage_mode": "hard-linked-immutable-payloads",
"source_job_id": source.job.job_id,
"projected_job_id": lab_job.job_id,
@@ -263,6 +317,13 @@ def publish_e21_lab_instance(
include_recorded_media=False,
provenance={
"schema_version": "missioncore.e21-lab-publication/v1",
"method": _laboratory_method(
pipeline_id="realtime-envelope/v1",
execution_class="hybrid",
algorithm="bounded real-time perception replay",
profile_sha256=str(e21_report["identity"]["profile_sha256"]),
source_result_id=str(e21_document["result_id"]),
),
"storage_mode": "bounded-derived-replay-and-projection",
"e21_result_id": e21_document["result_id"],
"worker_result_id": worker_document["result_id"],
@@ -361,6 +422,13 @@ def publish_e22_lab_instance(
include_recorded_media=False,
provenance={
"schema_version": "missioncore.e22-lab-publication/v1",
"method": _laboratory_method(
pipeline_id="temporal-stability/v1",
execution_class="hybrid",
algorithm="bounded temporal 2D/3D/semantic stabilization",
profile_sha256=build.profile_sha256,
source_result_id=source.result_id,
),
"storage_mode": "bounded-derived-replay-and-temporal-projection",
"source_result_id": source.result_id,
"source_lab_session_id": (None if source_lab is None else source_lab.session_id),
@@ -487,6 +555,13 @@ def publish_e23_lab_instance(
include_recorded_media=False,
provenance={
"schema_version": "missioncore.e23-lab-publication/v1",
"method": _laboratory_method(
pipeline_id="inline-temporal-stability/v1",
execution_class="hybrid",
algorithm="warm-worker inline temporal stabilization",
profile_sha256=profile_sha256,
source_result_id=str(worker_document["result_id"]),
),
"storage_mode": "bounded-inline-worker-result-and-immutable-source-replay",
"worker_result_id": worker_document["result_id"],
"source_report_sha256": _sha256(source_path),
@@ -589,6 +664,13 @@ def publish_e24_lab_instance(
include_recorded_media=False,
provenance={
"schema_version": "missioncore.e24-lab-publication/v1",
"method": _laboratory_method(
pipeline_id="world-motion/v1",
execution_class="hybrid",
algorithm="world-frame motion tracking",
profile_sha256=build.profile_sha256,
source_result_id=source.result_id,
),
"storage_mode": "bounded-world-frame-tracking-and-immutable-source-replay",
"source_result_id": source.result_id,
"source_lab_session_id": (None if source_lab is None else source_lab.session_id),
@@ -695,6 +777,13 @@ def publish_e25_lab_instance(
include_recorded_media=False,
provenance={
"schema_version": "missioncore.e25-lab-publication/v1",
"method": _laboratory_method(
pipeline_id="persistent-support-motion/v1",
execution_class="hybrid",
algorithm="persistent occupied-support tracking",
profile_sha256=build.profile_sha256,
source_result_id=source.result_id,
),
"storage_mode": "bounded-persistent-support-and-immutable-source-replay",
"source_result_id": source.result_id,
"source_lab_session_id": (None if source_lab is None else source_lab.session_id),
@@ -826,6 +915,13 @@ def publish_e26_lab_instance(
include_recorded_media=False,
provenance={
"schema_version": "missioncore.e26-lab-publication/v1",
"method": _laboratory_method(
pipeline_id="camera-ego-motion-fusion/v1",
execution_class="hybrid",
algorithm="KB4 multiview ego-motion + persistent LiDAR support",
profile_sha256=build.profile_sha256,
source_result_id=lidar_source.result_id,
),
"storage_mode": (
"bounded-camera-ego-motion-and-immutable-lidar-source-replay"
),
@@ -1375,6 +1375,7 @@ def k1_local_surface_catalog_item(model: K1LocalSurfaceV1) -> dict[str, object]:
"status": model.report["status"],
"source": model.report["source"],
"surface_model": model.report["surface_model"],
"producer_sha256": model.identity["producer_sha256"],
"occupancy_policy": model.report["occupancy_policy"],
"metrics": model.report["metrics"],
"anchors": model.report["anchors"],
+86 -9
View File
@@ -11,7 +11,7 @@ import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any, cast
from typing import Any, Literal, cast
from uuid import uuid4
from k1link.artifacts import utc_now_iso
@@ -45,6 +45,8 @@ LAB_ARCHIVE_ID = "missioncore.lab-instances"
LAB_ORIGIN = "missioncore.lab-instance/v1"
LAB_ID_PATTERN = re.compile(r"^LAB [A-Z][A-Z0-9._-]{0,31}$")
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
LAB_METHOD_SCHEMA = "missioncore.laboratory-method/v1"
SessionScope = Literal["all", "source", "laboratory"]
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS observation_sessions (
@@ -205,27 +207,51 @@ class SessionStore:
connection.commit()
return tuple(imported)
def list_recent(self, *, limit: int = 20, cursor: str | None = None) -> SessionPage:
def list_recent(
self,
*,
limit: int = 20,
cursor: str | None = None,
scope: SessionScope = "all",
) -> SessionPage:
if not 1 <= limit <= 100:
raise ValueError("limit must be within 1..100")
scope_clause = {
"all": "1 = 1",
"source": (
"NOT EXISTS (SELECT 1 FROM observation_lab_instances AS lab "
"WHERE lab.session_id = sessions.session_id)"
),
"laboratory": (
"EXISTS (SELECT 1 FROM observation_lab_instances AS lab "
"WHERE lab.session_id = sessions.session_id)"
),
}.get(scope)
if scope_clause is None:
raise ValueError("scope must be all, source, or laboratory")
parameters: list[object] = []
where = ""
where = f"WHERE {scope_clause}" # noqa: S608 - closed static scope clauses
with self._connect() as connection:
if cursor is not None:
_validate_identifier(cursor, "session cursor")
cursor_row = connection.execute(
"SELECT started_at_utc, session_id FROM observation_sessions "
"WHERE session_id = ?",
"SELECT sessions.started_at_utc, sessions.session_id "
"FROM observation_sessions AS sessions "
f"WHERE {scope_clause} AND sessions.session_id = ?", # noqa: S608
(cursor,),
).fetchone()
if cursor_row is None:
raise SessionNotFoundError("observation session cursor was not found")
where = "WHERE (COALESCE(started_at_utc, ''), session_id) < (COALESCE(?, ''), ?)"
where += (
" AND (COALESCE(sessions.started_at_utc, ''), sessions.session_id) "
"< (COALESCE(?, ''), ?)"
)
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
parameters.append(limit + 1)
rows = connection.execute(
f"SELECT * FROM observation_sessions {where} " # noqa: S608 - static clause
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
f"SELECT sessions.* FROM observation_sessions AS sessions {where} " # noqa: S608
"ORDER BY COALESCE(sessions.started_at_utc, '') DESC, "
"sessions.session_id DESC LIMIT ?",
parameters,
).fetchall()
lab_rows = (
@@ -366,7 +392,9 @@ class SessionStore:
or duration_seconds <= 0
):
raise ValueError("LAB duration must be a positive finite value")
serialized_provenance = _serialize_provenance(provenance or {})
normalized_provenance = provenance or {}
_validate_lab_method(normalized_provenance)
serialized_provenance = _serialize_provenance(normalized_provenance)
published_at = utc_now_iso()
with self._lock, self._connect() as connection:
@@ -1078,6 +1106,55 @@ def _serialize_provenance(value: dict[str, Any]) -> str:
return serialized
def _validate_lab_method(provenance: dict[str, Any]) -> None:
method = provenance.get("method")
if not isinstance(method, dict):
raise ValueError("LAB provenance must include a method manifest")
if method.get("schema_version") != LAB_METHOD_SCHEMA:
raise ValueError("LAB method schema is invalid")
if method.get("completeness") not in {"complete", "legacy-partial"}:
raise ValueError("LAB method completeness is invalid")
if method.get("execution_class") not in {
"deterministic",
"ai-inference",
"hybrid",
}:
raise ValueError("LAB method execution class is invalid")
pipeline_id = method.get("pipeline_id")
if (
not isinstance(pipeline_id, str)
or not pipeline_id.strip()
or len(pipeline_id) > 160
):
raise ValueError("LAB method pipeline id is invalid")
components = method.get("components")
if not isinstance(components, list) or not 1 <= len(components) <= 32:
raise ValueError("LAB method components are invalid")
identities = 0
for component in components:
if not isinstance(component, dict):
raise ValueError("LAB method component is invalid")
if component.get("kind") not in {"source", "tool", "model", "algorithm", "runtime"}:
raise ValueError("LAB method component kind is invalid")
for field in ("name", "version", "role"):
value = component.get(field)
if not isinstance(value, str) or not value.strip() or len(value) > 240:
raise ValueError(f"LAB method component {field} is invalid")
identity = component.get("identity_sha256")
if identity is not None:
if not isinstance(identity, str) or SHA256_PATTERN.fullmatch(identity) is None:
raise ValueError("LAB method component identity is invalid")
identities += 1
if identities == 0:
raise ValueError("LAB method must bind at least one component identity")
if method["completeness"] == "complete" and any(
component.get("identity_sha256") is None
for component in components
if component.get("kind") in {"model", "algorithm"}
):
raise ValueError("complete LAB method must identify every model and algorithm")
def _require_utc_timestamp(value: str, field: str) -> None:
from datetime import datetime
+2 -1
View File
@@ -312,10 +312,11 @@ def build_session_router(
def list_observation_sessions(
limit: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None, max_length=128),
scope: Literal["all", "source", "laboratory"] = "all",
) -> dict[str, Any]:
try:
_refresh_catalog(catalog_refresher)
page = store.list_recent(limit=limit, cursor=cursor)
page = store.list_recent(limit=limit, cursor=cursor, scope=scope)
return {
"items": [
{
+29 -2
View File
@@ -43,6 +43,24 @@ from k1link.web.session_api import (
)
def lab_method() -> dict[str, object]:
return {
"schema_version": "missioncore.laboratory-method/v1",
"completeness": "complete",
"execution_class": "deterministic",
"pipeline_id": "test-pipeline/v1",
"components": [
{
"kind": "algorithm",
"name": "test algorithm",
"version": "v1",
"role": "contract fixture",
"identity_sha256": "9" * 64,
}
],
}
def make_legacy_session(sessions_root: Path, session_id: str) -> Path:
session = sessions_root / session_id
capture = session / "captures" / "mqtt_live"
@@ -281,17 +299,26 @@ def test_session_router_exposes_immutable_lab_provenance(tmp_path: Path) -> None
source_result_id="e21-realtime-envelope-" + "b" * 64,
config_sha256="c" * 64,
run_created_at_utc="2026-07-23T15:55:15.548Z",
provenance={"source_payloads_mutated": False},
provenance={
"source_payloads_mutated": False,
"method": lab_method(),
},
)
router = build_session_router(store)
list_route = endpoint(router, "/api/v1/observation-sessions", "GET")
detail_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "GET")
listing = list_route(limit=20, cursor=None)
listing = list_route(limit=20, cursor=None, scope="all")
item = next(value for value in listing["items"] if value["id"] == binding.session_id)
source_listing = list_route(limit=20, cursor=None, scope="source")
laboratory_listing = list_route(limit=20, cursor=None, scope="laboratory")
detail = detail_route(session_id=binding.session_id)
assert item["lab"] == binding.as_dict()
assert [value["id"] for value in source_listing["items"]] == [source.name]
assert [value["id"] for value in laboratory_listing["items"]] == [
binding.session_id
]
assert detail["lab"] == binding.as_dict()
assert item["lab"]["source_session_id"] == source.name
assert item["lab"]["provenance"]["source_payloads_mutated"] is False
+53 -3
View File
@@ -23,6 +23,24 @@ from k1link.sessions import (
)
def lab_method() -> dict[str, object]:
return {
"schema_version": "missioncore.laboratory-method/v1",
"completeness": "complete",
"execution_class": "deterministic",
"pipeline_id": "test-pipeline/v1",
"components": [
{
"kind": "algorithm",
"name": "test algorithm",
"version": "v1",
"role": "contract fixture",
"identity_sha256": "9" * 64,
}
],
}
def make_legacy_session(
sessions_root: Path,
session_id: str,
@@ -798,7 +816,10 @@ def test_lab_instance_is_independent_and_never_deletes_source_evidence(
source_result_id="e21-realtime-envelope-" + "b" * 64,
config_sha256="c" * 64,
run_created_at_utc="2026-07-23T15:51:25.000Z",
provenance={"storage_mode": "hard-linked-immutable-payloads"},
provenance={
"storage_mode": "hard-linked-immutable-payloads",
"method": lab_method(),
},
)
detail = store.get_session(binding.session_id)
@@ -809,6 +830,13 @@ def test_lab_instance_is_independent_and_never_deletes_source_evidence(
assert lab_command.session_id == binding.session_id
assert source.is_dir()
assert [item.session_id for item in store.list_recent(scope="source").items] == [
source.name
]
assert [
item.session_id for item in store.list_recent(scope="laboratory").items
] == [binding.session_id]
with pytest.raises(SessionIntegrityError, match="has LAB instances"):
store.delete_session(source.name)
assert source.is_dir()
@@ -838,7 +866,7 @@ def test_lab_instance_publication_is_idempotent_but_provenance_is_immutable(
"source_result_id": "e10-integrated-perception-" + "e" * 64,
"config_sha256": "f" * 64,
"run_created_at_utc": "2026-07-23T05:19:43.138Z",
"provenance": {"source": "accepted"},
"provenance": {"source": "accepted", "method": lab_method()},
}
first = store.publish_lab_instance(**parameters)
@@ -849,6 +877,28 @@ def test_lab_instance_publication_is_idempotent_but_provenance_is_immutable(
store.publish_lab_instance(**{**parameters, "config_sha256": "0" * 64})
def test_lab_instance_rejects_publication_without_a_method_manifest(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
with pytest.raises(ValueError, match="method manifest"):
store.publish_lab_instance(
session_id="lab-without-method",
source_session_id=source.name,
display_name="LAB E30 · incomplete method",
lab_id="LAB E30",
result_kind="e30-test",
result_id="e30-test",
run_created_at_utc="2026-07-26T15:00:00Z",
provenance={"schema_version": "legacy"},
)
def test_bounded_lab_instance_excludes_unbounded_recorded_media(
tmp_path: Path,
) -> None:
@@ -872,7 +922,7 @@ def test_bounded_lab_instance_excludes_unbounded_recorded_media(
run_created_at_utc="2026-07-23T15:55:15.548Z",
duration_seconds=59.962,
include_recorded_media=False,
provenance={"timeline_scope": "bounded"},
provenance={"timeline_scope": "bounded", "method": lab_method()},
)
detail = store.get_session(binding.session_id)