diff --git a/apps/control-station/src/components/ObservationSessionSelect.tsx b/apps/control-station/src/components/ObservationSessionSelect.tsx index 1d0bbd6..f030c71 100644 --- a/apps/control-station/src/components/ObservationSessionSelect.tsx +++ b/apps/control-station/src/components/ObservationSessionSelect.tsx @@ -122,6 +122,7 @@ export function ObservationSessionSelect({ const [deleteTarget, setDeleteTarget] = useState(null); const sessions = useObservationSessions({ limit, + scope: "source", replayEnabled: blockedReason === null, onReplayBegin, onReplayAccepted, @@ -321,14 +322,13 @@ export function ObservationSessionArchive({ const [deleteTarget, setDeleteTarget] = useState(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 <>
{ - 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}`, { diff --git a/apps/control-station/src/core/observation/useObservationSessions.ts b/apps/control-station/src/core/observation/useObservationSessions.ts index 5fa1400..3b9a532 100644 --- a/apps/control-station/src/core/observation/useObservationSessions.ts +++ b/apps/control-station/src/core/observation/useObservationSessions.ts @@ -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]); diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index ee6d16d..fef09fe 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -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)); diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 5bc1318..a2f5ccf 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -1204,6 +1204,117 @@ interface LaboratoryOption { 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; + 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; + 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 = { + "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({ 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 (
@@ -1309,13 +1423,69 @@ function LaboratoryEvidence({ ); } +function LaboratoryMethodCard({ method }: { method: LaboratoryMethod }) { + const complete = method.completeness === "complete"; + const executionLabels: Record = { + deterministic: "Детерминированный", + "ai-inference": "AI inference", + hybrid: "Гибридный", + }; + return ( +
+
+
+ МЕТОД И ВОСПРОИЗВОДИМОСТЬ +

{method.pipelineId}

+

+ Зафиксированы вычислительный класс, инструменты, модели и алгоритмы. + {complete + ? " Идентичности достаточны для повторного запуска." + : " Это legacy-прогон: отсутствующие исторические версии не восстановлены задним числом."} +

+
+ + {complete ? "Метод полный" : "Legacy · частично"} + +
+
+
+ Класс вычисления + {executionLabels[method.executionClass]} +
+
+ Компонентов + {method.components.length} +
+
+
    + {method.components.map((component, index) => ( +
  • + {component.kind} +
    + {component.name} + {component.role} · {component.version} +
    + + {component.identitySha256 + ? component.identitySha256.slice(0, 12) + : "identity не зафиксирована"} + +
  • + ))} +
+
+ ); +} + 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 (
{task} + {method} {evidence} {result} {details} @@ -1406,10 +1577,45 @@ function E29LaboratoryResult({ ]} /> )} + method={( + + )} evidence={( {replayReady ? ( @@ -1592,10 +1798,14 @@ function PublishedLaboratoryResult({ ]} /> )} + method={( + + )} evidence={( {replayReady ? ( @@ -1900,10 +2110,43 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) { ]} /> )} + method={( + + )} evidence={( { + 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({ diff --git a/apps/control-station/test/productShellContract.test.mjs b/apps/control-station/test/productShellContract.test.mjs index 3bc48ac..98520ca 100644 --- a/apps/control-station/test/productShellContract.test.mjs +++ b/apps/control-station/test/productShellContract.test.mjs @@ -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/); diff --git a/src/k1link/compute/lab_instances.py b/src/k1link/compute/lab_instances.py index b8ca4bd..604807d 100644 --- a/src/k1link/compute/lab_instances.py +++ b/src/k1link/compute/lab_instances.py @@ -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" ), diff --git a/src/k1link/compute/lidar_local_surface.py b/src/k1link/compute/lidar_local_surface.py index 191ef36..209ff1c 100644 --- a/src/k1link/compute/lidar_local_surface.py +++ b/src/k1link/compute/lidar_local_surface.py @@ -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"], diff --git a/src/k1link/sessions/store.py b/src/k1link/sessions/store.py index 9c9289d..e4f59e0 100644 --- a/src/k1link/sessions/store.py +++ b/src/k1link/sessions/store.py @@ -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 diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index 79a9b1c..1650ecf 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -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": [ { diff --git a/tests/test_session_api.py b/tests/test_session_api.py index eabf247..12ea5a8 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -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 diff --git a/tests/test_session_store.py b/tests/test_session_store.py index 5c64c81..b5e6579 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -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)