diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 014eeeb..23f7571 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -863,6 +863,7 @@ export default function App() { onDeleteBegin: releaseRecordedReplayForDelete, }} onLaboratoryAnnotationActionChange={laboratoryAnnotation.setAction} + onLaboratoryViewActionChange={laboratoryAnnotation.setViewAction} navigation={{ openView, openSource, @@ -1243,7 +1244,6 @@ export default function App() { ]} /> - ); } diff --git a/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx b/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx index 8689a4f..6a27454 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryPresentation.tsx @@ -1,12 +1,10 @@ import type { ReactNode } from "react"; -import { - Select, - StatusBadge, -} from "@nodedc/ui-react"; +import { Select, StatusBadge } from "@nodedc/ui-react"; export interface LaboratoryOption { id: T; label: string; + status?: "progress" | "retained" | "failed" | "unreviewed"; } export type LaboratoryExecutionClass = @@ -103,6 +101,20 @@ export function LaboratorySelector({ options={options.map((option) => ({ value: option.id, label: option.label, + icon: option.status ? ( + + + ) : undefined, }))} variant="split" menuWidth="anchor" diff --git a/apps/control-station/src/components/laboratory/useLaboratoryAnnotationHeader.tsx b/apps/control-station/src/components/laboratory/useLaboratoryAnnotationHeader.tsx index fddb6ba..c8769e7 100644 --- a/apps/control-station/src/components/laboratory/useLaboratoryAnnotationHeader.tsx +++ b/apps/control-station/src/components/laboratory/useLaboratoryAnnotationHeader.tsx @@ -1,26 +1,41 @@ import { useState, type ReactNode } from "react"; import { Button, Icon } from "@nodedc/ui-react"; -import type { LaboratoryAnnotationAction } from "../../workspaces/contracts"; +import type { + LaboratoryAnnotationAction, + LaboratoryViewAction, +} from "../../workspaces/contracts"; export function useLaboratoryAnnotationHeader(): { control: ReactNode; setAction: (action: LaboratoryAnnotationAction | null) => void; + setViewAction: (action: LaboratoryViewAction | null) => void; } { const [action, setAction] = useState(null); + const [viewAction, setViewAction] = useState(null); return { setAction, - control: action ? ( - + setViewAction, + control: action || viewAction ? ( +
+ {viewAction ? ( + + ) : null} + {action ? ( + + ) : null} +
) : null, }; } diff --git a/apps/control-station/src/core/laboratory/evidenceReport.ts b/apps/control-station/src/core/laboratory/evidenceReport.ts new file mode 100644 index 0000000..af33693 --- /dev/null +++ b/apps/control-station/src/core/laboratory/evidenceReport.ts @@ -0,0 +1,197 @@ +export type LaboratoryEvidenceCompleteness = "recorded" | "not-recorded"; +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export interface LaboratoryEvidenceArtifact { + kind: string | null; + path: string; + byteLength: number; + sha256: string; + schemaVersion: string | null; + mediaType: string | null; + verified: true; +} + +export interface LaboratoryEvidenceReport { + schemaVersion: "missioncore.laboratory-evidence-report/v1"; + workId: string; + resultId: string; + createdAtUtc: string | null; + access: "read-only"; + proof: { + documentSchemaVersion: string; + documentSha256: string; + identitySha256: string; + reportSchemaVersion: string | null; + reportSha256: string | null; + artifactCount: number; + verifiedArtifactCount: number; + }; + completeness: Readonly>; + identity: Record; + source: Record | null; + configuration: Record | null; + method: Record | null; + execution: Record | null; + resources: Record | null; + metrics: Record | null; + gates: Record | null; + decision: JsonValue | undefined; + limitations: JsonValue | undefined; + authority: Record | null; + artifacts: readonly LaboratoryEvidenceArtifact[]; + visualEvidence: Record; + rawReport: Record; + canonicalJson: Record; +} + +export class LaboratoryEvidenceReportContractError extends Error {} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new LaboratoryEvidenceReportContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function nullableRecord(value: unknown, label: string): Record | null { + return value === null ? null : jsonRecord(value, label); +} + +function jsonRecord(value: unknown, label: string): Record { + const document = record(value, label); + for (const [key, item] of Object.entries(document)) validateJson(item, `${label}.${key}`); + return document as Record; +} + +function validateJson(value: unknown, label: string): asserts value is JsonValue { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) { + if (typeof value === "number" && !Number.isFinite(value)) { + throw new LaboratoryEvidenceReportContractError(`${label}: число не конечно.`); + } + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => validateJson(item, `${label}[${index}]`)); + return; + } + const document = record(value, label); + Object.entries(document).forEach(([key, item]) => validateJson(item, `${label}.${key}`)); +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new LaboratoryEvidenceReportContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function nullableText(value: unknown, label: string): string | null { + return value === null ? null : text(value, label); +} + +function integer(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new LaboratoryEvidenceReportContractError(`${label}: ожидалось целое число.`); + } + return value; +} + +function parseArtifact(value: unknown): LaboratoryEvidenceArtifact { + const artifact = record(value, "LAB artifact"); + if (artifact.verified !== true) { + throw new LaboratoryEvidenceReportContractError("LAB artifact: хэш не подтверждён."); + } + return { + kind: nullableText(artifact.kind, "LAB artifact kind"), + path: text(artifact.path, "LAB artifact path"), + byteLength: integer(artifact.byte_length, "LAB artifact byte_length"), + sha256: text(artifact.sha256, "LAB artifact sha256"), + schemaVersion: nullableText(artifact.schema_version, "LAB artifact schema_version"), + mediaType: nullableText(artifact.media_type, "LAB artifact media_type"), + verified: true, + }; +} + +export function parseLaboratoryEvidenceReport(value: unknown): LaboratoryEvidenceReport { + const payload = jsonRecord(value, "LAB evidence report") as Record; + if (payload.schema_version !== "missioncore.laboratory-evidence-report/v1") { + throw new LaboratoryEvidenceReportContractError("LAB evidence report: неизвестная схема."); + } + if (payload.access !== "read-only") { + throw new LaboratoryEvidenceReportContractError("LAB evidence report: доступ не read-only."); + } + const proof = record(payload.proof, "LAB evidence proof"); + const completenessPayload = record(payload.completeness, "LAB evidence completeness"); + const completeness: Record = {}; + for (const [key, state] of Object.entries(completenessPayload)) { + if (state !== "recorded" && state !== "not-recorded") { + throw new LaboratoryEvidenceReportContractError(`LAB completeness ${key}: неизвестное значение.`); + } + completeness[key] = state; + } + if (!Array.isArray(payload.artifacts)) { + throw new LaboratoryEvidenceReportContractError("LAB evidence artifacts: ожидался список."); + } + return { + schemaVersion: "missioncore.laboratory-evidence-report/v1", + workId: text(payload.work_id, "LAB work_id"), + resultId: text(payload.result_id, "LAB result_id"), + createdAtUtc: nullableText(payload.created_at_utc, "LAB created_at_utc"), + access: "read-only", + proof: { + documentSchemaVersion: text(proof.document_schema_version, "document schema"), + documentSha256: text(proof.document_sha256, "document sha256"), + identitySha256: text(proof.identity_sha256, "identity sha256"), + reportSchemaVersion: nullableText(proof.report_schema_version, "report schema"), + reportSha256: nullableText(proof.report_sha256, "report sha256"), + artifactCount: integer(proof.artifact_count, "artifact_count"), + verifiedArtifactCount: integer(proof.verified_artifact_count, "verified_artifact_count"), + }, + completeness, + identity: jsonRecord(payload.identity, "LAB identity"), + source: nullableRecord(payload.source, "LAB source"), + configuration: nullableRecord(payload.configuration, "LAB configuration"), + method: nullableRecord(payload.method, "LAB method"), + execution: nullableRecord(payload.execution, "LAB execution"), + resources: nullableRecord(payload.resources, "LAB resources"), + metrics: nullableRecord(payload.metrics, "LAB metrics"), + gates: nullableRecord(payload.gates, "LAB gates"), + decision: payload.decision as JsonValue | undefined, + limitations: payload.limitations as JsonValue | undefined, + authority: nullableRecord(payload.authority, "LAB authority"), + artifacts: payload.artifacts.map(parseArtifact), + visualEvidence: jsonRecord(payload.visual_evidence, "LAB visual evidence"), + rawReport: jsonRecord(payload.raw_report, "LAB raw report"), + canonicalJson: payload as Record, + }; +} + +export async function fetchLaboratoryEvidenceReport({ + workId, + resultId, + fetcher = fetch, + signal, +}: { + workId: string; + resultId: string; + fetcher?: typeof fetch; + signal?: AbortSignal; +}): Promise { + const response = await fetcher( + `/api/v1/laboratory/evidence-reports/${encodeURIComponent(workId)}/${encodeURIComponent(resultId)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new LaboratoryEvidenceReportContractError( + response.status === 404 + ? "Для этой LAB канонический evidence-report пока не опубликован." + : `Evidence-report LAB не прошёл серверную проверку: HTTP ${response.status}.`, + ); + } + const report = parseLaboratoryEvidenceReport(await response.json()); + if (report.workId !== workId || report.resultId !== resultId) { + throw new LaboratoryEvidenceReportContractError("Evidence-report не совпадает с выбранной LAB identity."); + } + return report; +} diff --git a/apps/control-station/src/core/laboratory/valueReviewIndex.ts b/apps/control-station/src/core/laboratory/valueReviewIndex.ts new file mode 100644 index 0000000..0222ec9 --- /dev/null +++ b/apps/control-station/src/core/laboratory/valueReviewIndex.ts @@ -0,0 +1,129 @@ +export type LaboratoryValueSignal = "progress" | "retained" | "failed"; +export type LaboratoryValueLifecycle = "current" | "legacy"; +export type LaboratoryVisualEvidence = "available" | "partial" | "missing"; + +export interface LaboratoryValueReviewEntry { + catalogId: string; + evidenceId: string; + signal: LaboratoryValueSignal; + lifecycle: LaboratoryValueLifecycle; + visualEvidence: LaboratoryVisualEvidence; +} + +export interface LaboratoryValueReviewIndex { + reviewedAtUtc: string; + items: readonly LaboratoryValueReviewEntry[]; +} + +export class LaboratoryValueReviewContractError extends Error {} + +const ENTRY_KEYS = [ + "access", + "catalog_id", + "evidence_id", + "lifecycle", + "signal", + "visual_evidence", +] as const; + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new LaboratoryValueReviewContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function exactKeys( + value: Record, + expected: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + if (actual.join("\0") !== [...expected].sort().join("\0")) { + throw new LaboratoryValueReviewContractError(`${label}: нарушен состав полей.`); + } +} + +function textValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim() || value !== value.trim()) { + throw new LaboratoryValueReviewContractError(`${label}: ожидалась непустая строка.`); + } + return value; +} + +function parseEntry(value: unknown): LaboratoryValueReviewEntry { + const item = objectValue(value, "LAB value-review item"); + exactKeys(item, ENTRY_KEYS, "LAB value-review item"); + if (item.access !== "read-only") { + throw new LaboratoryValueReviewContractError("LAB value-review item: доступ не read-only."); + } + if (!(["progress", "retained", "failed"] as const).includes( + item.signal as LaboratoryValueSignal, + )) { + throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный signal."); + } + if (!(["current", "legacy"] as const).includes( + item.lifecycle as LaboratoryValueLifecycle, + )) { + throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный lifecycle."); + } + if (!(["available", "partial", "missing"] as const).includes( + item.visual_evidence as LaboratoryVisualEvidence, + )) { + throw new LaboratoryValueReviewContractError( + "LAB value-review item: неизвестный visual_evidence.", + ); + } + return { + catalogId: textValue(item.catalog_id, "LAB value-review catalog_id"), + evidenceId: textValue(item.evidence_id, "LAB value-review evidence_id"), + signal: item.signal as LaboratoryValueSignal, + lifecycle: item.lifecycle as LaboratoryValueLifecycle, + visualEvidence: item.visual_evidence as LaboratoryVisualEvidence, + }; +} + +export async function fetchLaboratoryValueReviewIndex({ + fetcher = fetch, + signal, +}: { + fetcher?: typeof fetch; + signal?: AbortSignal; +} = {}): Promise { + const response = await fetcher("/api/v1/laboratory/value-review-index", { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) { + throw new LaboratoryValueReviewContractError( + `Value-review индекс LAB недоступен: HTTP ${response.status}.`, + ); + } + const payload = objectValue(await response.json(), "LAB value-review index"); + exactKeys( + payload, + ["access", "items", "reviewed_at_utc", "schema_version"], + "LAB value-review index", + ); + if ( + payload.schema_version !== "missioncore.laboratory-value-review-index/v1" + || payload.access !== "read-only" + || !Array.isArray(payload.items) + || payload.items.length > 128 + ) { + throw new LaboratoryValueReviewContractError( + "LAB value-review index: нарушен контракт.", + ); + } + const items = payload.items.map(parseEntry); + if (new Set(items.map((item) => item.catalogId)).size !== items.length) { + throw new LaboratoryValueReviewContractError( + "LAB value-review index: catalog_id продублирован.", + ); + } + return { + reviewedAtUtc: textValue(payload.reviewed_at_utc, "LAB value-review reviewed_at_utc"), + items, + }; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 01ffd9a..25d56fe 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -7,6 +7,7 @@ @import "./styles/l3-pointpillars-visual-audit.css"; @import "./styles/l34-annotation.css"; @import "./styles/laboratory-reporting.css"; +@import "./styles/laboratory-evidence-report.css"; @import "./styles/e34-temporal-layer.css"; @import "./styles/e35-degradation-recovery.css"; @import "./styles/e30-human-review.css"; diff --git a/apps/control-station/src/styles/laboratory-evidence-report.css b/apps/control-station/src/styles/laboratory-evidence-report.css new file mode 100644 index 0000000..6ecd0b2 --- /dev/null +++ b/apps/control-station/src/styles/laboratory-evidence-report.css @@ -0,0 +1,298 @@ +.laboratory-header-tools { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; +} + +.laboratory-header-tools > .nodedc-button:first-child { + min-width: 6.9rem; +} + +.laboratory-status-dot { + display: inline-block; + width: 0.48rem; + height: 0.48rem; + flex: 0 0 0.48rem; + border-radius: 50%; + background: currentcolor; + color: var(--nodedc-text-muted); + box-shadow: 0 0 0 0.08rem rgb(255 255 255 / 0.04); +} + +.laboratory-status-dot[data-status="progress"] { + color: rgb(var(--nodedc-success-rgb)); + box-shadow: 0 0 0.38rem rgb(var(--nodedc-success-rgb) / 0.35); +} + +.laboratory-status-dot[data-status="retained"] { + color: rgb(var(--nodedc-warning-rgb)); + box-shadow: 0 0 0.38rem rgb(var(--nodedc-warning-rgb) / 0.28); +} + +.laboratory-status-dot[data-status="failed"], +.laboratory-status-dot[data-status="unreviewed"] { + color: var(--nodedc-text-muted); +} + +.laboratory-evidence-report { + display: grid; + gap: 0.8rem; + min-width: 0; + padding-bottom: 1rem; +} + +.laboratory-evidence-report__header, +.laboratory-evidence-report__integrity, +.laboratory-evidence-report__section { + border-radius: 1rem; + background: rgb(255 255 255 / 0.025); + padding: 1rem; +} + +.laboratory-evidence-report__header, +.laboratory-evidence-report__integrity > header, +.laboratory-evidence-report__artifact-list article > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.laboratory-evidence-report h2, +.laboratory-evidence-report h3, +.laboratory-evidence-report p, +.laboratory-evidence-report dl, +.laboratory-evidence-report dd, +.laboratory-evidence-report ol { + margin: 0; +} + +.laboratory-evidence-report h2 { + margin-top: 0.25rem; + color: var(--nodedc-text-primary); + font-size: 1.02rem; +} + +.laboratory-evidence-report h3 { + margin-top: 0.22rem; + color: var(--nodedc-text-primary); + font-size: 0.78rem; +} + +.laboratory-evidence-report__header p { + margin-top: 0.32rem; + color: var(--nodedc-text-muted); + font-size: 0.6rem; +} + +.laboratory-evidence-report__identity { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.42rem; + margin-top: 0.8rem !important; +} + +.laboratory-evidence-report__identity > div, +.laboratory-evidence-report__completeness > div { + min-width: 0; + border-radius: 0.72rem; + background: rgb(255 255 255 / 0.028); + padding: 0.68rem; +} + +.laboratory-evidence-report dt { + color: var(--nodedc-text-muted); + font-size: 0.52rem; + text-transform: uppercase; +} + +.laboratory-evidence-report__identity dd { + overflow-wrap: anywhere; + margin-top: 0.25rem; + color: var(--nodedc-text-secondary); + font-family: var(--nodedc-font-family-mono, monospace); + font-size: 0.56rem; + line-height: 1.45; +} + +.laboratory-evidence-report__completeness { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.35rem; + margin-top: 0.45rem !important; +} + +.laboratory-evidence-report__completeness dd { + margin-top: 0.22rem; + color: var(--nodedc-text-secondary); + font-size: 0.55rem; +} + +.laboratory-evidence-report__completeness [data-state="recorded"] dd { + color: rgb(var(--nodedc-success-rgb)); +} + +.laboratory-evidence-report__completeness [data-state="not-recorded"] dd { + color: rgb(var(--nodedc-warning-rgb)); +} + +.laboratory-evidence-report__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 0.55rem; +} + +.laboratory-evidence-report__section { + min-width: 0; +} + +.laboratory-evidence-report__section > header { + margin-bottom: 0.7rem; +} + +.laboratory-evidence-report__tree { + display: grid; + gap: 0.28rem; +} + +.laboratory-evidence-report__tree > div { + display: grid; + grid-template-columns: minmax(7.5rem, 0.38fr) minmax(0, 1fr); + gap: 0.55rem; + border-top: 1px solid rgb(255 255 255 / 0.04); + padding-top: 0.34rem; +} + +.laboratory-evidence-report__tree > div:first-child { + border-top: 0; + padding-top: 0; +} + +.laboratory-evidence-report__tree[data-depth]:not([data-depth="0"]) > div { + grid-template-columns: minmax(6rem, 0.32fr) minmax(0, 1fr); +} + +.laboratory-evidence-report__tree dd, +.laboratory-evidence-report__value, +.laboratory-evidence-report__array { + min-width: 0; + overflow-wrap: anywhere; + color: var(--nodedc-text-secondary); + font-size: 0.59rem; + line-height: 1.48; +} + +.laboratory-evidence-report__array { + display: grid; + gap: 0.35rem; + padding-left: 1rem; +} + +.laboratory-evidence-report__missing { + color: rgb(var(--nodedc-warning-rgb)); + font-size: 0.6rem; + line-height: 1.5; +} + +.laboratory-evidence-report__artifact-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.42rem; +} + +.laboratory-evidence-report__artifact-list article { + min-width: 0; + border-radius: 0.75rem; + background: rgb(255 255 255 / 0.028); + padding: 0.72rem; +} + +.laboratory-evidence-report__artifact-list strong { + color: var(--nodedc-text-primary); + font-size: 0.63rem; +} + +.laboratory-evidence-report__artifact-list p { + overflow-wrap: anywhere; + margin: 0.34rem 0 !important; + color: var(--nodedc-text-secondary); + font-size: 0.58rem; +} + +.laboratory-evidence-report__artifact-list dl { + display: grid; + gap: 0.22rem; +} + +.laboratory-evidence-report__artifact-list dl > div { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); + gap: 0.4rem; +} + +.laboratory-evidence-report__artifact-list dd { + overflow-wrap: anywhere; + color: var(--nodedc-text-muted); + font-family: var(--nodedc-font-family-mono, monospace); + font-size: 0.53rem; +} + +.laboratory-evidence-report__canonical-json pre { + max-height: 34rem; + overflow: auto; + border-radius: 0.72rem; + background: rgb(0 0 0 / 0.24); + padding: 0.8rem; + color: var(--nodedc-text-secondary); + font-family: var(--nodedc-font-family-mono, monospace); + font-size: 0.55rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.laboratory-evidence-report__notice { + display: flex; + align-items: flex-start; + gap: 0.6rem; + border-radius: 0.8rem; + background: rgb(var(--nodedc-warning-rgb) / 0.08); + padding: 0.8rem; +} + +.laboratory-evidence-report__notice strong { + color: var(--nodedc-text-primary); + font-size: 0.7rem; +} + +.laboratory-evidence-report__notice p { + margin-top: 0.25rem; + color: var(--nodedc-text-secondary); + font-size: 0.6rem; + line-height: 1.5; +} + +@media (max-width: 1180px) { + .laboratory-evidence-report__completeness { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .laboratory-evidence-report__header, + .laboratory-evidence-report__integrity > header { + display: grid; + } + + .laboratory-evidence-report__grid, + .laboratory-evidence-report__artifact-list { + grid-template-columns: 1fr; + } + + .laboratory-evidence-report__identity, + .laboratory-evidence-report__completeness { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/apps/control-station/src/workspaces/contracts.ts b/apps/control-station/src/workspaces/contracts.ts index 44c7498..70ad2b5 100644 --- a/apps/control-station/src/workspaces/contracts.ts +++ b/apps/control-station/src/workspaces/contracts.ts @@ -29,6 +29,11 @@ export interface LaboratoryAnnotationAction { onClick: () => void; } +export interface LaboratoryViewAction { + label: string; + onClick: () => void; +} + export interface WorkspaceRendererProps { definition: WorkspaceDefinition; state: MissionRuntimeState | null; @@ -65,4 +70,5 @@ export interface WorkspaceRendererProps { onLaboratoryAnnotationActionChange: ( action: LaboratoryAnnotationAction | null, ) => void; + onLaboratoryViewActionChange: (action: LaboratoryViewAction | null) => void; } diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index 2d77ca0..b3dd723 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -11,8 +11,6 @@ import { LaboratorySelector, LaboratorySummary, LaboratoryWorkTemplate, - type LaboratoryMethod, - type LaboratoryMethodComponent, } from "../../components/laboratory/LaboratoryPresentation"; import type { ObservationSessionSummary } from "../../core/observation/sessionArchive"; import { useObservationSessions } from "../../core/observation/useObservationSessions"; @@ -26,9 +24,7 @@ import { fetchE30ReviewCatalog, type E30ReviewResult, } from "../../core/laboratory/e30Review"; -import { - type AdvancedLaboratoryResults, -} from "../../core/laboratory/advancedResults"; +import { type AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults"; import { fetchLidarLocalSurfaces, type LidarLocalSurfaceModel, @@ -42,11 +38,15 @@ import { advancedLaboratorySourceSession, isAdvancedLaboratoryWorkId, } from "./AdvancedLaboratoryResult"; +import { LaboratoryEvidenceReportView } from "./LaboratoryEvidenceReportView"; import { e28LaboratoryBrief, e29LaboratoryBrief, e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF, } from "./laboratoryArchiveBriefs"; import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog"; +import { useLaboratoryValueReviewIndex } from "./useLaboratoryValueReviewIndex"; +import { useLaboratoryEvidenceReport } from "./useLaboratoryEvidenceReport"; +import { useLaboratoryViewMode } from "./useLaboratoryViewMode"; import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability"; import { buildLaboratoryCatalog, @@ -54,6 +54,16 @@ import { experimentOptionsForProfile, workOptionsForExperiment, } from "./laboratoryArchiveProfiles"; +import { + experimentOptionsWithSignals, + profileOptionsWithSignals, + projectLaboratoryValueReviews, + workOptionsWithSignals, +} from "./laboratoryValueReviewProjection"; +import { + digestFromContentId, + publishedLaboratoryMethod, +} from "./publishedLaboratoryMethod"; import type { LaboratoryCatalogSeed, LaboratoryExperimentId, @@ -64,98 +74,6 @@ type LaboratoryWorkspaceProps = WorkspaceRendererProps & { SpatialView: ComponentType; }; -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 formatSeconds(value: number): string { return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`; } @@ -550,6 +468,10 @@ function PublishedLaboratoryResult({ } export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { + const [viewMode] = useLaboratoryViewMode( + props.onLaboratoryViewActionChange, + ); + const laboratoryValueReview = useLaboratoryValueReviewIndex(); const [profileId, setProfileId] = useState( "rig-right-yolox-lidar-range-v1", ); @@ -602,7 +524,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { }, }); const advancedResults: AdvancedLaboratoryResults = advanced.results; - const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: workId, l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange }); + const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange }); useEffect(() => { const controller = new AbortController(); setEvidenceLoading(true); @@ -651,6 +573,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { items.push({ id: "e28-local-surface", createdAtUtc: e28Model.createdAtUtc ?? "", + evidenceId: e28Model.modelId, }); } if ( @@ -660,12 +583,14 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { items.push({ id: "e29-camera-geometry", createdAtUtc: e29Result.createdAtUtc ?? "", + evidenceId: e29Result.resultId, }); } if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) { items.push({ id: "e30-evidence-review", createdAtUtc: e30Result.createdAtUtc ?? "", + evidenceId: e30Result.resultId, }); } return items.filter(({ createdAtUtc }) => createdAtUtc.trim()); @@ -684,18 +609,33 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { }), [advanced.index, knownWorks, publishedWorks, rigLabel], ); + const valueReviews = useMemo(() => projectLaboratoryValueReviews({ + catalog, + index: laboratoryValueReview.index, + publishedWorks, + }), [catalog, laboratoryValueReview.index, publishedWorks]); const profiles = useMemo( - () => buildLaboratoryProfiles(catalog), - [catalog], + () => profileOptionsWithSignals(buildLaboratoryProfiles(catalog), catalog, valueReviews), + [catalog, valueReviews], ); const experimentOptions = useMemo( - () => experimentOptionsForProfile(profileId, catalog), - [catalog, profileId], + () => experimentOptionsWithSignals( + experimentOptionsForProfile(profileId, catalog), profileId, catalog, valueReviews, + ), + [catalog, profileId, valueReviews], ); const workOptions = useMemo( - () => workOptionsForExperiment(profileId, experimentId, catalog), - [catalog, experimentId, profileId], + () => workOptionsWithSignals( + workOptionsForExperiment(profileId, experimentId, catalog), valueReviews, + ), + [catalog, experimentId, profileId, valueReviews], ); + const selectedCatalog = catalog.find((entry) => entry.id === workId) ?? null; + const evidenceReport = useLaboratoryEvidenceReport({ + workId, + resultId: selectedCatalog?.evidenceId ?? "", + enabled: viewMode === "report" && selectedCatalog !== null, + }); const selectedSessionId = workId.startsWith("session:") ? workId.slice("session:".length) : null; @@ -828,6 +768,17 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { || props.observationLayout.maximizedFloatingSourceId, ); + if (viewMode === "report" && selectedCatalog) { + return ( + + ); + } + return (
> = { + acceptance: "Приёмка", + accepted: "Принято", + architecture: "Архитектура", + authority: "Полномочия", + byte_length: "Размер", + calibration_model: "Модель калибровки", + calibration_sha256: "SHA калибровки", + camera_source_id: "Камера", + checks: "Проверки", + commands_enabled: "Команды разрешены", + completeness: "Полнота", + config_sha256: "SHA конфига", + container_image: "Образ контейнера", + core_capacity_fps: "Вычислительная ёмкость, FPS", + core_path_p95_ms: "Core path p95, мс", + created_at_utc: "Создано UTC", + decision: "Решение", + detector: "Детектор", + execution_class: "Класс исполнения", + frame_count: "Кадры", + failed_frame_count: "Ошибки кадров", + gpu_memory_used_mib: "GPU memory, MiB", + gpu_name: "GPU", + gpu_power_watts: "GPU power, W", + gpu_temperature_celsius: "GPU temperature, °C", + gpu_utilization_percent: "GPU utilization, %", + ground_truth: "Ground truth", + identity_sha256: "SHA identity", + limitations: "Ограничения", + metrics: "Метрики", + model_sha256: "SHA модели", + navigation_or_safety_accepted: "Допуск navigation/safety", + next_action: "Следующее действие", + pipeline_id: "Pipeline", + preprocessing_contract: "Preprocessing contract", + profile_sha256: "SHA профиля", + provider_promoted: "Provider promoted", + report_sha256: "SHA отчёта", + resolution: "Разрешение", + resources: "Ресурсы", + result_id: "Result identity", + runtime: "Runtime", + schema_version: "Версия схемы", + session_id: "Сессия", + source: "Источник", + configuration: "Конфигурация", + status: "Статус", + stream_sha256: "SHA потока", + worker_host: "Worker host", +}; + +const COMPLETENESS_LABELS: Readonly> = { + identity: "Identity", + source: "Источник", + method: "Метод и модули", + execution: "Runtime / worker", + resources: "Нагрузка", + metrics: "Метрики", + gates: "Acceptance gates", + decision: "Решение", + limitations: "Ограничения", + authority: "Полномочия", + artifacts: "Артефакты", + visual_evidence: "Визуал", +}; + +function fieldLabel(value: string): string { + return FIELD_LABELS[value] ?? value.replaceAll("_", " "); +} + +function primitive(value: string | number | boolean | null): string { + if (value === null) return "Не зафиксировано"; + if (typeof value === "boolean") return value ? "Да" : "Нет"; + if (typeof value === "number") { + return value.toLocaleString("ru-RU", { maximumFractionDigits: 6 }); + } + return value; +} + +function EvidenceValue({ value, depth = 0 }: { value: JsonValue; depth?: number }) { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) { + return {primitive(value as string | number | boolean | null)}; + } + if (Array.isArray(value)) { + if (!value.length) return Пустой список; + return ( +
    + {value.map((item, index) => ( +
  1. + ))} +
+ ); + } + return ( +
+ {Object.entries(value).map(([key, item]) => ( +
+
{fieldLabel(key)}
+
+
+ ))} +
+ ); +} + +function ReportSection({ + eyebrow, + title, + value, +}: { + eyebrow: string; + title: string; + value: JsonValue | undefined; +}) { + return ( +
+
+ {eyebrow} +

{title}

+
+ {value === null || value === undefined ? ( +

+ Не зафиксировано в immutable evidence этой лабораторной работы. +

+ ) : ( + + )} +
+ ); +} + +function LoadingReport({ catalog }: { catalog: LaboratoryCatalogEntry }) { + return ( +
+
+ ); +} + +export function LaboratoryEvidenceReportView({ + catalog, + report, + loading, + error, +}: { + catalog: LaboratoryCatalogEntry; + report: LaboratoryEvidenceReport | null; + loading: boolean; + error: string | null; +}) { + if (loading) return ; + if (!report) { + return ( +
+
+
+ ОТЧЁТ ВЫБРАННОЙ LAB · EVIDENCE IDENTITY +

{catalog.variantName}

+

{catalog.evidenceId}

+
+ Неполный evidence contract +
+
+ +
+ Канонический доказательный JSON не опубликован +

{error ?? "Для этой legacy LAB доступен визуал, но нет полного manifest/report контракта."}

+
+
+
+
LAB
{catalog.id}
+
Evidence identity
{catalog.evidenceId}
+
Дата
{laboratoryTimestamp(catalog.createdAtUtc)}
+
+
+ ); + } + + const recorded = Object.values(report.completeness).filter((value) => value === "recorded").length; + const total = Object.keys(report.completeness).length; + return ( +
+
+
+ ОТЧЁТ ВЫБРАННОЙ LAB · IMMUTABLE EVIDENCE +

{catalog.variantName}

+

{catalog.profileName} · {laboratoryTimestamp(catalog.createdAtUtc)}

+
+ + {recorded}/{total} доказательных разделов + +
+ +
+
+
+ ЦЕЛОСТНОСТЬ И ПРОИСХОЖДЕНИЕ +

Отчёт собран из проверенного manifest, а не из UI-копирайта

+
+ + SHA-256 {report.proof.verifiedArtifactCount}/{report.proof.artifactCount} + +
+
+
LAB work ID
{report.workId}
+
Result identity
{report.resultId}
+
Identity SHA-256
{report.proof.identitySha256}
+
Report SHA-256
{report.proof.reportSha256 ?? "Отдельный report artifact не зафиксирован"}
+
Manifest/document SHA-256
{report.proof.documentSha256}
+
Schema
{report.proof.reportSchemaVersion ?? report.proof.documentSchemaVersion}
+
+
+ {Object.entries(report.completeness).map(([key, state]) => ( +
+
{COMPLETENESS_LABELS[key] ?? fieldLabel(key)}
+
{state === "recorded" ? "Зафиксировано" : "Не зафиксировано"}
+
+ ))} +
+
+ +
+ + + + + + + + + + + +
+ +
+
+ VERIFIED ARTIFACTS +

Файлы доказательства, размер и полный SHA-256

+
+ {report.artifacts.length ? ( +
+ {report.artifacts.map((artifact) => ( +
+
+ {artifact.kind ?? "artifact"} + SHA verified +
+

{artifact.path}

+
+
Размер
{artifact.byteLength.toLocaleString("ru-RU")} байт
+
SHA-256
{artifact.sha256}
+
Schema / media
{artifact.schemaVersion ?? artifact.mediaType ?? "Не размечено"}
+
+
+ ))} +
+ ) :

Artifact manifest не зафиксирован.

} +
+ +
+
+ CANONICAL JSON · READ-ONLY +

Полный нормализованный evidence-report без потери исходных полей

+
+
{JSON.stringify(report.canonicalJson, null, 2)}
+
+
+ ); +} diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index e69b8d5..4a19245 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -31,11 +31,13 @@ export type LaboratoryWorkId = export interface LaboratoryCatalogSeed { id: LaboratoryWorkId; createdAtUtc: string; + evidenceId: string; } export interface LaboratoryCatalogEntry { id: LaboratoryWorkId; createdAtUtc: string; + evidenceId: string; profileId: LaboratoryProfileId; profileName: string; experimentId: LaboratoryExperimentId; @@ -369,18 +371,23 @@ export function buildLaboratoryCatalog({ advancedIndex: readonly AdvancedLaboratoryIndexItem[]; publishedWorks: readonly ObservationSessionSummary[]; }): readonly LaboratoryCatalogEntry[] { - const seeded = new Map(); - for (const work of knownWorks) seeded.set(work.id, work.createdAtUtc); - for (const work of advancedIndex) seeded.set(work.workId, work.createdAtUtc); + const seeded = new Map(); + for (const work of knownWorks) { + seeded.set(work.id, { createdAtUtc: work.createdAtUtc, evidenceId: work.evidenceId }); + } + for (const work of advancedIndex) { + seeded.set(work.workId, { createdAtUtc: work.createdAtUtc, evidenceId: work.resultId }); + } const entries: LaboratoryCatalogEntry[] = []; - for (const [id, createdAtUtc] of seeded) { + for (const [id, identity] of seeded) { if (id.startsWith("session:")) continue; const definition = KNOWN_WORKS[id as Exclude]; if (!definition) continue; entries.push({ id, - createdAtUtc, + createdAtUtc: identity.createdAtUtc, + evidenceId: identity.evidenceId, profileId: definition.profileId, profileName: definition.profileName(rigLabel), experimentId: definition.experimentId, @@ -396,6 +403,7 @@ export function buildLaboratoryCatalog({ entries.push({ id: `session:${session.id}`, createdAtUtc: session.lab?.runCreatedAtUtc ?? session.startedAtUtc, + evidenceId: session.lab?.sourceResultId ?? session.lab?.resultId ?? session.id, profileId, profileName: `${rig(rigLabel)} RIGHT · ${pipelineName}`, experimentId: `${profileId}:ravnoves00`, diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryValueReviewProjection.ts b/apps/control-station/src/workspaces/laboratory/laboratoryValueReviewProjection.ts new file mode 100644 index 0000000..b822f2e --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/laboratoryValueReviewProjection.ts @@ -0,0 +1,125 @@ +import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation"; +import type { + LaboratoryValueReviewEntry, + LaboratoryValueReviewIndex, + LaboratoryValueLifecycle, + LaboratoryValueSignal, + LaboratoryVisualEvidence, +} from "../../core/laboratory/valueReviewIndex"; +import type { ObservationSessionSummary } from "../../core/observation/sessionArchive"; +import type { + LaboratoryCatalogEntry, + LaboratoryExperimentId, + LaboratoryProfileId, + LaboratoryWorkId, +} from "./laboratoryArchiveProfiles"; + +export type ProjectedLaboratorySignal = LaboratoryValueSignal | "unreviewed"; + +export interface ProjectedLaboratoryValueReview { + catalog: LaboratoryCatalogEntry; + signal: ProjectedLaboratorySignal; + lifecycle: LaboratoryValueLifecycle; + visualEvidence: LaboratoryVisualEvidence; +} + +function legacySessionReview( + catalog: LaboratoryCatalogEntry, + session: ObservationSessionSummary, +): ProjectedLaboratoryValueReview { + const passed = session.lab?.provenance.benchmark_passed; + const signal: LaboratoryValueSignal = passed === true + ? "progress" + : passed === false + ? "failed" + : "retained"; + return { + catalog, + signal, + lifecycle: "legacy", + visualEvidence: "available", + }; +} + +function reviewedValue( + catalog: LaboratoryCatalogEntry, + review: LaboratoryValueReviewEntry, +): ProjectedLaboratoryValueReview { + return { + catalog, + signal: review.signal, + lifecycle: review.lifecycle, + visualEvidence: review.visualEvidence, + }; +} + +export function projectLaboratoryValueReviews({ + catalog, + index, + publishedWorks, +}: { + catalog: readonly LaboratoryCatalogEntry[]; + index: LaboratoryValueReviewIndex | null; + publishedWorks: readonly ObservationSessionSummary[]; +}): readonly ProjectedLaboratoryValueReview[] { + const reviewed = new Map(index?.items.map((item) => [item.catalogId, item])); + const sessions = new Map(publishedWorks.map((session) => [session.id, session])); + return catalog.map((entry) => { + const review = reviewed.get(entry.id); + if (review?.evidenceId === entry.evidenceId) return reviewedValue(entry, review); + if (entry.id.startsWith("session:")) { + const session = sessions.get(entry.id.slice("session:".length)); + if (session) return legacySessionReview(entry, session); + } + return { + catalog: entry, + signal: "unreviewed", + lifecycle: "current", + visualEvidence: "partial", + } satisfies ProjectedLaboratoryValueReview; + }); +} + +function statusMap( + reviews: readonly ProjectedLaboratoryValueReview[], +): ReadonlyMap { + return new Map(reviews.map((review) => [review.catalog.id, review.signal])); +} + +export function profileOptionsWithSignals( + options: readonly LaboratoryOption[], + catalog: readonly LaboratoryCatalogEntry[], + reviews: readonly ProjectedLaboratoryValueReview[], +): readonly LaboratoryOption[] { + const signals = statusMap(reviews); + return options.map((option) => { + const latest = catalog.find((entry) => entry.profileId === option.id); + return { ...option, status: latest ? signals.get(latest.id) ?? "unreviewed" : "unreviewed" }; + }); +} + +export function experimentOptionsWithSignals( + options: readonly LaboratoryOption[], + profileId: LaboratoryProfileId, + catalog: readonly LaboratoryCatalogEntry[], + reviews: readonly ProjectedLaboratoryValueReview[], +): readonly LaboratoryOption[] { + const signals = statusMap(reviews); + return options.map((option) => { + const latest = catalog.find((entry) => ( + entry.profileId === profileId && entry.experimentId === option.id + )); + return { ...option, status: latest ? signals.get(latest.id) ?? "unreviewed" : "unreviewed" }; + }); +} + +export function workOptionsWithSignals( + options: readonly LaboratoryOption[], + reviews: readonly ProjectedLaboratoryValueReview[], +): readonly LaboratoryOption[] { + const signals = statusMap(reviews); + return options.map((option) => ({ + ...option, + status: signals.get(option.id) ?? "unreviewed", + })); +} diff --git a/apps/control-station/src/workspaces/laboratory/publishedLaboratoryMethod.ts b/apps/control-station/src/workspaces/laboratory/publishedLaboratoryMethod.ts new file mode 100644 index 0000000..9878baf --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/publishedLaboratoryMethod.ts @@ -0,0 +1,97 @@ +import type { + LaboratoryMethod, + LaboratoryMethodComponent, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { ObservationSessionSummary } from "../../core/observation/sessionArchive"; + +export function digestFromContentId(value: string | null | undefined): string | null { + const digest = value?.split("-").at(-1) ?? ""; + return /^[a-f0-9]{64}$/.test(digest) ? digest : null; +} + +export 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, + }, + ], + }; +} diff --git a/apps/control-station/src/workspaces/laboratory/useLaboratoryEvidenceReport.ts b/apps/control-station/src/workspaces/laboratory/useLaboratoryEvidenceReport.ts new file mode 100644 index 0000000..d4a1c3c --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/useLaboratoryEvidenceReport.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; + +import { + fetchLaboratoryEvidenceReport, + type LaboratoryEvidenceReport, +} from "../../core/laboratory/evidenceReport"; + +export function useLaboratoryEvidenceReport({ + workId, + resultId, + enabled, +}: { + workId: string; + resultId: string; + enabled: boolean; +}): { + report: LaboratoryEvidenceReport | null; + loading: boolean; + error: string | null; +} { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) return; + const controller = new AbortController(); + setReport(null); + setLoading(true); + setError(null); + void fetchLaboratoryEvidenceReport({ workId, resultId, signal: controller.signal }) + .then(setReport) + .catch((caught: unknown) => { + if (controller.signal.aborted) return; + setError(caught instanceof Error ? caught.message : "Evidence-report LAB недоступен."); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [enabled, resultId, workId]); + + return { report, loading, error }; +} diff --git a/apps/control-station/src/workspaces/laboratory/useLaboratoryValueReviewIndex.ts b/apps/control-station/src/workspaces/laboratory/useLaboratoryValueReviewIndex.ts new file mode 100644 index 0000000..762066d --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/useLaboratoryValueReviewIndex.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; + +import { + fetchLaboratoryValueReviewIndex, + type LaboratoryValueReviewIndex, +} from "../../core/laboratory/valueReviewIndex"; + +export function useLaboratoryValueReviewIndex(): { + index: LaboratoryValueReviewIndex | null; + loading: boolean; + error: string | null; +} { + const [index, setIndex] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + void fetchLaboratoryValueReviewIndex({ signal: controller.signal }) + .then(setIndex) + .catch((caught: unknown) => { + if (controller.signal.aborted) return; + setIndex(null); + setError(caught instanceof Error ? caught.message : "Value-review индекс LAB недоступен."); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, []); + + return { index, loading, error }; +} diff --git a/apps/control-station/src/workspaces/laboratory/useLaboratoryViewMode.ts b/apps/control-station/src/workspaces/laboratory/useLaboratoryViewMode.ts new file mode 100644 index 0000000..38efba5 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/useLaboratoryViewMode.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { LaboratoryViewAction } from "../contracts"; + +export type LaboratoryViewMode = "laboratory" | "report"; + +export function useLaboratoryViewMode( + onActionChange: (action: LaboratoryViewAction | null) => void, +): [LaboratoryViewMode, (next: LaboratoryViewMode) => void] { + const [mode, setMode] = useState("laboratory"); + const toggle = useCallback(() => { + setMode((current) => current === "laboratory" ? "report" : "laboratory"); + }, []); + + useEffect(() => { + onActionChange({ + label: mode === "laboratory" ? "Отчёт" : "Лабораторные контуры", + onClick: toggle, + }); + return () => onActionChange(null); + }, [mode, onActionChange, toggle]); + + return [mode, setMode]; +} diff --git a/apps/control-station/test/laboratoryEvidenceReport.test.mjs b/apps/control-station/test/laboratoryEvidenceReport.test.mjs new file mode 100644 index 0000000..dda1cd6 --- /dev/null +++ b/apps/control-station/test/laboratoryEvidenceReport.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { readFile } from "node:fs/promises"; + +import { createServer } from "vite"; + +let server; +let fetchLaboratoryValueReviewIndex; +let fetchLaboratoryEvidenceReport; + +before(async () => { + server = await createServer({ server: { middlewareMode: true }, appType: "custom" }); + ({ fetchLaboratoryValueReviewIndex } = await server.ssrLoadModule( + "/src/core/laboratory/valueReviewIndex.ts", + )); + ({ fetchLaboratoryEvidenceReport } = await server.ssrLoadModule( + "/src/core/laboratory/evidenceReport.ts", + )); +}); + +after(async () => { + await server?.close(); +}); + +function payload(overrides = {}) { + return { + schema_version: "missioncore.laboratory-value-review-index/v1", + reviewed_at_utc: "2026-08-05T08:30:00Z", + items: [ + { + catalog_id: "e46j-raw-fisheye-realtime", + evidence_id: `e46j-raw-fisheye-realtime-${"a".repeat(64)}`, + signal: "progress", + lifecycle: "current", + visual_evidence: "available", + access: "read-only", + }, + ], + access: "read-only", + ...overrides, + }; +} + +test("LAB value-review index preserves the reviewed evidence identity", async () => { + const index = await fetchLaboratoryValueReviewIndex({ + fetcher: async () => new Response(JSON.stringify(payload()), { status: 200 }), + }); + + assert.equal(index.items[0].catalogId, "e46j-raw-fisheye-realtime"); + assert.match(index.items[0].evidenceId, /^e46j-raw-fisheye-realtime-[a-f0-9]{64}$/); + assert.equal(index.items[0].signal, "progress"); +}); + +test("LAB value-review index rejects extra fields instead of trusting presentation data", async () => { + const document = payload(); + document.items[0] = { ...document.items[0], renderer: "local" }; + + await assert.rejects( + fetchLaboratoryValueReviewIndex({ + fetcher: async () => new Response(JSON.stringify(document), { status: 200 }), + }), + /состав полей/, + ); +}); + +test("selected LAB evidence report preserves proof, telemetry and canonical JSON", async () => { + const workId = "e46j-raw-fisheye-realtime"; + const resultId = `e46j-raw-fisheye-realtime-${"b".repeat(64)}`; + const report = await fetchLaboratoryEvidenceReport({ + workId, + resultId, + fetcher: async () => new Response(JSON.stringify({ + schema_version: "missioncore.laboratory-evidence-report/v1", + work_id: workId, + result_id: resultId, + created_at_utc: "2026-08-04T19:37:51.841Z", + access: "read-only", + proof: { + document_schema_version: "missioncore.e46j-result/v1", + document_sha256: "c".repeat(64), + identity_sha256: "b".repeat(64), + report_schema_version: "missioncore.e46j-report/v1", + report_sha256: "d".repeat(64), + artifact_count: 1, + verified_artifact_count: 1, + }, + completeness: { + identity: "recorded", + source: "recorded", + configuration: "recorded", + method: "recorded", + execution: "recorded", + resources: "recorded", + metrics: "recorded", + gates: "recorded", + decision: "recorded", + limitations: "recorded", + authority: "recorded", + artifacts: "recorded", + visual_evidence: "recorded", + }, + identity: { profile_sha256: "e".repeat(64) }, + source: { frame_count: 4489, stream_sha256: "f".repeat(64) }, + configuration: { detector: { config_sha256: "8".repeat(64) } }, + method: { components: [{ kind: "model", identity_sha256: "a".repeat(64) }] }, + execution: { worker_host: "worker-006", gpu_name: "RTX 4090" }, + resources: { gpu_utilization_percent: { p95: 49 } }, + metrics: { core_capacity_fps: 47.84, core_path_p95_ms: 25.35 }, + gates: { passed: true }, + decision: { provider_promoted: false }, + limitations: ["No temporal identity."], + authority: { commands_enabled: false }, + artifacts: [{ + kind: "visual-overlay-video", + path: "overlay.mp4", + byte_length: 150563706, + sha256: "9".repeat(64), + schema_version: null, + media_type: "video/mp4", + verified: true, + }], + visual_evidence: { review: { completed: true }, artifacts: [] }, + raw_report: { schema_version: "missioncore.e46j-report/v1", metrics: { frame_count: 4489 } }, + }), { status: 200 }), + }); + + assert.equal(report.workId, workId); + assert.equal(report.resultId, resultId); + assert.equal(report.resources.gpu_utilization_percent.p95, 49); + assert.equal(report.artifacts[0].verified, true); + assert.equal(report.canonicalJson.raw_report.metrics.frame_count, 4489); +}); + +test("LAB report UI uses the panel header contract and canonical controls", async () => { + const root = new URL("../src/", import.meta.url); + const [workspace, header, report, presentation, styles] = await Promise.all([ + readFile(new URL("workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", root), "utf8"), + readFile(new URL("components/laboratory/useLaboratoryAnnotationHeader.tsx", root), "utf8"), + readFile(new URL("workspaces/laboratory/LaboratoryEvidenceReportView.tsx", root), "utf8"), + readFile(new URL("components/laboratory/LaboratoryPresentation.tsx", root), "utf8"), + readFile(new URL("styles/laboratory-evidence-report.css", root), "utf8"), + ]); + + assert.match(workspace, /useLaboratoryViewMode/); + assert.match(workspace, /]*viewAction/); + assert.match(report, /CANONICAL JSON/); + assert.match(report, /verifiedArtifactCount/); + assert.doesNotMatch(report, /Открыть LAB/); + assert.match(presentation, /className="laboratory-status-dot"/); + assert.doesNotMatch(presentation, / None: + self._definitions = { + definition.work_id: definition for definition in registry.definitions + } + self._runtime_root_provider = runtime_root_provider + + def read(self, work_id: str, result_id: str) -> dict[str, object]: + definition = self._definitions.get(work_id) + if definition is None or definition.result_id_pattern.fullmatch(result_id) is None: + raise LaboratoryEvidenceReportNotFound("LAB evidence identity is unknown") + result_root = self._result_root(definition, result_id) + document_path = _safe_file(result_root, definition.document_name) + document_bytes = _read_bounded(document_path, _DOCUMENT_MAX_BYTES, "LAB document") + document = _json_object(document_bytes, "LAB document") + _validate_document(document, definition, result_id) + + identity = _object_or_none(document.get("identity")) + identity_sha256 = document.get("identity_sha256") + if identity is None or not isinstance(identity_sha256, str): + raise LaboratoryEvidenceReportError("LAB identity proof is missing") + actual_identity_sha256 = _canonical_sha256(identity) + if actual_identity_sha256 != identity_sha256 or not result_id.endswith(identity_sha256): + raise LaboratoryEvidenceReportError("LAB identity proof is invalid") + + artifacts = _verified_artifacts(result_root, document.get("artifacts")) + report_descriptor = _report_descriptor(artifacts) + report = ( + _read_json_artifact(result_root, report_descriptor, "LAB report") + if report_descriptor is not None + else document + ) + runtime_descriptor = _runtime_descriptor(artifacts) + runtime = ( + _read_json_artifact(result_root, runtime_descriptor, "LAB runtime") + if runtime_descriptor is not None + else None + ) + + source = _first_object( + report.get("source"), + identity.get("source"), + _nested(identity, "profile", "source"), + ) or _source_projection(report, identity) + configuration = _configuration_projection(report, identity) + method = _first_object( + report.get("method"), + identity.get("method"), + identity.get("profile"), + ) + execution = _first_object( + report.get("execution"), + runtime, + identity.get("execution"), + identity.get("worker"), + report.get("worker"), + ) + metrics = _first_object(report.get("metrics"), _nested(runtime, "metrics")) + resources = _first_object( + _nested(report, "metrics", "resources"), + _nested(runtime, "metrics", "resources"), + _nested(runtime, "metrics", "gpu"), + ) + gates = _first_object( + report.get("acceptance"), + report.get("quality_gate"), + report.get("acceptance_requirements"), + _nested(runtime, "acceptance"), + ) + decision = _json_value_or_none(report.get("decision")) + limitations = _json_value_or_none(report.get("limitations")) + authority = _first_object( + report.get("authority"), + identity.get("authority"), + document.get("authority"), + ) + visual_review = _first_object( + report.get("visual_review"), + report.get("visual_evidence"), + ) + visual_artifacts = [ + artifact + for artifact in artifacts + if _is_visual_artifact(artifact) + ] + completeness_values: dict[str, object | None] = { + "identity": identity, + "source": source, + "configuration": configuration, + "method": method, + "execution": execution, + "resources": resources, + "metrics": metrics, + "gates": gates, + "decision": decision, + "limitations": limitations, + "authority": authority, + "artifacts": artifacts or None, + "visual_evidence": visual_review or (visual_artifacts or None), + } + return { + "schema_version": LABORATORY_EVIDENCE_REPORT_SCHEMA, + "work_id": work_id, + "result_id": result_id, + "created_at_utc": _optional_text(document.get("created_at_utc")), + "access": "read-only", + "proof": { + "document_schema_version": document.get("schema_version"), + "document_sha256": hashlib.sha256(document_bytes).hexdigest(), + "identity_sha256": identity_sha256, + "report_schema_version": report.get("schema_version"), + "report_sha256": ( + report_descriptor["sha256"] if report_descriptor is not None else None + ), + "artifact_count": len(artifacts), + "verified_artifact_count": len(artifacts), + }, + "completeness": { + key: "recorded" if value is not None else "not-recorded" + for key, value in completeness_values.items() + }, + "identity": identity, + "source": source, + "configuration": configuration, + "method": method, + "execution": execution, + "resources": resources, + "metrics": metrics, + "gates": gates, + "decision": decision, + "limitations": limitations, + "authority": authority, + "artifacts": artifacts, + "visual_evidence": { + "review": visual_review, + "artifacts": visual_artifacts, + }, + "raw_report": report, + } + + def _result_root( + self, + definition: LaboratoryEvidenceDefinition, + result_id: str, + ) -> Path: + configured = self._runtime_root_provider() + if configured is None: + raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable") + runtime_root = configured.expanduser().absolute() + if runtime_root.is_symlink(): + raise LaboratoryEvidenceReportError("LAB runtime root must not be a symlink") + try: + runtime_root = runtime_root.resolve(strict=True) + except OSError as exc: + raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable") from exc + candidate = definition.result_root(runtime_root) / result_id + if candidate.is_symlink(): + raise LaboratoryEvidenceReportError("LAB result must not be a symlink") + try: + result_root = candidate.resolve(strict=True) + except OSError as exc: + raise LaboratoryEvidenceReportNotFound("LAB evidence result is unavailable") from exc + if not result_root.is_dir() or not result_root.is_relative_to(runtime_root): + raise LaboratoryEvidenceReportError("LAB evidence result path is invalid") + return result_root + + +def _validate_document( + document: dict[str, Any], + definition: LaboratoryEvidenceDefinition, + result_id: str, +) -> None: + if document.get("schema_version") != definition.result_schema_version: + raise LaboratoryEvidenceReportError("LAB document schema is invalid") + if document.get("result_id") != result_id: + raise LaboratoryEvidenceReportError("LAB result identity is invalid") + + +def _verified_artifacts(result_root: Path, value: object) -> list[dict[str, object]]: + if value is None: + return [] + if not isinstance(value, list) or len(value) > _ARTIFACT_LIMIT: + raise LaboratoryEvidenceReportError("LAB artifact manifest is invalid") + verified: list[dict[str, object]] = [] + for index, item in enumerate(value): + descriptor = _object_or_none(item) + if descriptor is None: + raise LaboratoryEvidenceReportError(f"LAB artifact {index} is invalid") + path_value = descriptor.get("path") + byte_length = descriptor.get("byte_length") + sha256 = descriptor.get("sha256") + if ( + not isinstance(path_value, str) + or not isinstance(byte_length, int) + or isinstance(byte_length, bool) + or byte_length < 0 + or not isinstance(sha256, str) + or len(sha256) != 64 + ): + raise LaboratoryEvidenceReportError(f"LAB artifact {index} proof is invalid") + path = _safe_file(result_root, path_value) + if path.stat().st_size != byte_length or _file_sha256(path) != sha256: + raise LaboratoryEvidenceReportError(f"LAB artifact {index} proof does not match") + verified.append( + { + "kind": _optional_text(descriptor.get("role") or descriptor.get("kind")), + "path": path_value, + "byte_length": byte_length, + "sha256": sha256, + "schema_version": _optional_text(descriptor.get("schema_version")), + "media_type": _optional_text(descriptor.get("media_type")), + "verified": True, + } + ) + return verified + + +def _safe_file(root: Path, relative: str) -> Path: + if not isinstance(relative, str) or "\\" in relative: + raise LaboratoryEvidenceReportError("LAB artifact path is invalid") + posix = PurePosixPath(relative) + if ( + posix.is_absolute() + or not posix.parts + or str(posix) != relative + or any(part in {"", ".", ".."} for part in posix.parts) + ): + raise LaboratoryEvidenceReportError("LAB artifact path is invalid") + candidate = root.joinpath(*posix.parts) + current = root + for part in posix.parts: + current = current / part + if current.is_symlink(): + raise LaboratoryEvidenceReportError("LAB artifact must not use symlinks") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise LaboratoryEvidenceReportError("LAB artifact is missing") from exc + if not resolved.is_file() or not resolved.is_relative_to(root): + raise LaboratoryEvidenceReportError("LAB artifact path escaped its result") + return resolved + + +def _read_bounded(path: Path, maximum: int, label: str) -> bytes: + if path.stat().st_size > maximum: + raise LaboratoryEvidenceReportError(f"{label} is too large") + try: + return path.read_bytes() + except OSError as exc: + raise LaboratoryEvidenceReportError(f"{label} is unreadable") from exc + + +def _json_object(value: bytes, label: str) -> dict[str, Any]: + try: + document = json.loads(value) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise LaboratoryEvidenceReportError(f"{label} is invalid JSON") from exc + if not isinstance(document, dict) or not all(isinstance(key, str) for key in document): + raise LaboratoryEvidenceReportError(f"{label} must be an object") + return document + + +def _read_json_artifact( + root: Path, + descriptor: dict[str, object], + label: str, +) -> dict[str, Any]: + path_value = descriptor["path"] + if not isinstance(path_value, str): + raise LaboratoryEvidenceReportError(f"{label} path is invalid") + encoded = _read_bounded( + _safe_file(root, path_value), + _DOCUMENT_MAX_BYTES, + label, + ) + return _json_object(encoded, label) + + +def _report_descriptor( + artifacts: list[dict[str, object]], +) -> dict[str, object] | None: + return next( + ( + artifact + for artifact in artifacts + if "report" in str(artifact.get("kind") or "").lower() + and str(artifact.get("path") or "").lower().endswith(".json") + ), + None, + ) + + +def _runtime_descriptor( + artifacts: list[dict[str, object]], +) -> dict[str, object] | None: + return next( + ( + artifact + for artifact in artifacts + if "runtime" in str(artifact.get("kind") or "").lower() + and str(artifact.get("path") or "").lower().endswith(".json") + ), + None, + ) + + +def _is_visual_artifact(artifact: dict[str, object]) -> bool: + kind = str(artifact.get("kind") or "").lower() + media_type = str(artifact.get("media_type") or "").lower() + suffix = Path(str(artifact.get("path") or "")).suffix.lower() + return ( + any(token in kind for token in ("visual", "video", "overlay", "contact-sheet", "image")) + or media_type.startswith(("image/", "video/")) + or suffix in {".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"} + ) + + +def _canonical_sha256(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(_HASH_CHUNK_BYTES), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _object_or_none(value: object) -> dict[str, Any] | None: + if isinstance(value, dict) and all(isinstance(key, str) for key in value): + return value + return None + + +def _first_object(*values: object) -> dict[str, Any] | None: + for value in values: + document = _object_or_none(value) + if document is not None: + return document + return None + + +def _nested(value: object, *keys: str) -> object: + current = value + for key in keys: + document = _object_or_none(current) + if document is None: + return None + current = document.get(key) + return current + + +def _json_value_or_none(value: object) -> object | None: + return value if value is not None else None + + +def _source_projection(*documents: dict[str, Any]) -> dict[str, Any] | None: + keys = { + "camera_source_id", + "frame_count", + "route_frame_count", + "session_id", + "source_display_name", + "source_id", + "source_result_id", + "source_session_id", + "source_sha256", + "stream_sha256", + "upstream", + } + result: dict[str, Any] = {} + for document in documents: + for key, value in document.items(): + if key in keys or key.endswith("_source"): + result.setdefault(key, value) + return result or None + + +def _configuration_projection( + report: dict[str, Any], + identity: dict[str, Any], +) -> dict[str, Any] | None: + keys = { + "analysis_profile", + "configuration", + "detection", + "detector", + "parameters", + "preprocessing", + "profile", + "valid_fov", + } + result: dict[str, Any] = {} + for document in (report, identity): + for key, value in document.items(): + if key in keys: + result.setdefault(key, value) + return result or None + + +def _optional_text(value: object) -> str | None: + return value if isinstance(value, str) and value.strip() else None diff --git a/src/k1link/laboratory/value_review_registry.py b/src/k1link/laboratory/value_review_registry.py new file mode 100644 index 0000000..576dfb3 --- /dev/null +++ b/src/k1link/laboratory/value_review_registry.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Literal, cast + +LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA: Final = ( + "missioncore.laboratory-value-review-registry/v1" +) +LABORATORY_VALUE_REVIEW_INDEX_SCHEMA: Final = "missioncore.laboratory-value-review-index/v1" +_REGISTRY_MAX_BYTES: Final = 128 * 1024 +_CATALOG_ID = re.compile(r"^(?:[a-z][a-z0-9-]{2,95}|session:[A-Za-z0-9._:-]{3,128})$") +_EVIDENCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,191}$") +_ROOT_KEYS: Final = frozenset({"schema_version", "reviewed_at_utc", "entries"}) +_ENTRY_KEYS: Final = frozenset( + {"catalog_id", "evidence_id", "signal", "lifecycle", "visual_evidence"} +) + +LaboratoryValueSignal = Literal["progress", "retained", "failed"] +LaboratoryValueLifecycle = Literal["current", "legacy"] +LaboratoryVisualEvidence = Literal["available", "partial", "missing"] + + +class LaboratoryValueReviewRegistryError(ValueError): + """Raised when reviewed LAB value metadata is unsafe or ambiguous.""" + + +@dataclass(frozen=True, slots=True) +class LaboratoryValueReviewEntry: + catalog_id: str + evidence_id: str + signal: LaboratoryValueSignal + lifecycle: LaboratoryValueLifecycle + visual_evidence: LaboratoryVisualEvidence + + def as_payload(self) -> dict[str, str]: + return { + "catalog_id": self.catalog_id, + "evidence_id": self.evidence_id, + "signal": self.signal, + "lifecycle": self.lifecycle, + "visual_evidence": self.visual_evidence, + "access": "read-only", + } + + +@dataclass(frozen=True, slots=True) +class LaboratoryValueReviewRegistry: + reviewed_at_utc: str + entries: tuple[LaboratoryValueReviewEntry, ...] + + def __post_init__(self) -> None: + _text(self.reviewed_at_utc, "reviewed_at_utc", maximum=64) + if not isinstance(self.entries, tuple) or not all( + isinstance(entry, LaboratoryValueReviewEntry) for entry in self.entries + ): + raise LaboratoryValueReviewRegistryError( + "LAB value-review entries must be an immutable tuple" + ) + catalog_ids = [entry.catalog_id for entry in self.entries] + if len(catalog_ids) != len(set(catalog_ids)): + raise LaboratoryValueReviewRegistryError("duplicate LAB value-review catalog_id") + + @classmethod + def from_file(cls, path: Path) -> LaboratoryValueReviewRegistry: + candidate = path.expanduser().absolute() + if candidate.is_symlink() or not candidate.is_file(): + raise LaboratoryValueReviewRegistryError( + "LAB value-review registry must be a regular file" + ) + if candidate.stat().st_size > _REGISTRY_MAX_BYTES: + raise LaboratoryValueReviewRegistryError("LAB value-review registry is too large") + try: + payload: object = json.loads(candidate.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise LaboratoryValueReviewRegistryError( + "LAB value-review registry is unreadable" + ) from exc + document = _object(payload, "LAB value-review registry") + _exact_keys(document, _ROOT_KEYS, "LAB value-review registry") + if document["schema_version"] != LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA: + raise LaboratoryValueReviewRegistryError( + "LAB value-review registry schema is invalid" + ) + entries = document["entries"] + if not isinstance(entries, list) or len(entries) > 128: + raise LaboratoryValueReviewRegistryError("LAB value-review entries are invalid") + return cls( + reviewed_at_utc=_text(document["reviewed_at_utc"], "reviewed_at_utc", maximum=64), + entries=tuple(_entry(item, index) for index, item in enumerate(entries)), + ) + + def as_payload(self) -> dict[str, object]: + return { + "schema_version": LABORATORY_VALUE_REVIEW_INDEX_SCHEMA, + "reviewed_at_utc": self.reviewed_at_utc, + "items": [entry.as_payload() for entry in self.entries], + "access": "read-only", + } + + +def _entry(value: object, index: int) -> LaboratoryValueReviewEntry: + document = _object(value, f"LAB value-review entry {index}") + _exact_keys(document, _ENTRY_KEYS, f"LAB value-review entry {index}") + catalog_id = _text(document["catalog_id"], "catalog_id", maximum=160) + evidence_id = _text(document["evidence_id"], "evidence_id", maximum=192) + if _CATALOG_ID.fullmatch(catalog_id) is None: + raise LaboratoryValueReviewRegistryError("LAB value-review catalog_id is invalid") + if _EVIDENCE_ID.fullmatch(evidence_id) is None: + raise LaboratoryValueReviewRegistryError("LAB value-review evidence_id is invalid") + signal = document["signal"] + lifecycle = document["lifecycle"] + visual_evidence = document["visual_evidence"] + if signal not in {"progress", "retained", "failed"}: + raise LaboratoryValueReviewRegistryError("LAB value-review signal is invalid") + if lifecycle not in {"current", "legacy"}: + raise LaboratoryValueReviewRegistryError("LAB value-review lifecycle is invalid") + if visual_evidence not in {"available", "partial", "missing"}: + raise LaboratoryValueReviewRegistryError("LAB value-review visual_evidence is invalid") + return LaboratoryValueReviewEntry( + catalog_id=catalog_id, + evidence_id=evidence_id, + signal=cast(LaboratoryValueSignal, signal), + lifecycle=cast(LaboratoryValueLifecycle, lifecycle), + visual_evidence=cast(LaboratoryVisualEvidence, visual_evidence), + ) + + +def _object(value: object, label: str) -> dict[str, object]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise LaboratoryValueReviewRegistryError(f"{label} must be an object") + return value + + +def _exact_keys(document: dict[str, object], expected: frozenset[str], label: str) -> None: + actual = frozenset(document) + if actual != expected: + raise LaboratoryValueReviewRegistryError( + f"{label} keys are invalid; missing={sorted(expected - actual)}, " + f"unexpected={sorted(actual - expected)}" + ) + + +def _text(value: object, label: str, *, maximum: int = 768) -> str: + if ( + not isinstance(value, str) + or not value.strip() + or value != value.strip() + or len(value) > maximum + ): + raise LaboratoryValueReviewRegistryError( + f"{label} must be a bounded trimmed string" + ) + return value diff --git a/src/k1link/web/advanced_laboratory_api.py b/src/k1link/web/advanced_laboratory_api.py index 8d66cf7..921b4bd 100644 --- a/src/k1link/web/advanced_laboratory_api.py +++ b/src/k1link/web/advanced_laboratory_api.py @@ -57,6 +57,7 @@ from k1link.compute.e40_perception_product_gate import ( ) from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity +from k1link.web.l31_pointpillars_ravnoves_api import latest_l31_identity from k1link.web.l32_pointpillars_camera_review_api import latest_l32_identity from k1link.web.l33_camera_first_detector_review_api import latest_l33_identity @@ -917,6 +918,15 @@ def build_advanced_laboratory_router( "access": "read-only", } ) + l31_identity = latest_l31_identity(l31_ravnoves_root_provider) + if l31_identity is not None: + items.append( + { + "work_id": "l31-pointpillars-ravnoves", + **l31_identity, + "access": "read-only", + } + ) l32_identity = latest_l32_identity(l32_camera_review_root_provider) if l32_identity is not None: items.append( diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 40cf13a..9969e8d 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -23,7 +23,11 @@ from k1link.compute import ( RecordedPerceptionOverlayMux, RecordedPerceptionOverlayStore, ) -from k1link.laboratory import LaboratoryEvidenceRegistry +from k1link.laboratory import ( + LaboratoryEvidenceRegistry, + LaboratoryEvidenceReportService, + LaboratoryValueReviewRegistry, +) from k1link.sessions import ( MaterializedRecording, RecordedMediaInspector, @@ -104,6 +108,7 @@ from k1link.web.l34e_self_review_diagnostic_api import ( ) from k1link.web.l34f_adjudication_api import build_l34f_adjudication_router from k1link.web.laboratory_api import build_laboratory_router +from k1link.web.laboratory_report_api import build_laboratory_report_router from k1link.web.lidar_api import build_lidar_router from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService from k1link.web.map_api import ( @@ -137,6 +142,13 @@ INVALID_REQUEST_DETAIL = "Некорректные параметры запро LABORATORY_EVIDENCE_REGISTRY = LaboratoryEvidenceRegistry.from_directory( REPOSITORY_ROOT / "config" / "laboratories" ) +LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file( + REPOSITORY_ROOT / "config" / "laboratory-value-review.json" +) +LABORATORY_EVIDENCE_REPORTS = LaboratoryEvidenceReportService( + LABORATORY_EVIDENCE_REGISTRY, + lambda: REPOSITORY_ROOT / ".runtime" / "compute-experiments", +) def _resolve_media_tool(name: str) -> Path | None: @@ -559,6 +571,12 @@ app.include_router( ), ) ) +app.include_router( + build_laboratory_report_router( + LABORATORY_VALUE_REVIEW_REGISTRY, + LABORATORY_EVIDENCE_REPORTS, + ) +) app.include_router( build_advanced_laboratory_router( evidence_registry=LABORATORY_EVIDENCE_REGISTRY, diff --git a/src/k1link/web/laboratory_report_api.py b/src/k1link/web/laboratory_report_api.py new file mode 100644 index 0000000..6f8ad55 --- /dev/null +++ b/src/k1link/web/laboratory_report_api.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from k1link.laboratory import ( + LaboratoryEvidenceReportError, + LaboratoryEvidenceReportNotFound, + LaboratoryEvidenceReportService, + LaboratoryValueReviewRegistry, +) + + +def build_laboratory_report_router( + registry: LaboratoryValueReviewRegistry, + evidence_reports: LaboratoryEvidenceReportService | None = None, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"]) + + @router.get("/value-review-index") + def get_value_review_index() -> dict[str, object]: + return registry.as_payload() + + @router.get("/evidence-reports/{work_id}/{result_id}") + def get_evidence_report(work_id: str, result_id: str) -> dict[str, object]: + if evidence_reports is None: + raise HTTPException(status_code=404, detail="LAB evidence report is unavailable") + try: + return evidence_reports.read(work_id, result_id) + except LaboratoryEvidenceReportNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except LaboratoryEvidenceReportError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + return router diff --git a/tests/test_advanced_laboratory_api.py b/tests/test_advanced_laboratory_api.py index d323878..0798297 100644 --- a/tests/test_advanced_laboratory_api.py +++ b/tests/test_advanced_laboratory_api.py @@ -74,6 +74,30 @@ def test_advanced_index_is_empty_when_not_configured() -> None: } +def test_advanced_index_includes_valid_l31_identity( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + identity = { + "result_id": f"l31-pointpillars-ravnoves-{'a' * 64}", + "created_at_utc": "2026-07-31T10:56:45.861Z", + } + monkeypatch.setattr(advanced_api, "latest_l31_identity", lambda _provider: identity) + router = build_advanced_laboratory_router( + l31_ravnoves_root_provider=lambda: tmp_path, + ) + + index = _endpoint(router, "/api/v1/laboratory/advanced-index")() + + assert index["items"] == [ # type: ignore[index] + { + "work_id": "l31-pointpillars-ravnoves", + **identity, + "access": "read-only", + } + ] + + def test_registry_index_does_not_follow_a_runtime_symlink(tmp_path: Path) -> None: actual = tmp_path / "actual" actual.mkdir() diff --git a/tests/test_laboratory_evidence_report.py b/tests/test_laboratory_evidence_report.py new file mode 100644 index 0000000..a591bfe --- /dev/null +++ b/tests/test_laboratory_evidence_report.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from k1link.laboratory import ( + LaboratoryEvidenceRegistry, + LaboratoryEvidenceReportError, + LaboratoryEvidenceReportNotFound, + LaboratoryEvidenceReportService, +) + + +def _canonical_sha256(value: object) -> str: + return hashlib.sha256( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + + +def _write_json(path: Path, value: object) -> bytes: + encoded = json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8") + path.write_bytes(encoded) + return encoded + + +def _service(tmp_path: Path) -> tuple[LaboratoryEvidenceReportService, str, Path]: + definitions = tmp_path / "definitions" + definitions.mkdir() + _write_json( + definitions / "e99-proof.json", + { + "schema_version": "missioncore.laboratory-evidence-definition/v1", + "work_id": "e99-proof", + "evidence": { + "runtime_relative_root": "e99/results", + "result_id_prefix": "e99-proof", + "document_name": "manifest.json", + "schema_version": "missioncore.e99-result/v1", + }, + }, + ) + runtime = tmp_path / "runtime" + results = runtime / "e99" / "results" + results.mkdir(parents=True) + source = {"session_id": "immutable-source", "stream_sha256": "a" * 64} + model = {"name": "detector", "sha256": "b" * 64} + identity = { + "schema_version": "missioncore.e99-result/v1", + "profile": { + "source": source, + "model": model, + }, + "worker": {"node": "worker-006", "container": "ndc-worker@sha256:proof"}, + "authority": {"commands_enabled": False, "navigation_or_safety_accepted": False}, + } + identity_sha256 = _canonical_sha256(identity) + result_id = f"e99-proof-{identity_sha256}" + result_root = results / result_id + result_root.mkdir() + report = { + "schema_version": "missioncore.e99-report/v1", + "source": source, + "method": {"components": [model]}, + "metrics": { + "frames": 100, + "resources": {"gpu_utilization_p95_percent": 48.0, "rss_p95_mib": 91.0}, + }, + "acceptance": {"passed": True, "checks": {"frame_coverage": True}}, + "decision": {"promoted": False, "next_action": "temporal gate"}, + "limitations": ["No independent ground truth."], + "authority": identity["authority"], + } + report_bytes = _write_json(result_root / "report.json", report) + visual_bytes = b"visual-proof" + (result_root / "visual.png").write_bytes(visual_bytes) + manifest = { + "schema_version": "missioncore.e99-result/v1", + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": "2026-08-05T09:00:00Z", + "artifacts": [ + { + "role": "engineering-report", + "path": "report.json", + "byte_length": len(report_bytes), + "sha256": hashlib.sha256(report_bytes).hexdigest(), + }, + { + "role": "visual-contact-sheet", + "path": "visual.png", + "byte_length": len(visual_bytes), + "sha256": hashlib.sha256(visual_bytes).hexdigest(), + }, + ], + } + _write_json(result_root / "manifest.json", manifest) + registry = LaboratoryEvidenceRegistry.from_directory(definitions) + return LaboratoryEvidenceReportService(registry, lambda: runtime), result_id, result_root + + +def test_evidence_report_projects_verified_canonical_evidence(tmp_path: Path) -> None: + service, result_id, _ = _service(tmp_path) + + report = service.read("e99-proof", result_id) + + assert report["schema_version"] == "missioncore.laboratory-evidence-report/v1" + assert report["result_id"] == result_id + assert report["source"]["session_id"] == "immutable-source" # type: ignore[index] + assert report["configuration"]["profile"]["model"]["name"] == "detector" # type: ignore[index] + assert report["resources"]["gpu_utilization_p95_percent"] == 48.0 # type: ignore[index] + assert report["proof"]["verified_artifact_count"] == 2 # type: ignore[index] + assert report["completeness"]["visual_evidence"] == "recorded" # type: ignore[index] + assert report["completeness"]["execution"] == "recorded" # type: ignore[index] + + +def test_evidence_report_rejects_tampered_artifact(tmp_path: Path) -> None: + service, result_id, result_root = _service(tmp_path) + (result_root / "visual.png").write_bytes(b"tampered") + + with pytest.raises(LaboratoryEvidenceReportError, match="does not match"): + service.read("e99-proof", result_id) + + +def test_evidence_report_rejects_unknown_identity(tmp_path: Path) -> None: + service, _, _ = _service(tmp_path) + + with pytest.raises(LaboratoryEvidenceReportNotFound, match="unavailable"): + service.read("e99-proof", f"e99-proof-{'f' * 64}") + + +def test_evidence_report_marks_missing_fields_without_inventing_them(tmp_path: Path) -> None: + service, result_id, result_root = _service(tmp_path) + report_path = result_root / "report.json" + report = json.loads(report_path.read_text(encoding="utf-8")) + report.pop("metrics") + report.pop("limitations") + report_bytes = _write_json(report_path, report) + manifest_path = result_root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["artifacts"][0]["byte_length"] = len(report_bytes) + manifest["artifacts"][0]["sha256"] = hashlib.sha256(report_bytes).hexdigest() + _write_json(manifest_path, manifest) + + evidence = service.read("e99-proof", result_id) + + assert evidence["metrics"] is None + assert evidence["limitations"] is None + assert evidence["completeness"]["metrics"] == "not-recorded" # type: ignore[index] + assert evidence["completeness"]["limitations"] == "not-recorded" # type: ignore[index] diff --git a/tests/test_laboratory_value_review_registry.py b/tests/test_laboratory_value_review_registry.py new file mode 100644 index 0000000..41b77e4 --- /dev/null +++ b/tests/test_laboratory_value_review_registry.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.routing import APIRoute + +from k1link.laboratory import ( + LaboratoryValueReviewRegistry, + LaboratoryValueReviewRegistryError, +) +from k1link.web.laboratory_report_api import build_laboratory_report_router + + +def _document() -> dict[str, object]: + return { + "schema_version": "missioncore.laboratory-value-review-registry/v1", + "reviewed_at_utc": "2026-08-05T08:30:00Z", + "entries": [ + { + "catalog_id": "e46j-raw-fisheye-realtime", + "evidence_id": f"e46j-raw-fisheye-realtime-{'a' * 64}", + "signal": "progress", + "lifecycle": "current", + "visual_evidence": "available", + } + ], + } + + +def _write(path: Path, document: dict[str, object]) -> None: + path.write_text(json.dumps(document), encoding="utf-8") + + +def test_value_review_registry_is_strict_and_projects_read_only_index(tmp_path: Path) -> None: + path = tmp_path / "laboratory-value-review.json" + _write(path, _document()) + + registry = LaboratoryValueReviewRegistry.from_file(path) + router = build_laboratory_report_router(registry) + route = next( + route + for route in router.routes + if isinstance(route, APIRoute) + and route.path == "/api/v1/laboratory/value-review-index" + ) + + payload = route.endpoint() + assert payload["schema_version"] == "missioncore.laboratory-value-review-index/v1" + assert payload["items"][0]["signal"] == "progress" + assert payload["items"][0]["access"] == "read-only" + + +def test_value_review_registry_rejects_unknown_keys(tmp_path: Path) -> None: + path = tmp_path / "laboratory-value-review.json" + document = _document() + document["renderer"] = "local" + _write(path, document) + + with pytest.raises(LaboratoryValueReviewRegistryError, match="unexpected"): + LaboratoryValueReviewRegistry.from_file(path) + + +def test_value_review_registry_rejects_duplicate_catalog_ids(tmp_path: Path) -> None: + path = tmp_path / "laboratory-value-review.json" + document = _document() + entries = document["entries"] + assert isinstance(entries, list) + entries.append({**entries[0], "evidence_id": f"duplicate-{'b' * 64}"}) + _write(path, document) + + with pytest.raises(LaboratoryValueReviewRegistryError, match="duplicate"): + LaboratoryValueReviewRegistry.from_file(path) + + +def test_product_value_review_registry_covers_reviewed_laboratory_families() -> None: + root = Path(__file__).resolve().parents[1] + registry = LaboratoryValueReviewRegistry.from_file( + root / "config" / "laboratory-value-review.json" + ) + + assert len(registry.entries) == 34 + assert {entry.catalog_id for entry in registry.entries} >= { + "e28-local-surface", + "e46d-temporal-failure-audit", + "e46j-raw-fisheye-realtime", + "l31-pointpillars-ravnoves", + "l34f-adjudicated-reference", + }