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/);