From 001d597a899359237a99a6e885e9cf580c50ac45 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 27 Jul 2026 11:00:32 +0300 Subject: [PATCH] feat(lab): complete E30 evidence review gate --- .../skills/mission-core-product-ui/SKILL.md | 172 +++ .../agents/openai.yaml | 4 + AGENTS.md | 62 + README.md | 6 + .../E30EngineeringGenerationSummary.tsx | 55 + .../laboratory/E30EvidenceTelemetry.tsx | 74 ++ .../laboratory/LaboratoryEvidenceViewer.tsx | 83 ++ .../laboratory/LaboratoryPresentation.tsx | 215 ++++ .../src/core/laboratory/e30Engineering.ts | 659 ++++++++++ .../src/core/laboratory/e30HumanReview.ts | 459 +++++++ .../src/core/laboratory/e30Review.ts | 759 +++++++++++ apps/control-station/src/styles.css | 2 + .../src/styles/e30-human-review.css | 150 +++ .../control-station/src/styles/laboratory.css | 868 +++++++++++++ .../control-station/src/styles/workspaces.css | 443 +------ .../src/workspaces/E30EvidencePointCloud.tsx | 468 +++++++ .../src/workspaces/E30EvidenceProjection.tsx | 215 ++++ .../src/workspaces/E30HumanReviewPanel.tsx | 283 +++++ .../src/workspaces/E30ReviewWorkspace.tsx | 362 ++++++ .../src/workspaces/Workspaces.tsx | 1096 +--------------- .../src/workspaces/contracts.ts | 59 + .../laboratory/LaboratoryArchiveWorkspace.tsx | 882 +++++++++++++ .../test/applicationArchitecture.test.mjs | 121 ++ .../test/e30Engineering.test.mjs | 194 +++ .../test/e30HumanReview.test.mjs | 207 +++ apps/control-station/test/e30Review.test.mjs | 244 ++++ .../test/laboratoryProductUi.test.mjs | 80 ++ .../test/observationSessions.test.mjs | 17 +- .../test/productShellContract.test.mjs | 27 +- docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md | 19 +- docs/15_LABORATORY_RUN_CANON.md | 6 +- ...16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md | 157 +++ ...7_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md | 206 +++ docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md | 228 ++++ docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md | 196 +++ ...n-capabilities-and-free-space-admission.md | 79 ++ .../issue_e30_codex_engineering_generation.py | 503 ++++++++ .../render_e30_engineering_review_sheets.py | 498 ++++++++ .../perception/run_e30_materialization.py | 60 + src/k1link/compute/e30_camera_evidence.py | 259 ++++ .../compute/e30_engineering_generation.py | 645 ++++++++++ src/k1link/compute/e30_human_review.py | 759 +++++++++++ src/k1link/compute/e30_materialization.py | 1112 +++++++++++++++++ src/k1link/compute/e30_review_pack.py | 734 +++++++++++ .../compute/semantic_geometry_fusion.py | 83 +- src/k1link/compute/sensor_representation.py | 484 +++++++ src/k1link/web/app.py | 85 ++ src/k1link/web/e30_engineering_api.py | 690 ++++++++++ src/k1link/web/e30_human_review_api.py | 263 ++++ src/k1link/web/e30_review_api.py | 818 ++++++++++++ tests/test_e30_engineering_generation.py | 158 +++ tests/test_e30_human_review.py | 344 +++++ tests/test_e30_materialization.py | 394 ++++++ tests/test_e30_review_pack.py | 228 ++++ tests/test_sensor_representation.py | 171 +++ 55 files changed, 15897 insertions(+), 1548 deletions(-) create mode 100644 .codex/skills/mission-core-product-ui/SKILL.md create mode 100644 .codex/skills/mission-core-product-ui/agents/openai.yaml create mode 100644 apps/control-station/src/components/laboratory/E30EngineeringGenerationSummary.tsx create mode 100644 apps/control-station/src/components/laboratory/E30EvidenceTelemetry.tsx create mode 100644 apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx create mode 100644 apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx create mode 100644 apps/control-station/src/core/laboratory/e30Engineering.ts create mode 100644 apps/control-station/src/core/laboratory/e30HumanReview.ts create mode 100644 apps/control-station/src/core/laboratory/e30Review.ts create mode 100644 apps/control-station/src/styles/e30-human-review.css create mode 100644 apps/control-station/src/styles/laboratory.css create mode 100644 apps/control-station/src/workspaces/E30EvidencePointCloud.tsx create mode 100644 apps/control-station/src/workspaces/E30EvidenceProjection.tsx create mode 100644 apps/control-station/src/workspaces/E30HumanReviewPanel.tsx create mode 100644 apps/control-station/src/workspaces/E30ReviewWorkspace.tsx create mode 100644 apps/control-station/src/workspaces/contracts.ts create mode 100644 apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx create mode 100644 apps/control-station/test/applicationArchitecture.test.mjs create mode 100644 apps/control-station/test/e30Engineering.test.mjs create mode 100644 apps/control-station/test/e30HumanReview.test.mjs create mode 100644 apps/control-station/test/e30Review.test.mjs create mode 100644 apps/control-station/test/laboratoryProductUi.test.mjs create mode 100644 docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md create mode 100644 docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md create mode 100644 docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md create mode 100644 docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md create mode 100644 docs/adr/0023-sensor-representation-capabilities-and-free-space-admission.md create mode 100644 experiments/perception/issue_e30_codex_engineering_generation.py create mode 100644 experiments/perception/render_e30_engineering_review_sheets.py create mode 100644 experiments/perception/run_e30_materialization.py create mode 100644 src/k1link/compute/e30_camera_evidence.py create mode 100644 src/k1link/compute/e30_engineering_generation.py create mode 100644 src/k1link/compute/e30_human_review.py create mode 100644 src/k1link/compute/e30_materialization.py create mode 100644 src/k1link/compute/e30_review_pack.py create mode 100644 src/k1link/compute/sensor_representation.py create mode 100644 src/k1link/web/e30_engineering_api.py create mode 100644 src/k1link/web/e30_human_review_api.py create mode 100644 src/k1link/web/e30_review_api.py create mode 100644 tests/test_e30_engineering_generation.py create mode 100644 tests/test_e30_human_review.py create mode 100644 tests/test_e30_materialization.py create mode 100644 tests/test_e30_review_pack.py create mode 100644 tests/test_sensor_representation.py diff --git a/.codex/skills/mission-core-product-ui/SKILL.md b/.codex/skills/mission-core-product-ui/SKILL.md new file mode 100644 index 0000000..f14d2cb --- /dev/null +++ b/.codex/skills/mission-core-product-ui/SKILL.md @@ -0,0 +1,172 @@ +--- +name: mission-core-product-ui +description: Design and enforce Mission Core product surfaces, information architecture, product UI, and laboratory presentation. Use for every new or relocated workspace, product root, navigation category, panel, viewer, Control Station surface, LAB catalog/summary, visualization control, fullscreen behavior, product-facing status, or UI refactor in NODEDC_MISSION_CORE, including work that could introduce a new component, page pattern, or task-specific composition. +--- + +# Mission Core product UI + +## Required context + +Before changing UI: + +1. Read the repository `AGENTS.md`. +2. Read `docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md`. +3. Read `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`. +4. For a new, relocated, or materially changed non-LAB surface, read + `docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`. +5. For LAB work, also read `docs/15_LABORATORY_RUN_CANON.md`. +6. Read the sibling Design Guideline: + - `registry/registry.json` + - `registry/components.json` + - `registry/icons.json` + - `docs/COMPONENTS.md` + - `docs/GOVERNANCE.md` + - the documentation for the affected page pattern or component. + +Do not start implementation before resolving the required controls and icons in +the registries. + +## Component gate + +- Reuse package exports from `@nodedc/ui-react`, `@nodedc/ui-core`, + `@nodedc/tokens`, and `@nodedc/page-patterns`. +- Do not create a local substitute for a button, icon button, segmented switch, + select, dropdown, glass surface, status, window, focus state, hover state, or + page pattern. +- Treat an absent generic visual entity as a product decision. Stop, describe + the user need and proposed generic API, and obtain explicit approval. +- After approval, implement the generic entity in the Design Guideline first, + including registry, documentation, interaction states, catalog example, and + validation. Consume it from Mission Core only afterward. +- Allow Mission Core domain renderers, data adapters, and domain layouts. Build + their controls from canonical exports. + +Explicit approval for one entity does not authorize unrelated visual entities. + +## Product surface design + +Keep product grammar stable without forcing one layout: + +- Reuse the canonical shell, controls, states, icons, focus, motion, and + responsive behavior. +- Change composition when the operator job, primary entity, lifecycle, or + action model changes materially. +- Do not treat the LAB template as the default for non-LAB work. +- Choose the smallest complete surface: view mode, inspector/panel, floating + window, existing section, dedicated workspace, product root, plugin slot, or + LAB result. + +Before adding or relocating a workspace or changing primary navigation: + +1. State the operator job, trigger/frequency, primary entity/lifecycle, + evidence, authority, actions, and complete state grammar. +2. Compare at least two credible placements or compositions. +3. Resolve the Design Guideline primitives and page patterns. +4. Classify the novelty: + - domain content in an admitted composition; + - new Mission Core-specific composition; + - new shared Design Guideline entity/pattern; + - new product root or platform meaning. +5. Present the selected placement and rejected alternative with UX reasoning. +6. Obtain product-owner agreement for a new/relocated workspace, primary + navigation change, new root, or shared visual pattern. +7. Implement only after the decision; do not add placeholder product routes or + temporary navigation. + +Valid task-specific compositions include scene-first, map-first, +timeline-first, list/detail queue, table/catalog, form/editor, graph/topology, +dashboard, and immutable LAB review. + +## Application architecture + +- Preserve `core → components/renderers → workspaces → composition/App`. +- Core owns contracts, adapters, state machines, and domain hooks. It must not + import visual adapters, workspaces, or App. +- Reusable components may consume Core and canonical UI exports. They must not + import workspaces or own product navigation. +- Add every new domain or LAB run as a bounded feature module. Do not append its + API adapter, renderer, selectors, or styles to `App.tsx`, + `Workspaces.tsx`, or `styles/workspaces.css`. +- Keep LAB composition under `workspaces/laboratory` and pass generic host + viewers through typed contracts. +- Treat `productModel.ts` as the application registry. Do not duplicate root, + workspace, icon, or capability lists in the shell. +- Do not introduce a runtime ontology merely to organize code. Use the typed + registry, executable contracts, and experimental vocabulary until the + multi-consumer admission gate in the architecture canon is met. +- Never raise an architecture line-count ratchet to fit a new feature. Split + the feature and retain the boundary test. + +## Laboratory product contract + +Use one template: + +`selectors → compact summary → evidence → result → optional reusable details` + +The compact summary must cover: + +- decision question and purpose; +- immutable source and tested bounds; +- pipeline, execution class, models, algorithms, tools, and worker/runtime; +- experimental feature or configuration; +- principal result and limitation; +- retained authority. + +Keep manifests, long hashes, implementation files, validation logs, regressions, +rejected approaches, and next-stage planning in the Mission Core Ops report. + +Do not expose roadmap steps, next-gate checklists, internal reason taxonomies, +debug controls, placeholder blocks, or implementation scaffolding in product +UI. + +## Evidence viewers + +- Use one reusable viewer frame for related representations. +- Provide canonical expand/restore through `IconButton` and `Icon`. +- Provide mode switching through `SegmentedControl`. +- Preserve selected evidence and mode across resize. +- Use 3D for spatial shape, range, height, topology, support, and occupied + volume. +- Use 2D for camera-plane reprojection, bbox/mask agreement, calibration, + field-of-view, occlusion, and pixel correspondence. +- When both are useful, keep them as modes of the same viewer and share source + indices and selected case. +- Keep renderer colors semantic and token-derived. Never use them to invent + control states or persistent colored outlines. + +## Ops report boundary + +When the task includes reporting a completed LAB or architecture milestone, use +the direct `nodedc-ops-agent` tools and write titled structured blocks in this +order: + +1. Objective and architecture stage. +2. Decision question and hypothesis. +3. Immutable source evidence and bounds. +4. Method, preprocessing, models, algorithms, tools, and identities. +5. Worker/runtime topology and resource policy. +6. Experimental implementation. +7. Validation and reproduced evidence. +8. Results. +9. Regressions, rejected approaches, and limitations. +10. Decision. +11. Next stage and forbidden authority. +12. Acceptance checker. + +Do not turn the product UI into a duplicate Ops report. + +## Validation + +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, + selected placement, rejected alternative, state grammar, and real acceptance + evidence. +3. 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 + 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. diff --git a/.codex/skills/mission-core-product-ui/agents/openai.yaml b/.codex/skills/mission-core-product-ui/agents/openai.yaml new file mode 100644 index 0000000..f74eea1 --- /dev/null +++ b/.codex/skills/mission-core-product-ui/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Mission Core Product UI" + short_description: "Mission Core surfaces and LAB product architecture" + default_prompt: "Use $mission-core-product-ui to design or review a Mission Core product surface and its placement." diff --git a/AGENTS.md b/AGENTS.md index 9d04394..ddfc144 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,3 +39,65 @@ and the boundary between Mission Core and vendor-specific integration code. Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy decoders before BLE/Wi-Fi/data-session evidence exists. + +## Product UI governance + +- For every Control Station, LAB, viewer, or product-presentation change, use + `.codex/skills/mission-core-product-ui/SKILL.md` and follow + `docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md` and + `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`. +- `NODEDC_DESIGN_GUIDELINE` is the only visual-design source of truth. Before + editing UI, read its `registry/registry.json`, `registry/components.json`, + `registry/icons.json`, and the relevant component documentation. +- Reuse `@nodedc/ui-react`, `@nodedc/ui-core`, tokens, icons, and page patterns. + Do not create an application-local visual control, interaction state, + geometry, color language, or copy of a design-system component. +- If the required visual entity is absent from the Design Guideline, stop and + obtain explicit product-owner approval. After approval, add it to the Design + Guideline first with registry, documentation, states, and validation; only + then consume it here. +- Domain renderers may remain Mission Core code when they visualize Mission + Core data. Their controls and containing surfaces must still be composed from + canonical Design Guideline exports. +- Product UI must not expose implementation steps, roadmap gates, internal + reason taxonomies, debug controls, placeholder status blocks, or temporary + experiment scaffolding. Keep these in Ops, engineering reports, or developer + tooling. +- Extend one reusable application pattern instead of adding per-LAB layouts. + A new LAB supplies data and renderer configuration; it does not invent a new + page hierarchy or visual language. +- Do not generalize the LAB hierarchy into a universal application template. + For a new non-LAB interface, follow + `docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`: identify the operator job, + compare placement/composition alternatives, classify the novelty, and obtain + product-owner agreement before adding a workspace, changing primary + navigation, or introducing a new product root. +- Preserve one product grammar while allowing task-specific composition. A + scene, map, timeline, queue, editor, topology, dashboard, and LAB review may + have different layouts when their entity, lifecycle, or action model differs. +- Preserve the internal dependency direction: + `core → components/renderers → workspaces → composition/App`. Core never + imports workspaces or visual adapters; reusable components never import + workspaces. New domains and LAB runs get their own feature module instead of + growing `App.tsx`, `Workspaces.tsx`, or generic CSS buckets. +- Treat `productModel.ts`, executable versioned contracts, and the experimental + vocabulary as the current local semantic sources. Do not add a parallel + runtime ontology unless the admission conditions in + `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md` are met. + +## Laboratory presentation and reporting + +- Use one fixed laboratory presentation contract: selectors, one compact + canonical summary, admitted evidence viewer(s), result, and optional reusable + technical details. +- 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. +- The complete engineering report belongs in the Mission Core Ops card as + titled structured blocks: objective and architecture stage, source evidence, + method/models/algorithms, worker/runtime, implementation, validation, + results, regressions and limitations, decision, next stage, and acceptance + checker. +- Spatial evidence uses 3D by default. Image-space reprojection may be offered + as a 2D diagnostic mode inside the same reusable viewer. Primary evidence + viewers must provide the canonical expand/restore action. diff --git a/README.md b/README.md index 39d29db..d108ed1 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,11 @@ present. - [Technical audit](docs/00_TECHNICAL_AUDIT.md) - [Implementation gates](docs/01_IMPLEMENTATION_PLAN.md) +- [Architecture audit execution roadmap](docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md) +- [Laboratory run canon](docs/15_LABORATORY_RUN_CANON.md) +- [Product UI and laboratory presentation canon](docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md) +- [Application component architecture](docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md) +- [Product surface extension protocol](docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md) - [First lab runbook](docs/02_FIRST_LAB_RUNBOOK.md) - [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md) - [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md) @@ -579,6 +584,7 @@ present. - [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md) - [Simulation Polygon qualification boundary](docs/adr/0015-simulation-polygon-qualification-boundary.md) - [Distributed product, edge and worker topology](docs/adr/0016-distributed-product-edge-and-worker-topology.md) +- [Sensor representation capabilities and free-space admission](docs/adr/0023-sensor-representation-capabilities-and-free-space-admission.md) - [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md) - [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md) - [Session manifest schema](schemas/session-manifest.schema.json) diff --git a/apps/control-station/src/components/laboratory/E30EngineeringGenerationSummary.tsx b/apps/control-station/src/components/laboratory/E30EngineeringGenerationSummary.tsx new file mode 100644 index 0000000..e37ab10 --- /dev/null +++ b/apps/control-station/src/components/laboratory/E30EngineeringGenerationSummary.tsx @@ -0,0 +1,55 @@ +import { StatusBadge } from "@nodedc/ui-react"; + +import type { E30EngineeringGeneration } from "../../core/laboratory/e30Engineering"; + +export function E30EngineeringGenerationSummary({ + generation, +}: { + generation: E30EngineeringGeneration; +}) { + const summary = generation.summary; + const confirmed = summary.verdictDistribution.confirmed ?? 0; + const corrected = summary.verdictDistribution.corrected ?? 0; + + return ( +
+
+
+ A3 · IMMUTABLE ENGINEERING GENERATION +
+ + {summary.reviewedItemCount} / {summary.itemCount} + +
+ +
+
+
Подтверждено
+
{confirmed.toLocaleString("ru-RU")}
+
+
+
Исправлено
+
{corrected.toLocaleString("ru-RU")}
+
+
+
Исключения
+
{summary.humanExceptionCount.toLocaleString("ru-RU")}
+
+
+
Средняя уверенность
+
+ {(summary.meanConfidence * 100).toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })} + % +
+
+
+
+ ); +} diff --git a/apps/control-station/src/components/laboratory/E30EvidenceTelemetry.tsx b/apps/control-station/src/components/laboratory/E30EvidenceTelemetry.tsx new file mode 100644 index 0000000..442b59e --- /dev/null +++ b/apps/control-station/src/components/laboratory/E30EvidenceTelemetry.tsx @@ -0,0 +1,74 @@ +import type { E30ReviewItemDetail } from "../../core/laboratory/e30Review"; + +function formatNumber(value: number): string { + return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 }); +} + +function evidenceRange(item: E30ReviewItemDetail): string { + const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM; + return value === null ? "недоступна" : `${formatNumber(value)} м`; +} + +export function E30EvidenceTelemetry({ + detail, + mode, +}: { + detail: E30ReviewItemDetail; + mode: "camera" | "3d"; +}) { + return ( + + ); +} diff --git a/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx b/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx new file mode 100644 index 0000000..cbac5ce --- /dev/null +++ b/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx @@ -0,0 +1,83 @@ +import { + useEffect, + useRef, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { + Icon, + IconButton, + SegmentedControl, +} from "@nodedc/ui-react"; + +export interface LaboratoryEvidenceViewerMode { + value: T; + label: string; +} + +export function LaboratoryEvidenceViewer({ + label, + mode, + modes, + expanded, + onModeChange, + onExpandedChange, + actions, + overlay, + children, +}: { + label: string; + mode: T; + modes: readonly LaboratoryEvidenceViewerMode[]; + expanded: boolean; + onModeChange: (mode: T) => void; + onExpandedChange: (expanded: boolean) => void; + actions?: ReactNode; + overlay?: ReactNode; + children: ReactNode; +}) { + const expandButtonRef = useRef(null); + + useEffect(() => { + if (!expanded) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + onExpandedChange(false); + window.requestAnimationFrame(() => expandButtonRef.current?.focus()); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [expanded, onExpandedChange]); + + const viewer = ( +
+
+ {children} +
+ {overlay} +
+ {actions} + + onExpandedChange(!expanded)} + > + + +
+
+ ); + + return expanded ? createPortal(viewer, document.body) : viewer; +} diff --git a/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx b/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx new file mode 100644 index 0000000..a61a39c --- /dev/null +++ b/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx @@ -0,0 +1,215 @@ +import type { ReactNode } from "react"; +import { + Select, + StatusBadge, +} from "@nodedc/ui-react"; + +export interface LaboratoryOption { + id: T; + label: string; +} + +export type LaboratoryExecutionClass = + | "deterministic" + | "ai-inference" + | "hybrid"; +export type LaboratoryMethodCompleteness = "complete" | "legacy-partial"; +export type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model"; + +export interface LaboratoryMethodComponent { + kind: "source" | "tool" | "model" | "algorithm" | "runtime"; + name: string; + version: string; + role: string; + identitySha256: string | null; +} + +export interface LaboratoryMethod { + completeness: LaboratoryMethodCompleteness; + executionClass: LaboratoryExecutionClass; + pipelineId: string; + components: readonly LaboratoryMethodComponent[]; +} + +const EXECUTION_LABELS: Record = { + deterministic: "Детерминированный", + "ai-inference": "AI inference", + hybrid: "Гибридный", +}; + +const COMPONENT_LABELS: Record = { + source: "Источник", + tool: "Инструмент", + model: "Модель", + algorithm: "Алгоритм", + runtime: "Runtime", +}; + +export function LaboratorySelector({ + eyebrow, + title, + description, + label, + value, + options, + disabled = false, + onChange, +}: { + eyebrow: string; + title: string; + description: string; + label: string; + value: T; + options: readonly LaboratoryOption[]; + disabled?: boolean; + onChange: (value: T) => void; +}) { + return ( +
+
+ {eyebrow} +

{title}

+

{description}

+
+
+ {label} + +
+ setNotes(event.currentTarget.value)} + /> + +
+ Что изменится после решения + {reviewPrompt.effects[disposition]} +
+
+ + {currentDecision + ? "Этот кадр уже решён — его можно пересмотреть." + : `${review.remainingItemCount} решений осталось.`} + + + +
+ + ) : null} + + {error ? ( +

{error}

+ ) : null} + + + Будет создан неизменяемый набор из {review.itemCount} решений. + После фиксации их нельзя будет изменить. +

