feat(lab): canonize versioned report template
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user