feat(lab): separate evidence catalogs and record methods
This commit is contained in:
@@ -122,6 +122,7 @@ export function ObservationSessionSelect({
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||||
const sessions = useObservationSessions({
|
const sessions = useObservationSessions({
|
||||||
limit,
|
limit,
|
||||||
|
scope: "source",
|
||||||
replayEnabled: blockedReason === null,
|
replayEnabled: blockedReason === null,
|
||||||
onReplayBegin,
|
onReplayBegin,
|
||||||
onReplayAccepted,
|
onReplayAccepted,
|
||||||
@@ -321,14 +322,13 @@ export function ObservationSessionArchive({
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||||
const sessions = useObservationSessions({
|
const sessions = useObservationSessions({
|
||||||
limit,
|
limit,
|
||||||
|
scope: labsOnly ? "laboratory" : "source",
|
||||||
replayEnabled: blockedReason === null,
|
replayEnabled: blockedReason === null,
|
||||||
onReplayBegin,
|
onReplayBegin,
|
||||||
onReplayAccepted,
|
onReplayAccepted,
|
||||||
onReplaySettled,
|
onReplaySettled,
|
||||||
});
|
});
|
||||||
const items = labsOnly
|
const items = sessions.items;
|
||||||
? sessions.items.filter((session) => session.lab !== null)
|
|
||||||
: sessions.items;
|
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface E29EvidenceResult {
|
|||||||
frameCount: number;
|
frameCount: number;
|
||||||
timelineStartSeconds: number;
|
timelineStartSeconds: number;
|
||||||
timelineEndSeconds: number;
|
timelineEndSeconds: number;
|
||||||
|
profileId: string;
|
||||||
|
producerSha256: string;
|
||||||
};
|
};
|
||||||
metrics: {
|
metrics: {
|
||||||
frames: {
|
frames: {
|
||||||
@@ -208,6 +210,7 @@ function parseReviewFrame(value: unknown): E29ReviewFrame {
|
|||||||
function parseEvidenceResult(value: unknown): E29EvidenceResult {
|
function parseEvidenceResult(value: unknown): E29EvidenceResult {
|
||||||
const source = record(value, "E29 result");
|
const source = record(value, "E29 result");
|
||||||
const identity = record(source.identity, "E29 identity");
|
const identity = record(source.identity, "E29 identity");
|
||||||
|
const profile = record(identity.profile, "E29 identity.profile");
|
||||||
const metrics = record(source.metrics, "E29 metrics");
|
const metrics = record(source.metrics, "E29 metrics");
|
||||||
const frames = record(metrics.frames, "E29 metrics.frames");
|
const frames = record(metrics.frames, "E29 metrics.frames");
|
||||||
const semantic = record(
|
const semantic = record(
|
||||||
@@ -253,6 +256,11 @@ function parseEvidenceResult(value: unknown): E29EvidenceResult {
|
|||||||
identity.timeline_end_seconds,
|
identity.timeline_end_seconds,
|
||||||
"E29 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: {
|
metrics: {
|
||||||
frames: {
|
frames: {
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export interface LidarLocalSurfaceModel {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
sourcePackId: string;
|
sourcePackId: string;
|
||||||
status: "diagnostic-only";
|
status: "diagnostic-only";
|
||||||
|
method: {
|
||||||
|
executionClass: "deterministic";
|
||||||
|
pipelineId: string;
|
||||||
|
algorithm: string;
|
||||||
|
producerSha256: string;
|
||||||
|
};
|
||||||
source: {
|
source: {
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
availableLidarFrames: number;
|
availableLidarFrames: number;
|
||||||
@@ -562,6 +568,8 @@ function temporalQualification(
|
|||||||
function model(value: unknown): LidarLocalSurfaceModel {
|
function model(value: unknown): LidarLocalSurfaceModel {
|
||||||
const source = record(value, "LiDAR local-surface model");
|
const source = record(value, "LiDAR local-surface model");
|
||||||
const sourceEvidence = record(source.source, "source");
|
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 metrics = record(source.metrics, "metrics");
|
||||||
const frames = record(metrics.frames, "metrics.frames");
|
const frames = record(metrics.frames, "metrics.frames");
|
||||||
if (
|
if (
|
||||||
@@ -611,6 +619,19 @@ function model(value: unknown): LidarLocalSurfaceModel {
|
|||||||
sessionId: text(source.session_id, "session_id", SAFE_ID),
|
sessionId: text(source.session_id, "session_id", SAFE_ID),
|
||||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||||
status: "diagnostic-only",
|
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: {
|
source: {
|
||||||
frameCount: integer(sourceEvidence.frame_count, "source.frame_count"),
|
frameCount: integer(sourceEvidence.frame_count, "source.frame_count"),
|
||||||
availableLidarFrames: integer(
|
availableLidarFrames: integer(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type ObservationSessionStatus =
|
|||||||
| "interrupted"
|
| "interrupted"
|
||||||
| "failed";
|
| "failed";
|
||||||
|
|
||||||
|
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
||||||
|
|
||||||
export interface ObservationLabInstance {
|
export interface ObservationLabInstance {
|
||||||
labId: string;
|
labId: string;
|
||||||
sourceSessionId: string;
|
sourceSessionId: string;
|
||||||
@@ -1077,15 +1079,21 @@ function requirePreparationEtag(
|
|||||||
export async function fetchObservationSessionCatalog({
|
export async function fetchObservationSessionCatalog({
|
||||||
signal,
|
signal,
|
||||||
limit,
|
limit,
|
||||||
|
scope = "all",
|
||||||
fetcher = globalThis.fetch,
|
fetcher = globalThis.fetch,
|
||||||
}: {
|
}: {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
scope?: ObservationSessionScope;
|
||||||
fetcher?: ObservationSessionFetch;
|
fetcher?: ObservationSessionFetch;
|
||||||
} = {}): Promise<ObservationSessionCatalog> {
|
} = {}): Promise<ObservationSessionCatalog> {
|
||||||
const query = Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= 100
|
const queryParameters = new URLSearchParams();
|
||||||
? `?limit=${Number(limit)}`
|
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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetcher(`/api/v1/observation-sessions${query}`, {
|
response = await fetcher(`/api/v1/observation-sessions${query}`, {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ObservationSessionFetch,
|
type ObservationSessionFetch,
|
||||||
type ObservationSessionPreparation,
|
type ObservationSessionPreparation,
|
||||||
type ObservationSessionReplayLaunch,
|
type ObservationSessionReplayLaunch,
|
||||||
|
type ObservationSessionScope,
|
||||||
type ObservationSessionSummary,
|
type ObservationSessionSummary,
|
||||||
} from "./sessionArchive";
|
} from "./sessionArchive";
|
||||||
|
|
||||||
@@ -369,12 +370,14 @@ export function clearObservationReplayPreparation(
|
|||||||
|
|
||||||
export function useObservationSessions({
|
export function useObservationSessions({
|
||||||
limit = 100,
|
limit = 100,
|
||||||
|
scope = "all",
|
||||||
replayEnabled = true,
|
replayEnabled = true,
|
||||||
onReplayBegin,
|
onReplayBegin,
|
||||||
onReplayAccepted,
|
onReplayAccepted,
|
||||||
onReplaySettled,
|
onReplaySettled,
|
||||||
}: {
|
}: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
scope?: ObservationSessionScope;
|
||||||
replayEnabled?: boolean;
|
replayEnabled?: boolean;
|
||||||
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
||||||
onReplayBegin?: (
|
onReplayBegin?: (
|
||||||
@@ -425,7 +428,10 @@ export function useObservationSessions({
|
|||||||
const sequence = ++catalogSequence.current;
|
const sequence = ++catalogSequence.current;
|
||||||
if (foreground) setState("loading");
|
if (foreground) setState("loading");
|
||||||
try {
|
try {
|
||||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
const catalog = await fetchObservationSessionCatalog({
|
||||||
|
limit: safeLimit,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||||
setItems(catalog.items.slice(0, safeLimit));
|
setItems(catalog.items.slice(0, safeLimit));
|
||||||
setState("ready");
|
setState("ready");
|
||||||
@@ -437,7 +443,7 @@ export function useObservationSessions({
|
|||||||
setError(errorMessage(loadError));
|
setError(errorMessage(loadError));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [safeLimit]);
|
}, [safeLimit, scope]);
|
||||||
|
|
||||||
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
||||||
|
|
||||||
|
|||||||
@@ -2466,6 +2466,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task,
|
.laboratory-task,
|
||||||
|
.laboratory-method,
|
||||||
.laboratory-result-summary {
|
.laboratory-result-summary {
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: rgb(255 255 255 / 0.025);
|
background: rgb(255 255 255 / 0.025);
|
||||||
@@ -2473,6 +2474,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task > header,
|
.laboratory-task > header,
|
||||||
|
.laboratory-method > header,
|
||||||
.laboratory-result-summary > header {
|
.laboratory-result-summary > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -2483,12 +2485,16 @@
|
|||||||
.laboratory-task h2,
|
.laboratory-task h2,
|
||||||
.laboratory-task p,
|
.laboratory-task p,
|
||||||
.laboratory-task dl,
|
.laboratory-task dl,
|
||||||
|
.laboratory-method h2,
|
||||||
|
.laboratory-method p,
|
||||||
|
.laboratory-method ul,
|
||||||
.laboratory-result-summary h2,
|
.laboratory-result-summary h2,
|
||||||
.laboratory-result-summary p {
|
.laboratory-result-summary p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task h2,
|
.laboratory-task h2,
|
||||||
|
.laboratory-method h2,
|
||||||
.laboratory-result-summary h2 {
|
.laboratory-result-summary h2 {
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
@@ -2497,6 +2503,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-task p,
|
.laboratory-task p,
|
||||||
|
.laboratory-method p,
|
||||||
.laboratory-result-summary > p {
|
.laboratory-result-summary > p {
|
||||||
max-width: 66rem;
|
max-width: 66rem;
|
||||||
margin-top: 0.38rem;
|
margin-top: 0.38rem;
|
||||||
@@ -2505,6 +2512,75 @@
|
|||||||
line-height: 1.55;
|
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 {
|
.laboratory-task dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -1204,6 +1204,117 @@ interface LaboratoryOption<T extends string> {
|
|||||||
label: 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>({
|
function LaboratorySelector<T extends string>({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
@@ -1285,17 +1396,20 @@ function LaboratoryTask({
|
|||||||
function LaboratoryEvidence({
|
function LaboratoryEvidence({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
|
kind,
|
||||||
resizable = false,
|
resizable = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
kind: LaboratoryEvidenceKind;
|
||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="lab-result-surface"
|
className="lab-result-surface"
|
||||||
|
data-evidence-kind={kind}
|
||||||
data-resizable={resizable ? "true" : undefined}
|
data-resizable={resizable ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<header>
|
<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({
|
function LaboratoryWorkTemplate({
|
||||||
task,
|
task,
|
||||||
|
method,
|
||||||
evidence,
|
evidence,
|
||||||
result = null,
|
result = null,
|
||||||
details = null,
|
details = null,
|
||||||
}: {
|
}: {
|
||||||
task: ReactNode;
|
task: ReactNode;
|
||||||
|
method: ReactNode;
|
||||||
evidence: ReactNode;
|
evidence: ReactNode;
|
||||||
result?: ReactNode;
|
result?: ReactNode;
|
||||||
details?: ReactNode;
|
details?: ReactNode;
|
||||||
@@ -1323,6 +1493,7 @@ function LaboratoryWorkTemplate({
|
|||||||
return (
|
return (
|
||||||
<div className="laboratory-work-template">
|
<div className="laboratory-work-template">
|
||||||
{task}
|
{task}
|
||||||
|
{method}
|
||||||
{evidence}
|
{evidence}
|
||||||
{result}
|
{result}
|
||||||
{details}
|
{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={(
|
evidence={(
|
||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="ИСХОДНЫЕ ДАННЫЕ"
|
eyebrow="ИСХОДНЫЕ ДАННЫЕ"
|
||||||
title="LiDAR, траектория и камера RAVNOVES00"
|
title="LiDAR, траектория и камера RAVNOVES00"
|
||||||
|
kind="recorded-replay"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
{replayReady ? (
|
{replayReady ? (
|
||||||
@@ -1592,10 +1798,14 @@ function PublishedLaboratoryResult({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
method={(
|
||||||
|
<LaboratoryMethodCard method={publishedLaboratoryMethod(session)} />
|
||||||
|
)}
|
||||||
evidence={(
|
evidence={(
|
||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||||
title="Исходная запись выбранной лабораторной работы"
|
title="Исходная запись выбранной лабораторной работы"
|
||||||
|
kind="recorded-replay"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
{replayReady ? (
|
{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={(
|
evidence={(
|
||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||||
title="Диагностическая поверхность и кадры LAB E28"
|
title="Диагностическая поверхность и кадры LAB E28"
|
||||||
|
kind="diagnostic-model"
|
||||||
>
|
>
|
||||||
<LidarQualityWorkspace
|
<LidarQualityWorkspace
|
||||||
embedded
|
embedded
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ function result(overrides = {}) {
|
|||||||
frame_count: 4489,
|
frame_count: 4489,
|
||||||
timeline_start_seconds: 35.4,
|
timeline_start_seconds: 35.4,
|
||||||
timeline_end_seconds: 484.0,
|
timeline_end_seconds: 484.0,
|
||||||
|
profile: {
|
||||||
|
profile_id: "camera-first-local-surface-validation/v1",
|
||||||
|
},
|
||||||
|
producer_sha256: "e".repeat(64),
|
||||||
},
|
},
|
||||||
metrics: {
|
metrics: {
|
||||||
frames: {
|
frames: {
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ function model(overrides = {}) {
|
|||||||
timeline_start_seconds: 1,
|
timeline_start_seconds: 1,
|
||||||
timeline_end_seconds: 2,
|
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(),
|
occupancy_policy: policy(),
|
||||||
metrics: {
|
metrics: {
|
||||||
frames: {
|
frames: {
|
||||||
|
|||||||
@@ -197,6 +197,25 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
|||||||
assert.match(workspaceSource, /e29-camera-geometry/);
|
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", () => {
|
test("session catalog exposes authoritative background preparation state", () => {
|
||||||
const catalog = decodeObservationSessionCatalog({
|
const catalog = decodeObservationSessionCatalog({
|
||||||
items: [session({
|
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 LaboratoryWorkTemplate\(/);
|
||||||
assert.match(source, /function LaboratoryEvidence\(/);
|
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(source, /data-viewer-focused=/);
|
||||||
assert.match(css, /height:\s*clamp\(42rem,\s*68vh,\s*58rem\)/);
|
assert.match(css, /height:\s*clamp\(42rem,\s*68vh,\s*58rem\)/);
|
||||||
assert.match(css, /resize:\s*vertical/);
|
assert.match(css, /resize:\s*vertical/);
|
||||||
|
|||||||
@@ -83,6 +83,53 @@ class PublishedCameraEgoMotionLabInstance:
|
|||||||
build: CameraEgoMotionBuild
|
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(
|
def publish_integrated_lab_instance(
|
||||||
*,
|
*,
|
||||||
repository_root: Path,
|
repository_root: Path,
|
||||||
@@ -156,6 +203,13 @@ def publish_integrated_lab_instance(
|
|||||||
run_created_at_utc=source.created_at_utc,
|
run_created_at_utc=source.created_at_utc,
|
||||||
provenance={
|
provenance={
|
||||||
"schema_version": "missioncore.integrated-lab-publication/v1",
|
"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",
|
"storage_mode": "hard-linked-immutable-payloads",
|
||||||
"source_job_id": source.job.job_id,
|
"source_job_id": source.job.job_id,
|
||||||
"projected_job_id": lab_job.job_id,
|
"projected_job_id": lab_job.job_id,
|
||||||
@@ -263,6 +317,13 @@ def publish_e21_lab_instance(
|
|||||||
include_recorded_media=False,
|
include_recorded_media=False,
|
||||||
provenance={
|
provenance={
|
||||||
"schema_version": "missioncore.e21-lab-publication/v1",
|
"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",
|
"storage_mode": "bounded-derived-replay-and-projection",
|
||||||
"e21_result_id": e21_document["result_id"],
|
"e21_result_id": e21_document["result_id"],
|
||||||
"worker_result_id": worker_document["result_id"],
|
"worker_result_id": worker_document["result_id"],
|
||||||
@@ -361,6 +422,13 @@ def publish_e22_lab_instance(
|
|||||||
include_recorded_media=False,
|
include_recorded_media=False,
|
||||||
provenance={
|
provenance={
|
||||||
"schema_version": "missioncore.e22-lab-publication/v1",
|
"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",
|
"storage_mode": "bounded-derived-replay-and-temporal-projection",
|
||||||
"source_result_id": source.result_id,
|
"source_result_id": source.result_id,
|
||||||
"source_lab_session_id": (None if source_lab is None else source_lab.session_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,
|
include_recorded_media=False,
|
||||||
provenance={
|
provenance={
|
||||||
"schema_version": "missioncore.e23-lab-publication/v1",
|
"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",
|
"storage_mode": "bounded-inline-worker-result-and-immutable-source-replay",
|
||||||
"worker_result_id": worker_document["result_id"],
|
"worker_result_id": worker_document["result_id"],
|
||||||
"source_report_sha256": _sha256(source_path),
|
"source_report_sha256": _sha256(source_path),
|
||||||
@@ -589,6 +664,13 @@ def publish_e24_lab_instance(
|
|||||||
include_recorded_media=False,
|
include_recorded_media=False,
|
||||||
provenance={
|
provenance={
|
||||||
"schema_version": "missioncore.e24-lab-publication/v1",
|
"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",
|
"storage_mode": "bounded-world-frame-tracking-and-immutable-source-replay",
|
||||||
"source_result_id": source.result_id,
|
"source_result_id": source.result_id,
|
||||||
"source_lab_session_id": (None if source_lab is None else source_lab.session_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,
|
include_recorded_media=False,
|
||||||
provenance={
|
provenance={
|
||||||
"schema_version": "missioncore.e25-lab-publication/v1",
|
"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",
|
"storage_mode": "bounded-persistent-support-and-immutable-source-replay",
|
||||||
"source_result_id": source.result_id,
|
"source_result_id": source.result_id,
|
||||||
"source_lab_session_id": (None if source_lab is None else source_lab.session_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,
|
include_recorded_media=False,
|
||||||
provenance={
|
provenance={
|
||||||
"schema_version": "missioncore.e26-lab-publication/v1",
|
"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": (
|
"storage_mode": (
|
||||||
"bounded-camera-ego-motion-and-immutable-lidar-source-replay"
|
"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"],
|
"status": model.report["status"],
|
||||||
"source": model.report["source"],
|
"source": model.report["source"],
|
||||||
"surface_model": model.report["surface_model"],
|
"surface_model": model.report["surface_model"],
|
||||||
|
"producer_sha256": model.identity["producer_sha256"],
|
||||||
"occupancy_policy": model.report["occupancy_policy"],
|
"occupancy_policy": model.report["occupancy_policy"],
|
||||||
"metrics": model.report["metrics"],
|
"metrics": model.report["metrics"],
|
||||||
"anchors": model.report["anchors"],
|
"anchors": model.report["anchors"],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import threading
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, Literal, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from k1link.artifacts import utc_now_iso
|
from k1link.artifacts import utc_now_iso
|
||||||
@@ -45,6 +45,8 @@ LAB_ARCHIVE_ID = "missioncore.lab-instances"
|
|||||||
LAB_ORIGIN = "missioncore.lab-instance/v1"
|
LAB_ORIGIN = "missioncore.lab-instance/v1"
|
||||||
LAB_ID_PATTERN = re.compile(r"^LAB [A-Z][A-Z0-9._-]{0,31}$")
|
LAB_ID_PATTERN = re.compile(r"^LAB [A-Z][A-Z0-9._-]{0,31}$")
|
||||||
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
LAB_METHOD_SCHEMA = "missioncore.laboratory-method/v1"
|
||||||
|
SessionScope = Literal["all", "source", "laboratory"]
|
||||||
|
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS observation_sessions (
|
CREATE TABLE IF NOT EXISTS observation_sessions (
|
||||||
@@ -205,27 +207,51 @@ class SessionStore:
|
|||||||
connection.commit()
|
connection.commit()
|
||||||
return tuple(imported)
|
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:
|
if not 1 <= limit <= 100:
|
||||||
raise ValueError("limit must be within 1..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] = []
|
parameters: list[object] = []
|
||||||
where = ""
|
where = f"WHERE {scope_clause}" # noqa: S608 - closed static scope clauses
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
_validate_identifier(cursor, "session cursor")
|
_validate_identifier(cursor, "session cursor")
|
||||||
cursor_row = connection.execute(
|
cursor_row = connection.execute(
|
||||||
"SELECT started_at_utc, session_id FROM observation_sessions "
|
"SELECT sessions.started_at_utc, sessions.session_id "
|
||||||
"WHERE session_id = ?",
|
"FROM observation_sessions AS sessions "
|
||||||
|
f"WHERE {scope_clause} AND sessions.session_id = ?", # noqa: S608
|
||||||
(cursor,),
|
(cursor,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if cursor_row is None:
|
if cursor_row is None:
|
||||||
raise SessionNotFoundError("observation session cursor was not found")
|
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.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
|
||||||
parameters.append(limit + 1)
|
parameters.append(limit + 1)
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
f"SELECT * FROM observation_sessions {where} " # noqa: S608 - static clause
|
f"SELECT sessions.* FROM observation_sessions AS sessions {where} " # noqa: S608
|
||||||
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
|
"ORDER BY COALESCE(sessions.started_at_utc, '') DESC, "
|
||||||
|
"sessions.session_id DESC LIMIT ?",
|
||||||
parameters,
|
parameters,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
lab_rows = (
|
lab_rows = (
|
||||||
@@ -366,7 +392,9 @@ class SessionStore:
|
|||||||
or duration_seconds <= 0
|
or duration_seconds <= 0
|
||||||
):
|
):
|
||||||
raise ValueError("LAB duration must be a positive finite value")
|
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()
|
published_at = utc_now_iso()
|
||||||
|
|
||||||
with self._lock, self._connect() as connection:
|
with self._lock, self._connect() as connection:
|
||||||
@@ -1078,6 +1106,55 @@ def _serialize_provenance(value: dict[str, Any]) -> str:
|
|||||||
return serialized
|
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:
|
def _require_utc_timestamp(value: str, field: str) -> None:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|||||||
@@ -312,10 +312,11 @@ def build_session_router(
|
|||||||
def list_observation_sessions(
|
def list_observation_sessions(
|
||||||
limit: int = Query(default=20, ge=1, le=100),
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
cursor: str | None = Query(default=None, max_length=128),
|
cursor: str | None = Query(default=None, max_length=128),
|
||||||
|
scope: Literal["all", "source", "laboratory"] = "all",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
_refresh_catalog(catalog_refresher)
|
_refresh_catalog(catalog_refresher)
|
||||||
page = store.list_recent(limit=limit, cursor=cursor)
|
page = store.list_recent(limit=limit, cursor=cursor, scope=scope)
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
def make_legacy_session(sessions_root: Path, session_id: str) -> Path:
|
||||||
session = sessions_root / session_id
|
session = sessions_root / session_id
|
||||||
capture = session / "captures" / "mqtt_live"
|
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,
|
source_result_id="e21-realtime-envelope-" + "b" * 64,
|
||||||
config_sha256="c" * 64,
|
config_sha256="c" * 64,
|
||||||
run_created_at_utc="2026-07-23T15:55:15.548Z",
|
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)
|
router = build_session_router(store)
|
||||||
list_route = endpoint(router, "/api/v1/observation-sessions", "GET")
|
list_route = endpoint(router, "/api/v1/observation-sessions", "GET")
|
||||||
detail_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "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)
|
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)
|
detail = detail_route(session_id=binding.session_id)
|
||||||
|
|
||||||
assert item["lab"] == binding.as_dict()
|
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 detail["lab"] == binding.as_dict()
|
||||||
assert item["lab"]["source_session_id"] == source.name
|
assert item["lab"]["source_session_id"] == source.name
|
||||||
assert item["lab"]["provenance"]["source_payloads_mutated"] is False
|
assert item["lab"]["provenance"]["source_payloads_mutated"] is False
|
||||||
|
|||||||
@@ -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(
|
def make_legacy_session(
|
||||||
sessions_root: Path,
|
sessions_root: Path,
|
||||||
session_id: str,
|
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,
|
source_result_id="e21-realtime-envelope-" + "b" * 64,
|
||||||
config_sha256="c" * 64,
|
config_sha256="c" * 64,
|
||||||
run_created_at_utc="2026-07-23T15:51:25.000Z",
|
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)
|
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 lab_command.session_id == binding.session_id
|
||||||
assert source.is_dir()
|
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"):
|
with pytest.raises(SessionIntegrityError, match="has LAB instances"):
|
||||||
store.delete_session(source.name)
|
store.delete_session(source.name)
|
||||||
assert source.is_dir()
|
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,
|
"source_result_id": "e10-integrated-perception-" + "e" * 64,
|
||||||
"config_sha256": "f" * 64,
|
"config_sha256": "f" * 64,
|
||||||
"run_created_at_utc": "2026-07-23T05:19:43.138Z",
|
"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)
|
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})
|
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(
|
def test_bounded_lab_instance_excludes_unbounded_recorded_media(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -872,7 +922,7 @@ def test_bounded_lab_instance_excludes_unbounded_recorded_media(
|
|||||||
run_created_at_utc="2026-07-23T15:55:15.548Z",
|
run_created_at_utc="2026-07-23T15:55:15.548Z",
|
||||||
duration_seconds=59.962,
|
duration_seconds=59.962,
|
||||||
include_recorded_media=False,
|
include_recorded_media=False,
|
||||||
provenance={"timeline_scope": "bounded"},
|
provenance={"timeline_scope": "bounded", "method": lab_method()},
|
||||||
)
|
)
|
||||||
|
|
||||||
detail = store.get_session(binding.session_id)
|
detail = store.get_session(binding.session_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user