From 95c654069150e4800a4eb3b196e5595d3c362ae6 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 27 Jul 2026 16:05:32 +0300 Subject: [PATCH] feat(lab): canonize versioned report template --- .../skills/mission-core-product-ui/SKILL.md | 37 +++++- AGENTS.md | 5 + .../laboratory/LaboratoryPresentation.tsx | 111 +++++++++++++++- .../src/core/laboratory/advancedResults.ts | 14 +++ apps/control-station/src/styles.css | 1 + .../src/styles/laboratory-reporting.css | 54 ++++++++ .../control-station/src/styles/laboratory.css | 3 +- .../src/workspaces/Workspaces.tsx | 10 ++ .../src/workspaces/laboratory/E31Result.tsx | 72 ++++++----- .../src/workspaces/laboratory/E32Result.tsx | 72 ++++++----- .../src/workspaces/laboratory/E33Result.tsx | 119 ++++++++++-------- .../laboratory/LaboratoryArchiveWorkspace.tsx | 12 +- .../laboratory/laboratoryArchiveBriefs.ts | 44 +++++++ .../test/advancedLaboratoryResults.test.mjs | 9 ++ .../test/applicationArchitecture.test.mjs | 4 + .../test/laboratoryProductUi.test.mjs | 68 +++++++++- docs/15_LABORATORY_RUN_CANON.md | 18 +++ ...7_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md | 21 ++++ docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md | 17 ++- src/k1link/web/advanced_laboratory_api.py | 19 +++ 20 files changed, 582 insertions(+), 128 deletions(-) create mode 100644 apps/control-station/src/styles/laboratory-reporting.css create mode 100644 apps/control-station/src/workspaces/laboratory/laboratoryArchiveBriefs.ts diff --git a/.codex/skills/mission-core-product-ui/SKILL.md b/.codex/skills/mission-core-product-ui/SKILL.md index cfe8a73..5847df1 100644 --- a/.codex/skills/mission-core-product-ui/SKILL.md +++ b/.codex/skills/mission-core-product-ui/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 8c23cfc..eaa4a15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx b/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx index a61a39c..7a138e8 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx @@ -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 = { 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({ ))} +
+
+
Задача
+
{brief.question}
+
+
+
Как проверяли
+
{brief.approach}
+
+
+
Главный результат
+
{brief.principalResult}
+
+
+
Ограничение
+
{brief.limitation}
+
+
+ {method ? (
@@ -193,6 +236,69 @@ export function LaboratoryEvidence({ ); } +export function LaboratoryConclusion({ + proved, + notProved, + decision, +}: { + proved: string; + notProved: string; + decision: string; +}) { + return ( +
+
+
Доказано
+
{proved}
+
+
+
Не доказано
+
{notProved}
+
+
+
Решение
+
{decision}
+
+
+ ); +} + +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 ( +
+
+
+ РЕЗУЛЬТАТ И ВЫВОД +

{title}

+
+ {status} +
+
+ {metrics.map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.hint} +
+ ))} +
+ +
+ ); +} + export function LaboratoryWorkTemplate({ summary, evidence, @@ -205,7 +311,10 @@ export function LaboratoryWorkTemplate({ details?: ReactNode; }) { return ( -
+
{summary} {evidence} {result} diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index bcd7b94..501a637 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -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"), diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 4c82d43..053c73e 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -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"; diff --git a/apps/control-station/src/styles/laboratory-reporting.css b/apps/control-station/src/styles/laboratory-reporting.css new file mode 100644 index 0000000..e80a059 --- /dev/null +++ b/apps/control-station/src/styles/laboratory-reporting.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; + } +} diff --git a/apps/control-station/src/styles/laboratory.css b/apps/control-station/src/styles/laboratory.css index 23e570d..c127b14 100644 --- a/apps/control-station/src/styles/laboratory.css +++ b/apps/control-station/src/styles/laboratory.css @@ -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; } diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 34cd4dd..87969b3 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -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; diff --git a/apps/control-station/src/workspaces/laboratory/E31Result.tsx b/apps/control-station/src/workspaces/laboratory/E31Result.tsx index f73c536..8477424 100644 --- a/apps/control-station/src/workspaces/laboratory/E31Result.tsx +++ b/apps/control-station/src/workspaces/laboratory/E31Result.tsx @@ -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({ )} result={( -
-
-
- РЕЗУЛЬТАТ И ВЫВОД -

Привязка воспроизводима в границах этого источника

-
- Source profile принят -
-
-
- Camera ↔ LiDAR p95 - {metrics.lidarCameraP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс - host-arrival binding -
-
- Pose ↔ point p95 - {metrics.posePointP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} мс - по доступным кадрам -
-
- Поддержка соответствий - {(metrics.supportedFraction * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}% - {formatNumber(metrics.correspondenceCount, 0)} соответствий -
-
- Self-mask - {formatNumber(metrics.semanticSelfSampleCount, 0)} образцов - {formatNumber(metrics.semanticSelfCollateralCount, 0)} collateral -
-
-

Результат диагностический: он не переносится на другой монтаж сенсоров и не выдаёт командных полномочий.

-
+ )} /> ); diff --git a/apps/control-station/src/workspaces/laboratory/E32Result.tsx b/apps/control-station/src/workspaces/laboratory/E32Result.tsx index daf0e03..92d99db 100644 --- a/apps/control-station/src/workspaces/laboratory/E32Result.tsx +++ b/apps/control-station/src/workspaces/laboratory/E32Result.tsx @@ -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({ )} result={( -
-
-
- РЕЗУЛЬТАТ И ВЫВОД -

TrackGeometry построен без повторной настройки порогов

-
- Детерминированный replay -
-
-
- Опубликовано точек - {formatNumber(metrics.qualifiedPointsPublished, 0)} - {formatNumber(metrics.qualifiedPointsWithheld, 0)} withheld -
-
- Арбитраж наблюдений - {formatNumber(metrics.observationsArbitrated, 0)} - {formatNumber(metrics.overlappingClaimsRemoved, 0)} overlap снято -
-
- Semantic - {formatNumber(metrics.semanticPublished, 0)} - {formatNumber(metrics.semanticMasked, 0)} self-mask -
-
- Frame processing p95 - {metrics.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс - {(metrics.buildElapsedMs / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} с build -
-
-

Отсутствие LiDAR-точек по-прежнему не трактуется как свободное пространство; результат не является навигационным ground truth.

-
+ )} /> ); diff --git a/apps/control-station/src/workspaces/laboratory/E33Result.tsx b/apps/control-station/src/workspaces/laboratory/E33Result.tsx index 9a15c55..257004f 100644 --- a/apps/control-station/src/workspaces/laboratory/E33Result.tsx +++ b/apps/control-station/src/workspaces/laboratory/E33Result.tsx @@ -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({ @@ -67,40 +86,38 @@ export function E33Result({ )} result={( -
-
-
- РЕЗУЛЬТАТ И ВЫВОД -

Полный replay выдержан в темпе источника

-
- - {formatNumber(metrics.deliveredFrames, 0)} / {formatNumber(metrics.sourceFrames, 0)} - -
-
-
- Эффективная частота - {metrics.effectiveDeliveryFps.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} FPS - скорость источника 1× -
-
- Result age p95 - {metrics.resultAgeP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс - {(metrics.deadlineMissFraction * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% deadline miss -
-
- Processing p95 - {metrics.processingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс - {metrics.releaseLagP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс release lag -
-
- Ресурсы p95 - {metrics.processRssP95Mib.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} MiB - GPU {metrics.gpuUtilizationP95Percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}% -
-
-

Очереди остались bounded, пропусков нет. Результат подтверждает только worker shadow и не даёт навигационных или safety-полномочий.

-
+ )} /> ); diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index 5416810..2c4dfb2 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -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; }; @@ -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", diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveBriefs.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveBriefs.ts new file mode 100644 index 0000000..95c2a02 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveBriefs.ts @@ -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-результатов часть идентичностей и метрик могла не публиковаться; приложение их не выдумывает задним числом.", +}; diff --git a/apps/control-station/test/advancedLaboratoryResults.test.mjs b/apps/control-station/test/advancedLaboratoryResults.test.mjs index 8bd0027..6aec5c3 100644 --- a/apps/control-station/test/advancedLaboratoryResults.test.mjs +++ b/apps/control-station/test/advancedLaboratoryResults.test.mjs @@ -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" }, diff --git a/apps/control-station/test/applicationArchitecture.test.mjs b/apps/control-station/test/applicationArchitecture.test.mjs index 21250d6..867ba19 100644 --- a/apps/control-station/test/applicationArchitecture.test.mjs +++ b/apps/control-station/test/applicationArchitecture.test.mjs @@ -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, / { 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, / { + 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\)/); +}); diff --git a/docs/15_LABORATORY_RUN_CANON.md b/docs/15_LABORATORY_RUN_CANON.md index 9b6fc47..7413cb4 100644 --- a/docs/15_LABORATORY_RUN_CANON.md +++ b/docs/15_LABORATORY_RUN_CANON.md @@ -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: diff --git a/docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md b/docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md index 46cc666..95ad3cf 100644 --- a/docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md +++ b/docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md @@ -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. diff --git a/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md b/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md index 2cadc3c..126c9e7 100644 --- a/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md +++ b/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md @@ -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. diff --git a/src/k1link/web/advanced_laboratory_api.py b/src/k1link/web/advanced_laboratory_api.py index 0367f6c..7d5b0d2 100644 --- a/src/k1link/web/advanced_laboratory_api.py +++ b/src/k1link/web/advanced_laboratory_api.py @@ -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"),