feat(lab): canonize versioned report template

This commit is contained in:
DCCONSTRUCTIONS
2026-07-27 16:05:32 +03:00
parent 2c016b117d
commit 95c6540691
20 changed files with 582 additions and 128 deletions
+31 -6
View File
@@ -116,10 +116,32 @@ dashboard, and immutable LAB review.
## Laboratory product contract
Use one template:
Use the executable template
`missioncore.laboratory-report/v1`:
`selectors → compact summary → evidence → result → optional reusable details`
For every new bounded `workspaces/laboratory/ENNResult.tsx`:
1. Pass typed values into `LaboratorySummary`; always supply `question`,
`approach`, `principalResult`, and `limitation`.
2. Put visual evidence in `LaboratoryEvidence`.
3. Pass result metrics and `proved`, `notProved`, and `decision` into
`LaboratoryResultSummary`.
4. Do not render the `laboratory-summary`, `laboratory-result-summary`,
`laboratory-result-metrics`, or `laboratory-result-conclusion` classes in the
LAB module.
5. Do not change slot order or shared report geometry for one run.
Treat a missing field as missing product evidence, not as permission to omit
the section. An admitted diagnostic viewer may own the result interaction only
when the existing canon explicitly identifies that viewer as the result
instrument.
Changing the v1 anatomy requires product-owner agreement, a new template
version, migration of every consumer, updated canon, and an updated
architecture test. Never drift v1 incrementally from a single LAB.
The compact summary must cover:
- decision question and purpose;
@@ -178,12 +200,15 @@ Before handoff:
1. Search the changed product UI for raw local controls, hard-coded product
colors, temporary copy, gate/checklist UI, and per-LAB page branches.
2. For a new surface, verify the product-surface brief names the user job,
2. Verify every changed or new bounded `ENNResult.tsx` uses
`LaboratorySummary`, `LaboratoryEvidence`, and `LaboratoryResultSummary`
without raw canonical report markup.
3. For a new surface, verify the product-surface brief names the user job,
selected placement, rejected alternative, state grammar, and real acceptance
evidence.
3. Run `test/applicationArchitecture.test.mjs`, then the full frontend
4. Run `test/applicationArchitecture.test.mjs`, then the full frontend
typecheck, unit tests, and production build.
4. Use the in-app browser to verify normal and expanded modes, every new view
5. Use the in-app browser to verify normal and expanded modes, every new view
mode, keyboard Escape, and the canonical control states.
5. Verify the default composition answers the primary operator question.
6. Leave a real evidence case open for product-owner review.
6. Verify the default composition answers the primary operator question.
7. Leave a real evidence case open for product-owner review.
+5
View File
@@ -95,6 +95,11 @@ decoders before BLE/Wi-Fi/data-session evidence exists.
- Use one fixed laboratory presentation contract: selectors, one compact
canonical summary, admitted evidence viewer(s), result, and optional reusable
technical details.
- Keep `missioncore.laboratory-report/v1` executable: bounded `ENNResult.tsx`
modules supply all four `LaboratorySummary` brief fields and use
`LaboratoryResultSummary` for metrics plus proved/not-proved/decision. They
never own canonical summary/result markup. Anatomy changes require a new
version and product-owner agreement.
- The compact summary must explain the decision question, immutable source,
tested method/models/algorithms, experimental mode, principal result,
limitations, and retained authority. It is not the engineering report.
@@ -31,6 +31,28 @@ export interface LaboratoryMethod {
components: readonly LaboratoryMethodComponent[];
}
export interface LaboratoryBrief {
question: string;
approach: string;
principalResult: string;
limitation: string;
}
export interface LaboratoryResultMetric {
label: string;
value: string;
hint: string;
}
export interface LaboratoryResultConclusion {
proved: string;
notProved: string;
decision: string;
}
export const LABORATORY_REPORT_TEMPLATE_VERSION =
"missioncore.laboratory-report/v1";
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
deterministic: "Детерминированный",
"ai-inference": "AI inference",
@@ -96,6 +118,7 @@ export function LaboratorySummary({
status,
statusTone = "neutral",
facts,
brief,
method,
}: {
title: string;
@@ -103,6 +126,7 @@ export function LaboratorySummary({
status: string;
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
facts: readonly { label: string; value: string }[];
brief: LaboratoryBrief;
method?: LaboratoryMethod | null;
}) {
const methodComplete = method?.completeness === "complete";
@@ -126,6 +150,25 @@ export function LaboratorySummary({
))}
</dl>
<dl className="laboratory-summary__brief">
<div>
<dt>Задача</dt>
<dd>{brief.question}</dd>
</div>
<div>
<dt>Как проверяли</dt>
<dd>{brief.approach}</dd>
</div>
<div>
<dt>Главный результат</dt>
<dd>{brief.principalResult}</dd>
</div>
<div>
<dt>Ограничение</dt>
<dd>{brief.limitation}</dd>
</div>
</dl>
{method ? (
<div className="laboratory-summary__method">
<header>
@@ -193,6 +236,69 @@ export function LaboratoryEvidence({
);
}
export function LaboratoryConclusion({
proved,
notProved,
decision,
}: {
proved: string;
notProved: string;
decision: string;
}) {
return (
<dl className="laboratory-result-conclusion">
<div>
<dt>Доказано</dt>
<dd>{proved}</dd>
</div>
<div>
<dt>Не доказано</dt>
<dd>{notProved}</dd>
</div>
<div>
<dt>Решение</dt>
<dd>{decision}</dd>
</div>
</dl>
);
}
export function LaboratoryResultSummary({
title,
status,
statusTone = "neutral",
metrics,
conclusion,
}: {
title: string;
status: string;
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
metrics: readonly LaboratoryResultMetric[];
conclusion: LaboratoryResultConclusion;
}) {
return (
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>{title}</h2>
</div>
<StatusBadge tone={statusTone}>{status}</StatusBadge>
</header>
<div className="laboratory-result-metrics">
{metrics.map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<small>{metric.hint}</small>
</div>
))}
</div>
<LaboratoryConclusion {...conclusion} />
</section>
);
}
export function LaboratoryWorkTemplate({
summary,
evidence,
@@ -205,7 +311,10 @@ export function LaboratoryWorkTemplate({
details?: ReactNode;
}) {
return (
<div className="laboratory-work-template">
<div
className="laboratory-work-template"
data-template-contract={LABORATORY_REPORT_TEMPLATE_VERSION}
>
{summary}
{evidence}
{result}
@@ -64,15 +64,22 @@ export interface E33LaboratoryResult {
};
metrics: {
sourceFrames: number;
sourceAvailableFrames: number;
sourceUnavailableFrames: number;
deliveredFrames: number;
inputSuperseded: number;
resultSuperseded: number;
effectiveDeliveryFps: number;
sourceSpanSeconds: number;
deadlineMissFraction: number;
processingP95Ms: number;
releaseLagP95Ms: number;
resultAgeP95Ms: number;
resultAgeMaxMs: number;
resultDeadlineMs: number;
processRssP95Mib: number;
processRssMaxMib: number;
processRssLimitMib: number;
gpuUtilizationP95Percent: number;
gpuVisible: boolean;
workQueueCapacity: number;
@@ -314,15 +321,22 @@ function parseE33(value: unknown): E33LaboratoryResult {
},
metrics: {
sourceFrames: integerValue(metrics.source_frames, "E33.metrics.source_frames"),
sourceAvailableFrames: integerValue(metrics.source_available_frames, "E33.metrics.source_available_frames"),
sourceUnavailableFrames: integerValue(metrics.source_unavailable_frames, "E33.metrics.source_unavailable_frames"),
deliveredFrames: integerValue(metrics.delivered_frames, "E33.metrics.delivered_frames"),
inputSuperseded: integerValue(metrics.input_superseded, "E33.metrics.input_superseded"),
resultSuperseded: integerValue(metrics.result_superseded, "E33.metrics.result_superseded"),
effectiveDeliveryFps: numberValue(metrics.effective_delivery_fps, "E33.metrics.effective_delivery_fps"),
sourceSpanSeconds: numberValue(metrics.source_span_seconds, "E33.metrics.source_span_seconds"),
deadlineMissFraction: numberValue(metrics.deadline_miss_fraction, "E33.metrics.deadline_miss_fraction"),
processingP95Ms: numberValue(metrics.processing_p95_ms, "E33.metrics.processing_p95_ms"),
releaseLagP95Ms: numberValue(metrics.release_lag_p95_ms, "E33.metrics.release_lag_p95_ms"),
resultAgeP95Ms: numberValue(metrics.result_age_p95_ms, "E33.metrics.result_age_p95_ms"),
resultAgeMaxMs: numberValue(metrics.result_age_max_ms, "E33.metrics.result_age_max_ms"),
resultDeadlineMs: numberValue(metrics.result_deadline_ms, "E33.metrics.result_deadline_ms"),
processRssP95Mib: numberValue(metrics.process_rss_p95_mib, "E33.metrics.process_rss_p95_mib"),
processRssMaxMib: numberValue(metrics.process_rss_max_mib, "E33.metrics.process_rss_max_mib"),
processRssLimitMib: numberValue(metrics.process_rss_limit_mib, "E33.metrics.process_rss_limit_mib"),
gpuUtilizationP95Percent: numberValue(metrics.gpu_utilization_p95_percent, "E33.metrics.gpu_utilization_p95_percent"),
gpuVisible: booleanValue(metrics.gpu_visible, "E33.metrics.gpu_visible"),
workQueueCapacity: integerValue(metrics.work_queue_capacity, "E33.metrics.work_queue_capacity"),
+1
View File
@@ -2,6 +2,7 @@
@import "./styles/shell.css";
@import "./styles/workspaces.css";
@import "./styles/laboratory.css";
@import "./styles/laboratory-reporting.css";
@import "./styles/e30-human-review.css";
@import "./styles/spatial.css";
@import "./styles/device.css";
@@ -0,0 +1,54 @@
.laboratory-summary__brief {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.4rem;
margin-top: 0.65rem;
}
.laboratory-summary__brief > div {
display: grid;
gap: 0.28rem;
min-width: 0;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.025);
padding: 0.7rem;
}
.laboratory-summary__brief dt,
.laboratory-result-conclusion dt {
color: var(--nodedc-text-muted);
font-size: 0.54rem;
text-transform: uppercase;
}
.laboratory-summary__brief dd,
.laboratory-result-conclusion dd {
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 0.64rem;
line-height: 1.5;
}
.laboratory-result-conclusion {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.laboratory-result-conclusion > div {
display: grid;
gap: 0.28rem;
min-width: 0;
border-top: 1px solid rgb(255 255 255 / 0.08);
padding: 0.75rem 0.1rem 0;
}
@media (max-width: 1100px) {
.laboratory-summary__brief {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.laboratory-result-conclusion {
grid-template-columns: 1fr;
}
}
@@ -98,7 +98,8 @@
.laboratory-summary p,
.laboratory-summary dl,
.laboratory-result-summary h2,
.laboratory-result-summary p {
.laboratory-result-summary p,
.laboratory-result-summary dl {
margin: 0;
}
@@ -219,6 +219,16 @@ function SpatialWorkspace({
const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
);
useEffect(() => {
if (!pointCloudFocused) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
observationLayout.setFocusedSourceId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
const floatingSourceMaximized = !unifiedPerception &&
Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { StatusBadge } from "@nodedc/ui-react";
import {
LaboratoryEvidence,
LaboratoryResultSummary,
LaboratorySummary,
LaboratoryWorkTemplate,
} from "../../components/laboratory/LaboratoryPresentation";
@@ -36,6 +36,12 @@ export function E31Result({
},
{ label: "Контур", value: "Source-scoped · read-only" },
]}
brief={{
question: "Можно ли однозначно связать camera, LiDAR и pose всей записи RAVNOVES00 без подбора соответствий вручную?",
approach: "Для каждого кадра проверены временная привязка, чувствительность к offset, доступность pose и self-mask на одном неизменяемом источнике.",
principalResult: `${formatNumber(metrics.availableBindingCount, 0)} из ${formatNumber(metrics.frameCount, 0)} кадров получили воспроизводимую source-scoped привязку.`,
limitation: "Время основано на host-arrival, профиль действителен только для этой записи и этого монтажа сенсоров.",
}}
method={{
completeness: "complete",
executionClass: "deterministic",
@@ -70,38 +76,38 @@ export function E31Result({
</LaboratoryEvidence>
)}
result={(
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>Привязка воспроизводима в границах этого источника</h2>
</div>
<StatusBadge tone="success">Source profile принят</StatusBadge>
</header>
<div className="laboratory-result-metrics">
<div>
<span>Camera LiDAR p95</span>
<strong>{metrics.lidarCameraP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс</strong>
<small>host-arrival binding</small>
</div>
<div>
<span>Pose point p95</span>
<strong>{metrics.posePointP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс</strong>
<small>по доступным кадрам</small>
</div>
<div>
<span>Поддержка соответствий</span>
<strong>{(metrics.supportedFraction * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</strong>
<small>{formatNumber(metrics.correspondenceCount, 0)} соответствий</small>
</div>
<div>
<span>Self-mask</span>
<strong>{formatNumber(metrics.semanticSelfSampleCount, 0)} образцов</strong>
<small>{formatNumber(metrics.semanticSelfCollateralCount, 0)} collateral</small>
</div>
</div>
<p>Результат диагностический: он не переносится на другой монтаж сенсоров и не выдаёт командных полномочий.</p>
</section>
<LaboratoryResultSummary
title="Привязка воспроизводима в границах этого источника"
status="Source profile принят"
statusTone="success"
metrics={[
{
label: "Camera ↔ LiDAR p95",
value: `${metrics.lidarCameraP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс`,
hint: "host-arrival binding",
},
{
label: "Pose ↔ point p95",
value: `${metrics.posePointP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс`,
hint: "по доступным кадрам",
},
{
label: "Поддержка соответствий",
value: `${(metrics.supportedFraction * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`,
hint: `${formatNumber(metrics.correspondenceCount, 0)} соответствий`,
},
{
label: "Self-mask",
value: `${formatNumber(metrics.semanticSelfSampleCount, 0)} образцов`,
hint: `${formatNumber(metrics.semanticSelfCollateralCount, 0)} collateral`,
},
]}
conclusion={{
proved: "Camera, LiDAR и pose воспроизводимо связаны для неизменяемой записи RAVNOVES00 в заявленных границах source profile.",
notProved: "Не доказаны аппаратная синхронизация, переносимость профиля на другую запись или другой монтаж и пригодность результата для управления.",
decision: "Принять source-scoped профиль как диагностический вход E32; команды, навигация и safety остаются запрещены.",
}}
/>
)}
/>
);
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { StatusBadge } from "@nodedc/ui-react";
import {
LaboratoryEvidence,
LaboratoryResultSummary,
LaboratorySummary,
LaboratoryWorkTemplate,
} from "../../components/laboratory/LaboratoryPresentation";
@@ -33,6 +33,12 @@ export function E32Result({
{ label: "Кадры", value: formatNumber(metrics.framesTotal, 0) },
{ label: "Контур", value: "Diagnostic replay · read-only" },
]}
brief={{
question: "Можно ли детерминированно превратить полную принятую запись E29/E31 в покадровый TrackGeometry без повторной настройки порогов?",
approach: "Для каждого кадра применены один source profile, единый арбитраж перекрывающихся наблюдений, self-mask и запрет публикации диапазонов без квалифицированного источника.",
principalResult: `TrackGeometry построен для ${formatNumber(metrics.framesTotal, 0)} кадров; опубликовано ${formatNumber(metrics.qualifiedPointsPublished, 0)} квалифицированных точек.`,
limitation: "Результат наследует качество входных наблюдений и не считает отсутствие LiDAR-точек доказательством свободного пространства.",
}}
method={{
completeness: "complete",
executionClass: "deterministic",
@@ -67,38 +73,38 @@ export function E32Result({
</LaboratoryEvidence>
)}
result={(
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>TrackGeometry построен без повторной настройки порогов</h2>
</div>
<StatusBadge tone="success">Детерминированный replay</StatusBadge>
</header>
<div className="laboratory-result-metrics">
<div>
<span>Опубликовано точек</span>
<strong>{formatNumber(metrics.qualifiedPointsPublished, 0)}</strong>
<small>{formatNumber(metrics.qualifiedPointsWithheld, 0)} withheld</small>
</div>
<div>
<span>Арбитраж наблюдений</span>
<strong>{formatNumber(metrics.observationsArbitrated, 0)}</strong>
<small>{formatNumber(metrics.overlappingClaimsRemoved, 0)} overlap снято</small>
</div>
<div>
<span>Semantic</span>
<strong>{formatNumber(metrics.semanticPublished, 0)}</strong>
<small>{formatNumber(metrics.semanticMasked, 0)} self-mask</small>
</div>
<div>
<span>Frame processing p95</span>
<strong>{metrics.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
<small>{(metrics.buildElapsedMs / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} с build</small>
</div>
</div>
<p>Отсутствие LiDAR-точек по-прежнему не трактуется как свободное пространство; результат не является навигационным ground truth.</p>
</section>
<LaboratoryResultSummary
title="TrackGeometry построен без повторной настройки порогов"
status="Детерминированный replay"
statusTone="success"
metrics={[
{
label: "Опубликовано точек",
value: formatNumber(metrics.qualifiedPointsPublished, 0),
hint: `${formatNumber(metrics.qualifiedPointsWithheld, 0)} withheld`,
},
{
label: "Арбитраж наблюдений",
value: formatNumber(metrics.observationsArbitrated, 0),
hint: `${formatNumber(metrics.overlappingClaimsRemoved, 0)} overlap снято`,
},
{
label: "Semantic",
value: formatNumber(metrics.semanticPublished, 0),
hint: `${formatNumber(metrics.semanticMasked, 0)} self-mask`,
},
{
label: "Frame processing p95",
value: `${metrics.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс`,
hint: `${(metrics.buildElapsedMs / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} с build`,
},
]}
conclusion={{
proved: "Принятый source profile воспроизводимо материализует покадровый TrackGeometry с единственным владельцем перекрывающихся LiDAR-точек.",
notProved: "Не доказаны полнота препятствий, свободное пространство при отсутствии точек и навигационная точность геометрии.",
decision: "Принять TrackGeometry как неизменяемый диагностический вход worker shadow; не использовать как navigation ground truth.",
}}
/>
)}
/>
);
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { StatusBadge } from "@nodedc/ui-react";
import {
LaboratoryEvidence,
LaboratoryResultSummary,
LaboratorySummary,
LaboratoryWorkTemplate,
} from "../../components/laboratory/LaboratoryPresentation";
@@ -23,33 +23,52 @@ export function E33Result({
<LaboratoryWorkTemplate
summary={(
<LaboratorySummary
title="LAB E33 · worker shadow 1×"
description=олная запись TrackGeometry выполнена отдельным bounded worker в темпе исходника 1×. Проверены очереди, доставка каждого кадра, задержка результата и ресурсы без подключения командного канала."
status="Worker gate пройден"
title="LAB E33 · проверка TrackGeometry worker в реальном темпе"
description=роверяли не качество распознавания объектов, а способность отдельного worker принять уже готовый TrackGeometry и вернуть явный результат по каждому кадру без накопления задержки и скрытых потерь."
status="Принят как worker shadow"
statusTone="success"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · worker shadow` },
{ label: "Worker", value: result.worker.node },
{ label: "Доставка", value: `${formatNumber(metrics.deliveredFrames, 0)} / ${formatNumber(metrics.sourceFrames, 0)} кадров` },
{ label: "Контур", value: "Recorded-source-paced · 1×" },
{ label: "Источник", value: `${rigLabel} · RAVNOVES00` },
{
label: "Объём проверки",
value: `${formatNumber(metrics.sourceFrames, 0)} кадров · ${metrics.sourceSpanSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с`,
},
{
label: "Геометрия доступна",
value: `${formatNumber(metrics.sourceAvailableFrames, 0)} / ${formatNumber(metrics.sourceFrames, 0)} кадров`,
},
{ label: "Полномочия", value: "Read-only · команды и safety выключены" },
]}
brief={{
question: "Сможет ли отдельный worker обработать всю запись в исходном темпе 1×, не теряя кадры и не раздувая очереди?",
approach: "Готовый TrackGeometry из E32 выпускался по исходным временным меткам через отдельные work/result очереди ёмкостью 2. Для каждого кадра фиксировался один терминальный исход, задержка и использование памяти.",
principalResult: `Да. Доставлены все ${formatNumber(metrics.deliveredFrames, 0)} исходов, замен и deadline miss не было; p95 возраста результата — ${metrics.resultAgeP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс.`,
limitation: `Детектор, semantic-модели и качество геометрии здесь не пересчитывались. В ${formatNumber(metrics.sourceUnavailableFrames, 0)} кадрах источник уже не содержал квалифицированной геометрии.`,
}}
method={{
completeness: "complete",
executionClass: "deterministic",
pipelineId: result.pipelineId,
pipelineId: "TrackGeometry → recorded-paced bounded worker",
components: [
{
kind: "source",
name: result.e32ResultId,
version: "accepted TrackGeometry replay",
role: "immutable frame stream",
identitySha256: result.e32ResultId.split("-").at(-1) ?? null,
name: "Принятый TrackGeometry из LAB E32",
version: `${formatNumber(metrics.sourceFrames, 0)} кадров · неизменяемая запись`,
role: "готовый вход; detector и semantic inference не запускались повторно",
identitySha256: null,
},
{
kind: "algorithm",
name: "Recorded pacing + bounded latest-wins queues",
version: `1× · очереди ${metrics.workQueueCapacity}/${metrics.resultQueueCapacity}`,
role: "выпуск по исходному времени, закрытый учёт исходов и deadline-контроль",
identitySha256: null,
},
{
kind: "runtime",
name: result.worker.node,
version: `${result.worker.python} · NumPy ${result.worker.numpy}`,
role: "bounded worker, work/result queues 2/2",
name: "Изолированный worker",
version: `${result.worker.node} · Python ${result.worker.python} · NumPy ${result.worker.numpy}`,
role: "проверка структуры TrackGeometry, SHA-256 диапазонов PointSlab и публикация результата",
identitySha256: null,
},
],
@@ -59,7 +78,7 @@ export function E33Result({
evidence={(
<LaboratoryEvidence
eyebrow="ИСХОДНОЕ ДОКАЗАТЕЛЬСТВО"
title="Запись, доставленная worker без потерь"
title="Контрольная запись: кадры и TrackGeometry, которые доставлял worker"
kind="recorded-replay"
resizable
>
@@ -67,40 +86,38 @@ export function E33Result({
</LaboratoryEvidence>
)}
result={(
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>Полный replay выдержан в темпе источника</h2>
</div>
<StatusBadge tone="success">
{formatNumber(metrics.deliveredFrames, 0)} / {formatNumber(metrics.sourceFrames, 0)}
</StatusBadge>
</header>
<div className="laboratory-result-metrics">
<div>
<span>Эффективная частота</span>
<strong>{metrics.effectiveDeliveryFps.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} FPS</strong>
<small>скорость источника 1×</small>
</div>
<div>
<span>Result age p95</span>
<strong>{metrics.resultAgeP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
<small>{(metrics.deadlineMissFraction * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% deadline miss</small>
</div>
<div>
<span>Processing p95</span>
<strong>{metrics.processingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
<small>{metrics.releaseLagP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс release lag</small>
</div>
<div>
<span>Ресурсы p95</span>
<strong>{metrics.processRssP95Mib.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} MiB</strong>
<small>GPU {metrics.gpuUtilizationP95Percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</small>
</div>
</div>
<p>Очереди остались bounded, пропусков нет. Результат подтверждает только worker shadow и не даёт навигационных или safety-полномочий.</p>
</section>
<LaboratoryResultSummary
title="Worker выдержал запись; качество восприятия этим прогоном не проверялось"
status="Worker shadow принят"
statusTone="success"
metrics={[
{
label: "Доставка исходов",
value: `${formatNumber(metrics.deliveredFrames, 0)} / ${formatNumber(metrics.sourceFrames, 0)}`,
hint: "0 замен · 0 пропусков",
},
{
label: "Темп обработки",
value: `${metrics.effectiveDeliveryFps.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} FPS`,
hint: "исходная скорость 10 FPS · 1×",
},
{
label: "Возраст результата",
value: `${metrics.resultAgeP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс p95`,
hint: `${metrics.resultAgeMaxMs.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс max · лимит ${formatNumber(metrics.resultDeadlineMs, 0)} мс`,
},
{
label: "Память процесса",
value: `${metrics.processRssMaxMib.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} MiB max`,
hint: `лимит ${formatNumber(metrics.processRssLimitMib, 0)} MiB`,
},
]}
conclusion={{
proved: "Финальная стадия публикации TrackGeometry на этом worker успевает за данной записью: очереди ограничены, каждый кадр учтён, задержка остаётся ниже заявленного deadline.",
notProved: "Прогон не измеряет точность машин, кубов или сегментации, не запускает заново detector/semantic pipeline и не доказывает переносимость на другую запись или монтаж сенсоров.",
decision: "Результат принимается только как диагностический worker shadow. Он допускает дальнейшие лабораторные испытания временного occupied/unknown слоя, но не включает команды, навигацию или safety.",
}}
/>
)}
/>
);
@@ -44,8 +44,11 @@ import type { WorkspaceRendererProps } from "../contracts";
import { E31Result } from "./E31Result";
import { E32Result } from "./E32Result";
import { E33Result } from "./E33Result";
import {
e28LaboratoryBrief, e29LaboratoryBrief,
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
} from "./laboratoryArchiveBriefs";
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
SpatialView: ComponentType<WorkspaceRendererProps>;
};
@@ -227,6 +230,7 @@ function E29LaboratoryResult({
value: "Read-only · hash verified",
},
]}
brief={e29LaboratoryBrief(formatNumber(semantic.total, 0))}
method={{
completeness: "complete",
executionClass: "hybrid",
@@ -411,6 +415,7 @@ function E30LaboratoryResult({
{ label: "Кейсов", value: formatNumber(result.itemCount, 0) },
{ label: "Контур", value: "Read-only · без LAB publish" },
]}
brief={e30LaboratoryBrief(formatNumber(result.itemCount, 0))}
/>
)}
evidence={(
@@ -483,6 +488,7 @@ function PublishedLaboratoryResult({
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
{ label: "Контур", value: "Диагностика · без команд" },
]}
brief={PUBLISHED_LABORATORY_BRIEF}
method={publishedLaboratoryMethod(session)}
/>
)}
@@ -867,6 +873,10 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
{ label: "Режим", value: "Recorded-source-paced shadow" },
{ label: "Контур", value: "Read-only · hash verified" },
]}
brief={e28LaboratoryBrief(
formatNumber(e28Model?.metrics.frames.valid ?? 0, 0),
formatNumber(e28Model?.metrics.frames.total ?? 0, 0),
)}
method={{
completeness: "complete",
executionClass: e28Model?.method.executionClass ?? "deterministic",
@@ -0,0 +1,44 @@
import type {
LaboratoryBrief,
} from "../../components/laboratory/LaboratoryPresentation";
export function e28LaboratoryBrief(
validFrames: string,
totalFrames: string,
): LaboratoryBrief {
return {
question: "Можно ли устойчиво оценивать локальную поверхность и временные остатки на полной записи без изменения источника?",
approach: "LiDAR и pose воспроизводятся через bounded recorded-source-paced shadow; rolling local plane оценивает поверхность, препятствия и temporal residuals.",
principalResult: `${validFrames} из ${totalFrames} кадров прошли диагностическую обработку L2.6.`,
limitation: "Модель проверена только на RAVNOVES00 и не получает командных, навигационных или safety-полномочий.",
};
}
export function e29LaboratoryBrief(
observationCount: string,
): LaboratoryBrief {
return {
question: "Совпадают ли camera-семантика объекта и независимая LiDAR-геометрия в одном точном кадре?",
approach: "Camera сохраняет класс и идентичность, а локальная поверхность L2.6 отдельно проверяет дальность, occupied support и конфликт принадлежности точек.",
principalResult: `${observationCount} наблюдений разложены по согласованию camera и geometry без объявления пустого LiDAR свободным пространством.`,
limitation: "Результат зависит от точности исходной camera-семантики и локальной поверхности; он остаётся диагностическим и read-only.",
};
}
export function e30LaboratoryBrief(
itemCount: string,
): LaboratoryBrief {
return {
question: "Можно ли человеку проверить спорный camera/LiDAR-кейс по понятному совмещённому доказательству, а не по изолированному облаку точек?",
approach: "Каждый кейс связан с точным camera frame, переключаемой LiDAR-проекцией, frame-local индексами и синхронным 3D в одном viewer.",
principalResult: `${itemCount} кейсов доступны для camera-first проверки и отдельного разбора только неоднозначных исключений.`,
limitation: "Рабочее место не меняет исходный A2 и не превращает решение оператора в navigation или safety ground truth.",
};
}
export const PUBLISHED_LABORATORY_BRIEF: LaboratoryBrief = {
question: "Что было зафиксировано опубликованной лабораторной работой на её неизменяемом источнике?",
approach: "Mission Core открывает связанный source replay и проецирует только опубликованные method/provenance и метрики без реконструкции отсутствующих данных.",
principalResult: "Оператор получает исходное визуальное доказательство и зафиксированный результат одного LAB run в общей продуктовой форме.",
limitation: "Для legacy-результатов часть идентичностей и метрик могла не публиковаться; приложение их не выдумывает задним числом.",
};
@@ -96,15 +96,22 @@ function e33() {
},
metrics: {
source_frames: 4489,
source_available_frames: 3928,
source_unavailable_frames: 561,
delivered_frames: 4489,
input_superseded: 0,
result_superseded: 0,
effective_delivery_fps: 10.006,
source_span_seconds: 448.623,
deadline_miss_fraction: 0,
processing_p95_ms: 0.749,
release_lag_p95_ms: 0.461,
result_age_p95_ms: 2.668,
result_age_max_ms: 13.638,
result_deadline_ms: 100,
process_rss_p95_mib: 90.32,
process_rss_max_mib: 92.34,
process_rss_limit_mib: 1024,
gpu_utilization_p95_percent: 52,
gpu_visible: true,
work_queue_capacity: 2,
@@ -147,6 +154,8 @@ test("decodes E31, E32 and E33 from separate read-only catalogs", async () => {
assert.equal(decoded.e31.metrics.availableBindingCount, 3928);
assert.equal(decoded.e32.metrics.qualifiedPointsPublished, 2119302);
assert.equal(decoded.e33.metrics.deliveredFrames, 4489);
assert.equal(decoded.e33.metrics.sourceAvailableFrames, 3928);
assert.equal(decoded.e33.metrics.resultAgeMaxMs, 13.638);
assert.deepEqual(requests, [
{ input: "/api/v1/laboratory/e31/results?limit=1", method: "GET" },
{ input: "/api/v1/laboratory/e32/results?limit=1", method: "GET" },
@@ -75,6 +75,7 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
);
const workspaceCss = await read("styles/workspaces.css");
const laboratoryCss = await read("styles/laboratory.css");
const laboratoryReportingCss = await read("styles/laboratory-reporting.css");
const e30Review = await read("workspaces/E30ReviewWorkspace.tsx");
const e30HumanReview = await read(
"workspaces/E30HumanReviewPanel.tsx",
@@ -94,6 +95,8 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
assert.doesNotMatch(workspaceCss, /\.(?:lab-|laboratory-|e30-)/);
assert.match(laboratoryCss, /\.laboratory-work-template/);
assert.match(laboratoryCss, /\.e30-review-workspace/);
assert.match(laboratoryReportingCss, /\.laboratory-summary__brief/);
assert.match(laboratoryReportingCss, /\.laboratory-result-conclusion/);
assert.doesNotMatch(laboratory, /E30HumanReviewPanel/);
assert.match(e30Review, /<E30HumanReviewPanel/);
assert.doesNotMatch(e30Review, /E30EngineeringAuditPanel/);
@@ -109,6 +112,7 @@ test("central composition files cannot silently become monoliths again", async (
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
["styles/workspaces.css", 4_350],
["styles/laboratory.css", 900],
["styles/laboratory-reporting.css", 100],
];
for (const [relativePath, maximumLines] of ratchets) {
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { readdir, readFile } from "node:fs/promises";
import { test } from "node:test";
const workspaceSourceUrl = new URL(
@@ -18,6 +18,18 @@ const laboratoryStylesUrl = new URL(
"../src/styles/laboratory.css",
import.meta.url,
);
const e33ResultUrl = new URL(
"../src/workspaces/laboratory/E33Result.tsx",
import.meta.url,
);
const workspacesUrl = new URL(
"../src/workspaces/Workspaces.tsx",
import.meta.url,
);
const laboratoryResultsUrl = new URL(
"../src/workspaces/laboratory/",
import.meta.url,
);
test("E30 uses one reusable evidence viewer with camera, 3D and expand controls", async () => {
const [workspaceSource, viewerSource, laboratoryStyles] = await Promise.all([
@@ -75,6 +87,60 @@ test("LAB product surface has a compact canonical summary and no roadmap footer"
assert.match(presentationSource, /export function LaboratorySummary/);
assert.match(presentationSource, /export function LaboratoryWorkTemplate/);
assert.match(
presentationSource,
/LABORATORY_REPORT_TEMPLATE_VERSION\s*=\s*\n?\s*"missioncore\.laboratory-report\/v1"/,
);
assert.match(presentationSource, /brief: LaboratoryBrief;/);
assert.doesNotMatch(presentationSource, /brief\?: LaboratoryBrief/);
assert.match(presentationSource, /data-template-contract=\{LABORATORY_REPORT_TEMPLATE_VERSION\}/);
assert.doesNotMatch(workspaceSource, /СЛЕДУЮЩИЙ GATE/);
assert.doesNotMatch(workspaceSource, /e30-review-workspace__taxonomy/);
});
test("bounded LAB result modules use the versioned shared report anatomy", async () => {
const resultNames = (await readdir(laboratoryResultsUrl))
.filter((name) => /^E\d+Result\.tsx$/.test(name))
.sort();
assert.ok(resultNames.length >= 3);
for (const resultName of resultNames) {
const source = await readFile(new URL(resultName, laboratoryResultsUrl), "utf8");
assert.match(source, /<LaboratoryWorkTemplate/, `${resultName} bypasses the LAB template`);
assert.match(source, /brief=\{\{/, `${resultName} omits the canonical four-part brief`);
assert.match(source, /<LaboratoryResultSummary/, `${resultName} invents a result layout`);
assert.doesNotMatch(
source,
/className="laboratory-(?:summary|result-summary)"/,
`${resultName} owns canonical report geometry`,
);
}
});
test("E33 explains the experiment, its method and its retained limits", async () => {
const [presentationSource, e33Source] = await Promise.all([
readFile(presentationSourceUrl, "utf8"),
readFile(e33ResultUrl, "utf8"),
]);
assert.match(presentationSource, /export interface LaboratoryBrief/);
assert.match(presentationSource, /export function LaboratoryConclusion/);
assert.match(presentationSource, /export function LaboratoryResultSummary/);
assert.match(e33Source, /Проверяли не качество распознавания объектов/);
assert.match(e33Source, /Recorded pacing \+ bounded latest-wins queues/);
assert.match(e33Source, /Детектор, semantic-модели и качество геометрии здесь не пересчитывались/);
assert.match(e33Source, /sourceUnavailableFrames/);
assert.match(e33Source, /не включает команды, навигацию или safety/);
assert.doesNotMatch(e33Source, /GPU \{metrics\.gpuUtilizationP95Percent/);
assert.doesNotMatch(e33Source, /name: result\.e32ResultId/);
});
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
const workspacesSource = await readFile(workspacesUrl, "utf8");
assert.match(workspacesSource, /if \(!pointCloudFocused\) return;/);
assert.match(workspacesSource, /event\.key !== "Escape"/);
assert.match(workspacesSource, /observationLayout\.setFocusedSourceId\(null\)/);
assert.match(workspacesSource, /window\.addEventListener\("keydown", onKeyDown\)/);
});
+18
View File
@@ -127,6 +127,14 @@ There are not two unrelated laboratory-page templates. Mission Core has one
`compact summary → evidence → result → optional reusable details`.
The executable product contract is versioned as
`missioncore.laboratory-report/v1`. Its canonical summary always contains the
four named fields `question`, `approach`, `principalResult`, and `limitation`.
Its ordinary result uses the shared `LaboratoryResultSummary` with metrics and
the three named conclusions `proved`, `notProved`, and `decision`. LAB feature
modules provide typed values; they do not render `laboratory-summary` or
`laboratory-result-summary` markup themselves.
Catalog profile/work selectors precede the template. The compact summary
projects the task, method, principal result, limitation, and retained authority
from typed LAB data; it does not reproduce the complete engineering report.
@@ -140,10 +148,20 @@ The evidence slot has two admitted renderers:
- `diagnostic-model` — a specialized visual result such as the LAB E28 L2.6
surface/timeline/review viewer.
An admitted diagnostic viewer may own the result interaction internally when
the evidence itself is the review/result instrument, as in E28 and E30. This is
not permission to omit the result from a new ordinary LAB report. New bounded
`ENNResult.tsx` modules use `LaboratoryResultSummary`.
If an experiment needs a new chart or control, the reusable result/evidence
component is extended and validated. A one-off page that bypasses the template
is not accepted.
Changing slot order, removing a required summary/conclusion field, or changing
shared report geometry requires product-owner agreement, a new contract
version, migration of all consumers, and an updated architecture test. A
single LAB run never changes the v1 anatomy.
## Publication gates
A LAB run is catalogued only when:
@@ -76,6 +76,25 @@ Every LAB uses the same product hierarchy:
A LAB provides typed data to these slots. It does not create its own page
grammar, toolbar language, status geometry, or decorative card stack.
The current executable anatomy is frozen as
`missioncore.laboratory-report/v1`:
```text
LaboratorySummary
question → approach → principalResult → limitation
LaboratoryEvidence
LaboratoryResultSummary
metrics → proved → notProved → decision
optional reusable details
```
`LaboratorySummary` requires all four brief fields.
`LaboratoryResultSummary` owns the result heading, status, metric grid, and
three-part conclusion. A bounded `ENNResult.tsx` supplies typed copy and values
to these components and must not reproduce their classes or DOM structure.
Changing this anatomy is a template-version decision, not a LAB-specific
layout edit.
### Canonical summary content
The summary must let an operator understand the evidence before opening the
@@ -204,3 +223,5 @@ Before A3 or any subsequent LAB UI change:
5. verify every admitted representation answers a named operator question;
6. run typecheck, unit tests, production build, and browser interaction QA;
7. visually inspect normal and expanded modes at representative viewport sizes.
8. verify every bounded `ENNResult.tsx` uses the v1 summary and result
components without raw canonical report markup.
+16 -1
View File
@@ -152,7 +152,7 @@ core/laboratory/eNNContract.ts
immutable API/schema adapter
workspaces/laboratory/ENNResult.tsx
concise summary + admitted evidence + result projection
typed values for the versioned summary + admitted evidence + result projection
domain renderer module
only when an existing renderer cannot express the evidence
@@ -167,6 +167,18 @@ styles/laboratory.css
The complete engineering narrative remains in Ops. The product component
receives a concise typed projection.
`components/laboratory/LaboratoryPresentation.tsx` owns the executable
`missioncore.laboratory-report/v1` anatomy. `LaboratorySummary` requires
`question`, `approach`, `principalResult`, and `limitation`;
`LaboratoryResultSummary` requires metrics plus `proved`, `notProved`, and
`decision`. A bounded LAB result module cannot own the canonical summary/result
DOM or CSS classes. The product UI test discovers every `ENNResult.tsx`
automatically and rejects such a fork.
Legacy/integrated diagnostic viewers may keep a result interaction inside the
evidence slot only where that viewer is already the admitted result instrument.
This exception does not apply automatically to a new LAB.
## Local ontology decision
Mission Core already has three semantic mechanisms:
@@ -201,6 +213,9 @@ vocabulary and executable contracts. They do not create a second runtime model.
- LAB code and CSS remain outside the central workspace buckets;
- central composition files cannot silently return to their previous size.
`test/laboratoryProductUi.test.mjs` additionally enforces the versioned LAB
report fields and shared result component across bounded LAB modules.
The line limits are ratchets, not quality targets. When a file reaches a limit,
split a feature; do not raise the limit to accommodate unrelated behavior.
+19
View File
@@ -224,6 +224,11 @@ def _project_e33(
"linked E32 binding",
)
profile = _object(identity.get("profile"), "E33 profile")
profile_acceptance = _object(
profile.get("acceptance"),
"E33 profile acceptance",
)
deadlines = _object(profile.get("deadlines"), "E33 deadlines")
worker = _object(identity.get("worker"), "E33 worker")
metrics = _object(result.report.get("metrics"), "E33 metrics")
accounting = _object(metrics.get("accounting"), "E33 accounting")
@@ -232,6 +237,8 @@ def _project_e33(
result_age = _object(metrics.get("result_age_ms"), "E33 result age")
resources = _object(metrics.get("resources"), "E33 resources")
process_rss = _object(resources.get("process_rss_mib"), "E33 RSS")
linked_metrics = _object(linked_e32.report.get("metrics"), "linked E32 metrics")
linked_frames = _object(linked_metrics.get("frames"), "linked E32 frames")
gpu_utilization = _object(
resources.get("gpu_utilization_percent"),
"E33 GPU utilization",
@@ -254,15 +261,27 @@ def _project_e33(
},
"metrics": {
"source_frames": accounting.get("source_frames"),
"source_available_frames": linked_frames.get("source_available"),
"source_unavailable_frames": (
int(accounting["source_frames"])
- int(linked_frames["source_available"])
),
"delivered_frames": accounting.get("delivered"),
"input_superseded": accounting.get("input_superseded"),
"result_superseded": accounting.get("result_superseded"),
"effective_delivery_fps": metrics.get("effective_delivery_fps"),
"source_span_seconds": metrics.get("source_span_seconds"),
"deadline_miss_fraction": metrics.get("deadline_miss_fraction"),
"processing_p95_ms": processing.get("p95"),
"release_lag_p95_ms": release_lag.get("p95"),
"result_age_p95_ms": result_age.get("p95"),
"result_age_max_ms": result_age.get("maximum"),
"result_deadline_ms": deadlines.get("result_ms"),
"process_rss_p95_mib": process_rss.get("p95"),
"process_rss_max_mib": process_rss.get("maximum"),
"process_rss_limit_mib": profile_acceptance.get(
"maximum_process_rss_mib"
),
"gpu_utilization_p95_percent": gpu_utilization.get("p95"),
"gpu_visible": resources.get("gpu_visible"),
"work_queue_capacity": work_queue.get("capacity"),