+ )} + confirmLabel="Зафиксировать" + pendingLabel="Фиксируем…" + onClose={() => setFinalizeOpen(false)} + onConfirm={finalize} + /> +
+ ); +} diff --git a/apps/control-station/src/workspaces/E30ReviewWorkspace.tsx b/apps/control-station/src/workspaces/E30ReviewWorkspace.tsx new file mode 100644 index 0000000..d17ab40 --- /dev/null +++ b/apps/control-station/src/workspaces/E30ReviewWorkspace.tsx @@ -0,0 +1,362 @@ +import { useEffect, useState } from "react"; +import { + Button, + GlassSurface, + Icon, + SegmentedControl, + StatusBadge, +} from "@nodedc/ui-react"; + +import { LaboratoryEvidenceViewer } from "../components/laboratory/LaboratoryEvidenceViewer"; +import { E30EvidenceTelemetry } from "../components/laboratory/E30EvidenceTelemetry"; +import { E30EngineeringGenerationSummary } from "../components/laboratory/E30EngineeringGenerationSummary"; +import { + fetchE30EngineeringCatalog, + fetchE30EngineeringExceptions, + type E30EngineeringGeneration, +} from "../core/laboratory/e30Engineering"; +import type { E30HumanReviewDraft } from "../core/laboratory/e30HumanReview"; +import { + E30_STRATA, + fetchE30ReviewItemDetail, + fetchE30ReviewItems, + type E30ReviewItem, + type E30ReviewItemDetail, + type E30ReviewResult, + type E30Stratum, +} from "../core/laboratory/e30Review"; +import { formatNumber } from "../presentation"; +import { E30EvidencePointCloud } from "./E30EvidencePointCloud"; +import { E30EvidenceProjection } from "./E30EvidenceProjection"; +import { E30HumanReviewPanel } from "./E30HumanReviewPanel"; + +type E30EvidenceMode = "camera" | "3d"; +type E30Filter = E30Stratum | "review"; + +const FILTER_LABELS: Record = { + conflict: "Конфликт", + agree: "Согласовано", + "camera-only": "Только камера", + unknown: "Неизвестно", + "geometry-only": "Только геометрия", + review: "Проверка", +}; +const FILTERS: readonly E30Filter[] = [...E30_STRATA, "review"]; + +function formatSeconds(value: number): string { + return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`; +} + +function itemTitle(item: E30ReviewItem): string { + if (item.snapshot.label) return item.snapshot.label; + return item.locatorKind === "geometry-only-cluster" + ? "Геометрический кластер" + : "Семантическое наблюдение"; +} + +function evidenceRange(item: E30ReviewItem): string { + const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM; + return value === null + ? "Дальность недоступна" + : `${value.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`; +} + +export function E30ReviewWorkspace({ + result, +}: { + result: E30ReviewResult; +}) { + const [filter, setFilter] = useState("conflict"); + const [items, setItems] = useState([]); + const [itemTotal, setItemTotal] = useState(result.stratumCounts.conflict); + const [selectedItemId, setSelectedItemId] = useState(null); + const [detail, setDetail] = useState(null); + const [itemsLoading, setItemsLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [error, setError] = useState(null); + const [evidenceMode, setEvidenceMode] = useState("camera"); + const [pointLayerVisible, setPointLayerVisible] = useState(true); + const [viewerExpanded, setViewerExpanded] = useState(false); + const [engineeringGeneration, setEngineeringGeneration] = + useState(null); + const [engineeringLoading, setEngineeringLoading] = useState(true); + const [humanReview, setHumanReview] = + useState(null); + + useEffect(() => { + if (filter === "review" && !engineeringGeneration) { + setItems([]); + setItemTotal(0); + setSelectedItemId(null); + setItemsLoading(engineeringLoading); + return; + } + const controller = new AbortController(); + setItemsLoading(true); + setError(null); + setDetail(null); + const request = filter === "review" + ? fetchE30EngineeringExceptions( + result.resultId, + engineeringGeneration!.generationId, + { signal: controller.signal }, + ) + : fetchE30ReviewItems(result.resultId, filter, { + signal: controller.signal, + }); + void request.then((next) => { + setItems(next.items); + setItemTotal(next.total); + setSelectedItemId((current) => ( + next.items.some((item) => item.itemId === current) + ? current + : next.items[0]?.itemId ?? null + )); + }).catch((caught: unknown) => { + if (controller.signal.aborted) return; + setItems([]); + setSelectedItemId(null); + setError(caught instanceof Error ? caught.message : "Выборка E30 недоступна."); + }).finally(() => { + if (!controller.signal.aborted) setItemsLoading(false); + }); + return () => controller.abort(); + }, [ + engineeringGeneration, + engineeringLoading, + filter, + result.resultId, + ]); + + useEffect(() => { + const controller = new AbortController(); + setEngineeringLoading(true); + setEngineeringGeneration(null); + void fetchE30EngineeringCatalog(result.resultId, { + signal: controller.signal, + }).then((catalog) => { + setEngineeringGeneration(catalog.items[0] ?? null); + }).catch(() => { + if (!controller.signal.aborted) setEngineeringGeneration(null); + }).finally(() => { + if (!controller.signal.aborted) setEngineeringLoading(false); + }); + return () => controller.abort(); + }, [result.resultId]); + + useEffect(() => { + if (!selectedItemId) { + setDetail(null); + return; + } + const controller = new AbortController(); + setDetailLoading(true); + setError(null); + void fetchE30ReviewItemDetail(result.resultId, selectedItemId, { + signal: controller.signal, + }).then(setDetail).catch((caught: unknown) => { + if (controller.signal.aborted) return; + setDetail(null); + setError(caught instanceof Error ? caught.message : "Доказательство E30 недоступно."); + }).finally(() => { + if (!controller.signal.aborted) setDetailLoading(false); + }); + return () => controller.abort(); + }, [result.resultId, selectedItemId]); + + const selectItem = (item: E30ReviewItem) => { + setSelectedItemId(item.itemId); + }; + + const advanceAfterDecision = (next: E30HumanReviewDraft) => { + const resolved = new Set(next.decisions.map((decision) => decision.itemId)); + const currentIndex = items.findIndex((item) => item.itemId === selectedItemId); + const ordered = [ + ...items.slice(currentIndex + 1), + ...items.slice(0, currentIndex + 1), + ]; + const unresolved = ordered.find((item) => !resolved.has(item.itemId)); + if (unresolved) setSelectedItemId(unresolved.itemId); + }; + + return ( + +
+
+ CAMERA-BACKED REVIEW SUBSTRATE +

A2 evidence · A3 engineering audit

+

+ Точный camera frame, LiDAR-проекция и синхронный 3D сохраняют A2 + неизменяемым. A3 выпускает отдельные решения с явным provenance. +

+
+ + {result.cameraEvidenceAvailable + ? "Camera evidence привязано" + : "Camera evidence отсутствует"} + +
+ + {engineeringGeneration ? ( + + ) : null} + + ({ + value, + label: `${FILTER_LABELS[value]} · ${formatNumber( + value === "review" + ? engineeringGeneration?.summary.humanExceptionCount ?? 0 + : result.stratumCounts[value], + 0, + )}`, + }))} + onChange={setFilter} + /> + +
+ + +
+ {detailLoading ? ( +
+
+ ) : error || !detail ? ( +
+ + {error ?? "Выберите кейс."} +
+ ) : ( + <> +
+
+ {detail.reviewKey} +

{itemTitle(detail)}

+

+ {detail.snapshot.geometryReason ?? "Независимый geometry-only слой"} +

+
+ + {FILTER_LABELS[detail.stratum]} + +
+
+ } + aria-pressed={pointLayerVisible} + onClick={() => setPointLayerVisible((visible) => !visible)} + > + LiDAR + + ) : undefined} + overlay={( + + )} + > + {evidenceMode === "camera" ? ( + + ) : ( + + )} + +
+ + )} +
+
+ + {!detailLoading + && !error + && filter === "review" + && engineeringGeneration + && detail ? ( + + ) : null} +
+ ); +} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index a2f5ccf..34cd4dd 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -4,20 +4,14 @@ import { useMemo, useRef, useState, - type ComponentType, - type ReactNode, } from "react"; import { Button, GlassSurface, Icon, - Select, StatusBadge, } from "@nodedc/ui-react"; -import { - type ObservationSessionReplayCallbacks, -} from "../components/ObservationSessionSelect"; import { ObservationMedia, ObservationSourcePicker, @@ -25,36 +19,11 @@ import { } from "../components/ObservationSources"; import { ObservationTimeline } from "../components/ObservationTimeline"; import { FloatingObservationWindow } from "../components/FloatingObservationWindow"; -import type { - ObservationSessionReplayLaunch, - ObservationSessionSummary, -} from "../core/observation/sessionArchive"; -import { useObservationSessions } from "../core/observation/useObservationSessions"; -import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission"; import type { RecordedAdmissionPhase, RecordedCameraAdmissionState, } from "../core/observation/recordedSessionAdmission"; -import type { ObservationLayoutController } from "../core/observation/useObservationLayout"; -import type { - DeviceModelDefinition, - DevicePluginConnectionProps, -} from "../core/device-plugins/contracts"; -import type { - BackendStatus, - MissionRuntimeState, - ObservationSourceDescriptor, -} from "../core/runtime/contracts"; -import { - fetchE29EvidenceCatalog, - fetchE29EvidenceFrame, - type E29EvidenceFrame, - type E29EvidenceResult, -} from "../core/laboratory/e29Evidence"; -import { - fetchLidarLocalSurfaces, - type LidarLocalSurfaceModel, -} from "../core/lidar/localSurface"; +import type { ObservationSourceDescriptor } from "../core/runtime/contracts"; import { RerunViewport, isRecordedPlaybackPresentationReady, @@ -73,9 +42,10 @@ import { } from "../productModel"; import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation"; import type { SceneSettings } from "../sceneSettings"; -import { LidarQualityWorkspace } from "./LidarQualityWorkspace"; +import type { WorkspaceRendererProps } from "./contracts"; import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace"; import { ContourHealthWorkspace } from "./ContourHealthWorkspace"; +import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace"; function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" { if (status === "active") return "success"; @@ -127,48 +97,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; ); } -export interface WorkspaceNavigation { - openView: (viewId: string) => void; - openSource: () => void; - openDisplay: () => void; - openLayers: () => void; - activateAutomaticSpatialSource: () => void; -} - -export interface WorkspaceRendererProps { - definition: WorkspaceDefinition; - state: MissionRuntimeState | null; - backendStatus: BackendStatus; - sourceUrl: string; - recordedReplay: ObservationSessionReplayLaunch | null; - recordedSessionAdmission: RecordedSessionAdmissionController | null; - sceneSettings: SceneSettings; - accumulationSeconds: number; - onAccumulationChange: (value: number) => void; - onAccumulationCommit: () => void; - livePerceptionLayers: { - detections2d: boolean; - segmentation: boolean; - cuboids3d: boolean; - }; - onLivePerceptionLayersChange: (next: { - detections2d: boolean; - segmentation: boolean; - cuboids3d: boolean; - }) => void; - observationLayout: ObservationLayoutController; - deviceLabel: string | null; - navigation: WorkspaceNavigation; - spatialControls: { - View: ComponentType; - model: DeviceModelDefinition; - } | null; - sessionArchive: ObservationSessionReplayCallbacks & { - disabled: boolean; - blockedReason: string | null; - }; -} - function EmptySpatialStage({ settings }: { settings: SceneSettings }) { return (
@@ -192,6 +120,7 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) { function SpatialWorkspace({ state, sourceUrl, + requestedPlaybackSeconds, recordedReplay, recordedSessionAdmission, sceneSettings, @@ -209,6 +138,7 @@ function SpatialWorkspace({ const [selection, setSelection] = useState(null); const [playbackState, setPlaybackState] = useState(null); const [playbackController, setPlaybackController] = useState(null); + const lastRequestedPlaybackSeconds = useRef(null); const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0); const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false); const [perceptionLoad, setPerceptionLoad] = useState({ @@ -401,6 +331,24 @@ function SpatialWorkspace({ }); }, [pointCloudVisible, sourceUrl]); + useEffect(() => { + lastRequestedPlaybackSeconds.current = null; + }, [sourceUrl]); + + useEffect(() => { + if ( + !recordedSource + || !playbackController + || requestedPlaybackSeconds === null + || requestedPlaybackSeconds === undefined + || !Number.isFinite(requestedPlaybackSeconds) + || lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds + ) return; + lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds; + playbackController.setPlaying(false); + playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000)); + }, [playbackController, recordedSource, requestedPlaybackSeconds]); + useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; @@ -1196,997 +1144,6 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) { ); } -type LaboratoryProfileId = "sensor-fusion" | "published-perception"; -type LaboratoryWorkId = "e28-local-surface" | "e29-camera-geometry" | `session:${string}`; - -interface LaboratoryOption { - id: T; - label: string; -} - -type LaboratoryExecutionClass = "deterministic" | "ai-inference" | "hybrid"; -type LaboratoryMethodCompleteness = "complete" | "legacy-partial"; -type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model"; - -interface LaboratoryMethodComponent { - kind: "source" | "tool" | "model" | "algorithm" | "runtime"; - name: string; - version: string; - role: string; - identitySha256: string | null; -} - -interface LaboratoryMethod { - completeness: LaboratoryMethodCompleteness; - executionClass: LaboratoryExecutionClass; - pipelineId: string; - components: readonly LaboratoryMethodComponent[]; -} - -function digestFromContentId(value: string | null | undefined): string | null { - const digest = value?.split("-").at(-1) ?? ""; - return /^[a-f0-9]{64}$/.test(digest) ? digest : null; -} - -function publishedLaboratoryMethod( - session: ObservationSessionSummary, -): LaboratoryMethod { - const method = session.lab?.provenance.method; - if (method && typeof method === "object" && !Array.isArray(method)) { - const value = method as Record; - const rawComponents = Array.isArray(value.components) ? value.components : []; - const components: LaboratoryMethodComponent[] = rawComponents.flatMap((component) => { - if (!component || typeof component !== "object" || Array.isArray(component)) return []; - const item = component as Record; - const kind = item.kind; - if ( - kind !== "source" - && kind !== "tool" - && kind !== "model" - && kind !== "algorithm" - && kind !== "runtime" - ) return []; - if ( - typeof item.name !== "string" - || typeof item.version !== "string" - || typeof item.role !== "string" - ) return []; - return [{ - kind: kind as LaboratoryMethodComponent["kind"], - name: item.name, - version: item.version, - role: item.role, - identitySha256: typeof item.identity_sha256 === "string" - ? item.identity_sha256 - : null, - }]; - }); - const executionClass = value.execution_class; - const completeness = value.completeness; - if ( - components.length - && typeof value.pipeline_id === "string" - && ( - executionClass === "deterministic" - || executionClass === "ai-inference" - || executionClass === "hybrid" - ) - && (completeness === "complete" || completeness === "legacy-partial") - ) { - return { - completeness, - executionClass, - pipelineId: value.pipeline_id, - components, - }; - } - } - - const resultKind = session.lab?.resultKind ?? "unknown"; - const algorithmNames: Record = { - "e10-integrated-perception": "Camera semantics + LiDAR metric fusion", - "e21-realtime-envelope": "Bounded real-time perception replay", - "e22-temporal-stability": "Temporal 2D/3D/semantic stabilization", - "e23-inline-temporal-stability": "Inline warm-worker stabilization", - "e24-world-motion": "World-frame motion tracking", - "e25-persistent-support-motion": "Persistent occupied-support tracking", - "e26-camera-ego-motion-fusion": "KB4 ego-motion + persistent LiDAR support", - }; - return { - completeness: "legacy-partial", - executionClass: "hybrid", - pipelineId: resultKind, - components: [ - { - kind: "source", - name: session.lab?.sourceResultId ?? session.lab?.sourceSessionId ?? session.id, - version: "immutable source evidence", - role: "read-only input", - identitySha256: digestFromContentId(session.lab?.sourceResultId), - }, - { - kind: "algorithm", - name: algorithmNames[resultKind] ?? resultKind, - version: resultKind, - role: "laboratory derivative", - identitySha256: session.lab?.configSha256 ?? null, - }, - ], - }; -} - -function LaboratorySelector({ - eyebrow, - title, - description, - label, - value, - options, - disabled = false, - onChange, -}: { - eyebrow: string; - title: string; - description: string; - label: string; - value: T; - options: readonly LaboratoryOption[]; - disabled?: boolean; - onChange: (value: T) => void; -}) { - return ( -
-
- {eyebrow} -

{title}

-

{description}

-
-
- {label} -