907 lines
36 KiB
Python
907 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import secrets
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Annotated, Any, Final
|
|
|
|
import numpy as np
|
|
from fastapi import APIRouter, Header, HTTPException, Query
|
|
from fastapi import Path as PathParameter
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from k1link.simulation import (
|
|
QualificationRun,
|
|
QualificationRunIntegrityError,
|
|
QualificationRunNotFoundError,
|
|
QualificationRunStore,
|
|
)
|
|
from k1link.simulation.worker_gateway import (
|
|
STATUS_SCHEMA,
|
|
PolygonWorkerGateway,
|
|
SimulationWorkerGatewayError,
|
|
SimulationWorkerRejectedError,
|
|
SimulationWorkerUnavailableError,
|
|
UnixSocketWorkerGateway,
|
|
)
|
|
|
|
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
|
|
POLYGON_REVIEW_ROOT_ENV: Final = "MISSIONCORE_POLYGON_REVIEW_ROOT"
|
|
POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET"
|
|
POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
|
|
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
|
|
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
|
|
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
|
|
GROUND_QUALIFICATION_SCHEMA: Final = "missioncore.polygon-ground-qualification/v1"
|
|
GROUND_FAILURE_PREVIEW_SCHEMA: Final = "missioncore.polygon-ground-failure-preview/v1"
|
|
GROUND_REPORT_ARTIFACT_KIND: Final = "goose-ground-qualification-report"
|
|
RELLIS_REPORT_ARTIFACT_KIND: Final = "rellis-ground-qualification-report"
|
|
GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview"
|
|
GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1"
|
|
RELLIS_REPORT_SCHEMA: Final = "missioncore.rellis-ground-qualification-report/v1"
|
|
GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1"
|
|
GROUND_REVIEW_SCHEMA: Final = "missioncore.polygon-ground-review/v2"
|
|
GROUND_REVIEW_FRAME_SCHEMA: Final = "missioncore.polygon-ground-review-frame/v1"
|
|
GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1"
|
|
GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/v1"
|
|
RELLIS_REVIEW_PACK_SCHEMA: Final = "missioncore.rellis-ground-review-pack/v1"
|
|
RELLIS_REVIEW_FRAME_SCHEMA: Final = "missioncore.rellis-ground-review-frame/v1"
|
|
MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2
|
|
MAX_REVIEW_MANIFEST_BYTES: Final = 8 * 1024**2
|
|
MAX_REVIEW_FRAME_BYTES: Final = 2 * 1024**2
|
|
MAX_REVIEW_FRAMES: Final = 3_000
|
|
MAX_REVIEW_POINTS: Final = 20_000
|
|
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
|
|
GOOSE_FRAME_ID_PATTERN: Final = re.compile(
|
|
r"^(?P<dataset_sequence_id>[A-Za-z0-9][A-Za-z0-9_-]{0,95})"
|
|
r"__(?P<dataset_frame_number>[0-9]{4})"
|
|
r"_(?P<sensor_timestamp_ns>[0-9]{16,20})$"
|
|
)
|
|
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
|
|
"Архивный UI-0 публикует только квалификационные доказательства; "
|
|
"содержимое команд и mutation-маршруты в архиве отсутствуют.",
|
|
"Артефакты представлены относительными метаданными; их содержимое не публикуется этим API.",
|
|
"Терминальное состояние прогона не означает приёмку навигации, "
|
|
"восприятия или физической безопасности.",
|
|
)
|
|
|
|
RootProvider = Callable[[], Path | None]
|
|
WorkerProvider = Callable[[], PolygonWorkerGateway | None]
|
|
ControlProvider = Callable[[], bool]
|
|
CommitProvider = Callable[[], str | None]
|
|
|
|
|
|
class StartStockRoverRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
scenario_id: str
|
|
|
|
|
|
class AckermannCommandRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
speed_mps: float = Field(ge=-1.5, le=1.5, allow_inf_nan=False)
|
|
steering_normalized: float = Field(ge=-1.0, le=1.0, allow_inf_nan=False)
|
|
|
|
|
|
def configured_polygon_runs_root() -> Path | None:
|
|
raw = os.environ.get(POLYGON_RUNS_ROOT_ENV)
|
|
if raw is None or not raw.strip():
|
|
return None
|
|
return Path(raw.strip()).expanduser()
|
|
|
|
|
|
def configured_polygon_worker() -> PolygonWorkerGateway | None:
|
|
raw = os.environ.get(POLYGON_WORKER_SOCKET_ENV)
|
|
if raw is None or not raw.strip():
|
|
return None
|
|
return UnixSocketWorkerGateway(Path(raw.strip()).expanduser())
|
|
|
|
|
|
def configured_polygon_control() -> bool:
|
|
return os.environ.get(POLYGON_WORKER_CONTROL_ENV, "").strip() == "internal-virtual-only"
|
|
|
|
|
|
def configured_mission_core_commit() -> str | None:
|
|
raw = os.environ.get(MISSION_CORE_COMMIT_ENV)
|
|
if raw is None:
|
|
return None
|
|
normalized = raw.strip()
|
|
return normalized if COMMIT_PATTERN.fullmatch(normalized) else None
|
|
|
|
|
|
def build_polygon_router(
|
|
*,
|
|
root_provider: RootProvider = configured_polygon_runs_root,
|
|
worker_provider: WorkerProvider = configured_polygon_worker,
|
|
control_provider: ControlProvider = configured_polygon_control,
|
|
commit_provider: CommitProvider = configured_mission_core_commit,
|
|
) -> APIRouter:
|
|
router = APIRouter(prefix="/api/v1/polygon", tags=["polygon"])
|
|
|
|
@router.get("/runs")
|
|
def list_polygon_runs(
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
) -> dict[str, Any]:
|
|
store = _open_read_only_store(root_provider)
|
|
try:
|
|
ordered = sorted(
|
|
store.list_runs(),
|
|
key=lambda run: (run.created_at_utc, run.run_id),
|
|
reverse=True,
|
|
)
|
|
except QualificationRunIntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Журнал прогонов Полигона не прошёл проверку целостности.",
|
|
) from exc
|
|
return {
|
|
"schema_version": CATALOG_SCHEMA,
|
|
"access": "read-only",
|
|
"items": [_run_summary(run) for run in ordered[:limit]],
|
|
"total": len(ordered),
|
|
"limitations": list(READ_ONLY_LIMITATIONS),
|
|
}
|
|
|
|
@router.get("/runs/{run_id}")
|
|
def get_polygon_run(
|
|
run_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
],
|
|
event_limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
|
) -> dict[str, Any]:
|
|
store = _open_read_only_store(root_provider)
|
|
try:
|
|
run = store.load(run_id)
|
|
events = store.list_events(run_id)
|
|
commands = store.list_commands(run_id)
|
|
except QualificationRunNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc
|
|
except QualificationRunIntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Доказательства прогона не прошли проверку целостности.",
|
|
) from exc
|
|
visible_events = events[-event_limit:]
|
|
return {
|
|
"schema_version": DETAIL_SCHEMA,
|
|
"access": "read-only",
|
|
"run": {
|
|
**_run_summary(run),
|
|
"scenario_sha256": run.scenario_sha256,
|
|
"profile_sha256": run.profile_sha256,
|
|
"host_profile_sha256": run.host_profile_sha256,
|
|
"seed": run.seed,
|
|
"parent_run_id": run.parent_run_id,
|
|
"providers": [provider.to_dict() for provider in run.providers],
|
|
"authority": run.authority.to_dict(),
|
|
},
|
|
"events": [event.to_dict() for event in visible_events],
|
|
"events_total": len(events),
|
|
"events_truncated": len(visible_events) != len(events),
|
|
"commands": {
|
|
"count": len(commands),
|
|
"content_exposed": False,
|
|
},
|
|
"artifacts": [artifact.to_dict() for artifact in run.artifacts],
|
|
"limitations": list(READ_ONLY_LIMITATIONS),
|
|
}
|
|
|
|
@router.get("/runs/{run_id}/qualification")
|
|
def get_polygon_ground_qualification(
|
|
run_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
],
|
|
) -> dict[str, Any]:
|
|
store, run = _load_run(root_provider, run_id)
|
|
report = _read_registered_json(
|
|
store,
|
|
run,
|
|
artifact_kind=(GROUND_REPORT_ARTIFACT_KIND, RELLIS_REPORT_ARTIFACT_KIND),
|
|
expected_schema=(GROUND_REPORT_SCHEMA, RELLIS_REPORT_SCHEMA),
|
|
)
|
|
required = (
|
|
"identity_sha256",
|
|
"source_id",
|
|
"split",
|
|
"frame_count",
|
|
"aggregates",
|
|
"degradations",
|
|
"checks",
|
|
"worst_frames",
|
|
"decision",
|
|
"safety",
|
|
)
|
|
if any(key not in report for key in required):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Отчёт квалификации имеет неполный контракт.",
|
|
)
|
|
return {
|
|
"schema_version": GROUND_QUALIFICATION_SCHEMA,
|
|
"access": "read-only",
|
|
"run_id": run.run_id,
|
|
**{key: report[key] for key in required},
|
|
}
|
|
|
|
@router.get("/runs/{run_id}/qualification/failures/{frame_id}")
|
|
def get_polygon_ground_failure_preview(
|
|
run_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
],
|
|
frame_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
],
|
|
) -> dict[str, Any]:
|
|
store, run = _load_run(root_provider, run_id)
|
|
preview = _read_registered_json(
|
|
store,
|
|
run,
|
|
artifact_kind=GROUND_FAILURE_ARTIFACT_KIND,
|
|
expected_schema=GROUND_FAILURE_SCHEMA,
|
|
frame_id=frame_id,
|
|
)
|
|
required = (
|
|
"source_id",
|
|
"frame_id",
|
|
"source_point_count",
|
|
"point_count",
|
|
"sampling",
|
|
"points_xyz_m",
|
|
"ground_truth_ground",
|
|
"evaluated",
|
|
"current_ground",
|
|
"patchwork_ground",
|
|
"current_disagreement",
|
|
"patchwork_disagreement",
|
|
"safety",
|
|
)
|
|
if any(key not in preview for key in required) or preview["frame_id"] != frame_id:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Preview проблемного кадра имеет неполный контракт.",
|
|
)
|
|
return {
|
|
"schema_version": GROUND_FAILURE_PREVIEW_SCHEMA,
|
|
"access": "read-only",
|
|
"run_id": run.run_id,
|
|
**{key: preview[key] for key in required},
|
|
}
|
|
|
|
@router.get("/runs/{run_id}/qualification/review")
|
|
def get_polygon_ground_review(
|
|
run_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
],
|
|
) -> dict[str, Any]:
|
|
_, run = _load_run(root_provider, run_id)
|
|
_, manifest = _read_ground_review_manifest(root_provider, run.run_id)
|
|
return {
|
|
"schema_version": GROUND_REVIEW_SCHEMA,
|
|
"access": "read-only",
|
|
"run_id": run.run_id,
|
|
"identity_sha256": manifest["identity_sha256"],
|
|
"source_id": manifest["source_id"],
|
|
"frame_count": manifest["frame_count"],
|
|
"preview_points": manifest["preview_points"],
|
|
"frames": [
|
|
{
|
|
**{
|
|
key: frame[key]
|
|
for key in (
|
|
"sequence",
|
|
"frame_id",
|
|
"source_point_count",
|
|
"point_count",
|
|
"current",
|
|
"patchworkpp",
|
|
"ground_iou_delta",
|
|
)
|
|
},
|
|
**_dataset_frame_identity(frame),
|
|
}
|
|
for frame in manifest["frames"]
|
|
],
|
|
"safety": manifest["safety"],
|
|
}
|
|
|
|
@router.get("/runs/{run_id}/qualification/review/frames/{frame_id}")
|
|
def get_polygon_ground_review_frame(
|
|
run_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
],
|
|
frame_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
],
|
|
) -> dict[str, Any]:
|
|
_, run = _load_run(root_provider, run_id)
|
|
pack_root, manifest = _read_ground_review_manifest(root_provider, run.run_id)
|
|
matches = [frame for frame in manifest["frames"] if frame.get("frame_id") == frame_id]
|
|
if len(matches) != 1:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Кадр визуального прогона не найден.",
|
|
)
|
|
frame = matches[0]
|
|
path = _verified_review_frame_path(pack_root, frame)
|
|
try:
|
|
with np.load(path, allow_pickle=False) as source:
|
|
schema = str(source["schema"][0])
|
|
stored_frame_id = str(source["frame_id"][0])
|
|
source_point_count = int(source["source_point_count"][0])
|
|
xyz_cm = np.asarray(source["xyz_cm"])
|
|
remission = np.asarray(source["remission_u8"])
|
|
flags = np.asarray(source["flags"])
|
|
except (OSError, ValueError, KeyError, IndexError) as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Кадр визуального прогона повреждён.",
|
|
) from exc
|
|
point_count = int(frame["point_count"])
|
|
if (
|
|
schema
|
|
not in {
|
|
GOOSE_REVIEW_FRAME_SCHEMA,
|
|
RELLIS_REVIEW_FRAME_SCHEMA,
|
|
}
|
|
or stored_frame_id != frame_id
|
|
or source_point_count != frame["source_point_count"]
|
|
or xyz_cm.dtype != np.dtype("<i2")
|
|
or xyz_cm.shape != (point_count, 3)
|
|
or remission.dtype != np.uint8
|
|
or remission.shape != (point_count,)
|
|
or flags.dtype != np.uint8
|
|
or flags.shape != (point_count,)
|
|
or not 1 <= point_count <= MAX_REVIEW_POINTS
|
|
or np.any(flags > 15)
|
|
):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Кадр визуального прогона нарушает контракт.",
|
|
)
|
|
evaluated = (flags & 1) != 0
|
|
ground_truth = (flags & 2) != 0
|
|
current = (flags & 4) != 0
|
|
patchwork = (flags & 8) != 0
|
|
return {
|
|
"schema_version": GROUND_REVIEW_FRAME_SCHEMA,
|
|
"access": "read-only",
|
|
"run_id": run.run_id,
|
|
"source_id": manifest["source_id"],
|
|
"frame_id": frame_id,
|
|
"source_point_count": source_point_count,
|
|
"point_count": point_count,
|
|
"sampling": "deterministic-even-index",
|
|
"points_xyz_m": (xyz_cm.astype(np.float32) / 100.0).tolist(),
|
|
"intensity_0_255": remission.tolist(),
|
|
"ground_truth_ground": ground_truth.astype(np.uint8).tolist(),
|
|
"evaluated": evaluated.astype(np.uint8).tolist(),
|
|
"current_ground": current.astype(np.uint8).tolist(),
|
|
"patchwork_ground": patchwork.astype(np.uint8).tolist(),
|
|
"current_disagreement": (evaluated & (current != ground_truth))
|
|
.astype(np.uint8)
|
|
.tolist(),
|
|
"patchwork_disagreement": (evaluated & (patchwork != ground_truth))
|
|
.astype(np.uint8)
|
|
.tolist(),
|
|
"safety": manifest["safety"],
|
|
}
|
|
|
|
@router.get("/worker")
|
|
def get_polygon_worker() -> dict[str, Any]:
|
|
gateway = worker_provider()
|
|
if gateway is None:
|
|
return _unavailable_worker_status()
|
|
try:
|
|
status = gateway.status()
|
|
except SimulationWorkerGatewayError:
|
|
return _unavailable_worker_status()
|
|
return {
|
|
**status,
|
|
"control_available": bool(status["control_available"] and control_provider()),
|
|
}
|
|
|
|
@router.get("/worker/live")
|
|
def get_polygon_worker_live() -> dict[str, Any]:
|
|
gateway = _required_worker(worker_provider)
|
|
try:
|
|
return gateway.live()
|
|
except SimulationWorkerUnavailableError as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Simulation Worker недоступен.",
|
|
) from exc
|
|
except SimulationWorkerRejectedError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
except SimulationWorkerGatewayError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="Simulation Worker вернул некорректное состояние.",
|
|
) from exc
|
|
|
|
@router.post("/worker/runs")
|
|
def start_polygon_worker_run(
|
|
request: StartStockRoverRequest,
|
|
idempotency_key: Annotated[
|
|
str,
|
|
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
|
],
|
|
) -> dict[str, Any]:
|
|
if request.scenario_id != "stock-rover-ackermann":
|
|
raise HTTPException(status_code=422, detail="Сценарий Полигона не поддерживается.")
|
|
gateway, commit = _control_context(
|
|
worker_provider,
|
|
control_provider,
|
|
commit_provider,
|
|
)
|
|
run_id = _new_run_id(commit)
|
|
try:
|
|
return gateway.start(
|
|
run_id=run_id,
|
|
mission_core_commit=commit,
|
|
idempotency_key=idempotency_key,
|
|
)
|
|
except SimulationWorkerUnavailableError as exc:
|
|
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
|
except SimulationWorkerRejectedError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
except SimulationWorkerGatewayError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="Simulation Worker не подтвердил запуск.",
|
|
) from exc
|
|
|
|
@router.post("/worker/runs/{run_id}/stop")
|
|
def stop_polygon_worker_run(
|
|
run_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"),
|
|
],
|
|
idempotency_key: Annotated[
|
|
str,
|
|
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
|
],
|
|
) -> dict[str, Any]:
|
|
gateway, _ = _control_context(
|
|
worker_provider,
|
|
control_provider,
|
|
commit_provider,
|
|
)
|
|
try:
|
|
return gateway.stop(run_id=run_id, idempotency_key=idempotency_key)
|
|
except SimulationWorkerUnavailableError as exc:
|
|
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
|
except SimulationWorkerRejectedError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
except SimulationWorkerGatewayError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="Simulation Worker не подтвердил остановку.",
|
|
) from exc
|
|
|
|
@router.post("/worker/runs/{run_id}/commands")
|
|
def command_polygon_worker_run(
|
|
run_id: Annotated[
|
|
str,
|
|
PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"),
|
|
],
|
|
request: AckermannCommandRequest,
|
|
idempotency_key: Annotated[
|
|
str,
|
|
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
|
],
|
|
) -> dict[str, Any]:
|
|
gateway, _ = _control_context(
|
|
worker_provider,
|
|
control_provider,
|
|
commit_provider,
|
|
)
|
|
try:
|
|
return gateway.command(
|
|
run_id=run_id,
|
|
speed_mps=request.speed_mps,
|
|
steering_normalized=request.steering_normalized,
|
|
idempotency_key=idempotency_key,
|
|
)
|
|
except SimulationWorkerUnavailableError as exc:
|
|
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
|
except SimulationWorkerRejectedError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
except SimulationWorkerGatewayError as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="Simulation Worker не подтвердил команду ровера.",
|
|
) from exc
|
|
|
|
return router
|
|
|
|
|
|
def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
|
|
root = root_provider()
|
|
if root is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Источник журналов Полигона не настроен на этом экземпляре Mission Core.",
|
|
)
|
|
if not root.is_absolute():
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Источник журналов Полигона настроен некорректно.",
|
|
)
|
|
try:
|
|
return QualificationRunStore(root, read_only=True)
|
|
except QualificationRunIntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Источник журналов Полигона недоступен или настроен некорректно.",
|
|
) from exc
|
|
|
|
|
|
def _load_run(
|
|
root_provider: RootProvider,
|
|
run_id: str,
|
|
) -> tuple[QualificationRunStore, QualificationRun]:
|
|
store = _open_read_only_store(root_provider)
|
|
try:
|
|
return store, store.load(run_id)
|
|
except QualificationRunNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc
|
|
except QualificationRunIntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Доказательства прогона не прошли проверку целостности.",
|
|
) from exc
|
|
|
|
|
|
def _read_registered_json(
|
|
store: QualificationRunStore,
|
|
run: QualificationRun,
|
|
*,
|
|
artifact_kind: str | tuple[str, ...],
|
|
expected_schema: str | tuple[str, ...],
|
|
frame_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
artifact_kinds = (artifact_kind,) if isinstance(artifact_kind, str) else artifact_kind
|
|
expected_schemas = (
|
|
(expected_schema,) if isinstance(expected_schema, str) else expected_schema
|
|
)
|
|
candidates = [
|
|
artifact for artifact in run.artifacts if artifact.kind in artifact_kinds
|
|
]
|
|
if frame_id is not None:
|
|
candidates = [
|
|
artifact for artifact in candidates if Path(artifact.relative_path).stem == frame_id
|
|
]
|
|
if len(candidates) != 1:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Квалификационный артефакт для этого прогона не найден.",
|
|
)
|
|
artifact = candidates[0]
|
|
if artifact.byte_length > MAX_QUALIFICATION_ARTIFACT_BYTES:
|
|
raise HTTPException(status_code=500, detail="Квалификационный артефакт слишком велик.")
|
|
run_root = (store.root / run.run_id).resolve()
|
|
path = run_root / artifact.relative_path
|
|
try:
|
|
resolved = path.resolve(strict=True)
|
|
resolved.relative_to(run_root)
|
|
except (OSError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Путь квалификационного артефакта нарушает границу прогона.",
|
|
) from exc
|
|
if (
|
|
path.is_symlink()
|
|
or not resolved.is_file()
|
|
or resolved.stat().st_size != artifact.byte_length
|
|
):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Квалификационный артефакт не прошёл проверку файла.",
|
|
)
|
|
digest = hashlib.sha256(resolved.read_bytes()).hexdigest()
|
|
if digest != artifact.sha256:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Квалификационный артефакт не прошёл проверку SHA-256.",
|
|
)
|
|
try:
|
|
value = json.loads(resolved.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Квалификационный артефакт не является допустимым JSON.",
|
|
) from exc
|
|
if not isinstance(value, dict) or value.get("schema_version") not in expected_schemas:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Квалификационный артефакт имеет неизвестную схему.",
|
|
)
|
|
return value
|
|
|
|
|
|
def _read_ground_review_manifest(
|
|
root_provider: RootProvider,
|
|
run_id: str,
|
|
) -> tuple[Path, dict[str, Any]]:
|
|
runs_root = root_provider()
|
|
if runs_root is None or not runs_root.is_absolute():
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Источник визуальных прогонов не настроен.",
|
|
)
|
|
configured = os.environ.get(POLYGON_REVIEW_ROOT_ENV, "").strip()
|
|
review_root = (
|
|
Path(configured).expanduser() if configured else runs_root.parent / "polygon-review-packs"
|
|
)
|
|
if not review_root.is_absolute():
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Источник визуальных прогонов настроен некорректно.",
|
|
)
|
|
run_review_root = review_root / run_id
|
|
try:
|
|
resolved_review_root = review_root.resolve()
|
|
resolved_run_root = run_review_root.resolve(strict=True)
|
|
resolved_run_root.relative_to(resolved_review_root)
|
|
except (OSError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Покадровый просмотр для прогона ещё не опубликован.",
|
|
) from exc
|
|
candidates = sorted(
|
|
path
|
|
for path in resolved_run_root.iterdir()
|
|
if path.is_dir()
|
|
and not path.is_symlink()
|
|
and re.fullmatch(r"review-[a-f0-9]{64}", path.name)
|
|
and (path / "manifest.json").is_file()
|
|
)
|
|
if not candidates:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Покадровый просмотр для прогона ещё не опубликован.",
|
|
)
|
|
pack_root = candidates[-1]
|
|
manifest_path = pack_root / "manifest.json"
|
|
try:
|
|
if manifest_path.is_symlink() or manifest_path.stat().st_size > MAX_REVIEW_MANIFEST_BYTES:
|
|
raise OSError
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Манифест визуального прогона повреждён.",
|
|
) from exc
|
|
frames = manifest.get("frames") if isinstance(manifest, dict) else None
|
|
safety = manifest.get("safety") if isinstance(manifest, dict) else None
|
|
if (
|
|
not isinstance(manifest, dict)
|
|
or manifest.get("schema_version")
|
|
not in {GOOSE_REVIEW_PACK_SCHEMA, RELLIS_REVIEW_PACK_SCHEMA}
|
|
or manifest.get("source_run_id") != run_id
|
|
or not isinstance(manifest.get("identity_sha256"), str)
|
|
or not re.fullmatch(r"[a-f0-9]{64}", manifest["identity_sha256"])
|
|
or pack_root.name != f"review-{manifest['identity_sha256']}"
|
|
or not isinstance(manifest.get("source_id"), str)
|
|
or not isinstance(manifest.get("preview_points"), int)
|
|
or not isinstance(frames, list)
|
|
or not 1 <= len(frames) <= MAX_REVIEW_FRAMES
|
|
or manifest.get("frame_count") != len(frames)
|
|
or not isinstance(safety, dict)
|
|
or safety.get("visualization_only") is not True
|
|
or safety.get("navigation_or_safety_accepted") is not False
|
|
):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Манифест визуального прогона нарушает контракт.",
|
|
)
|
|
seen: set[str] = set()
|
|
for expected_sequence, frame in enumerate(frames):
|
|
if (
|
|
not isinstance(frame, dict)
|
|
or frame.get("sequence") != expected_sequence
|
|
or not isinstance(frame.get("frame_id"), str)
|
|
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", frame["frame_id"])
|
|
or frame["frame_id"] in seen
|
|
or not isinstance(frame.get("source_point_count"), int)
|
|
or not isinstance(frame.get("point_count"), int)
|
|
or not 1 <= frame["point_count"] <= MAX_REVIEW_POINTS
|
|
or frame["source_point_count"] < frame["point_count"]
|
|
or not isinstance(frame.get("relative_path"), str)
|
|
or not isinstance(frame.get("sha256"), str)
|
|
or not re.fullmatch(r"[a-f0-9]{64}", frame["sha256"])
|
|
or not isinstance(frame.get("byte_length"), int)
|
|
or not 1 <= frame["byte_length"] <= MAX_REVIEW_FRAME_BYTES
|
|
or not _valid_review_metrics(frame.get("current"))
|
|
or not _valid_review_metrics(frame.get("patchworkpp"))
|
|
or not isinstance(frame.get("ground_iou_delta"), (int, float))
|
|
or not math.isfinite(frame["ground_iou_delta"])
|
|
or not -1 <= frame["ground_iou_delta"] <= 1
|
|
):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Индекс кадров визуального прогона нарушает контракт.",
|
|
)
|
|
seen.add(frame["frame_id"])
|
|
return pack_root, manifest
|
|
|
|
|
|
def _dataset_frame_identity(frame: dict[str, Any]) -> dict[str, str | int | None]:
|
|
frame_id = frame["frame_id"]
|
|
explicit_sequence = frame.get("dataset_sequence_id")
|
|
explicit_number = frame.get("dataset_frame_number")
|
|
if explicit_sequence is not None or explicit_number is not None:
|
|
if (
|
|
not isinstance(explicit_sequence, str)
|
|
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,95}", explicit_sequence)
|
|
is None
|
|
or not isinstance(explicit_number, int)
|
|
or explicit_number < 0
|
|
):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Идентичность dataset-кадра нарушает контракт.",
|
|
)
|
|
return {
|
|
"dataset_sequence_id": explicit_sequence,
|
|
"dataset_frame_number": explicit_number,
|
|
"sensor_timestamp_ns": None,
|
|
}
|
|
match = GOOSE_FRAME_ID_PATTERN.fullmatch(frame_id)
|
|
if match is None:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Идентичность GOOSE-кадра нарушает контракт.",
|
|
)
|
|
return {
|
|
"dataset_sequence_id": match.group("dataset_sequence_id"),
|
|
"dataset_frame_number": int(match.group("dataset_frame_number")),
|
|
# Nanosecond Unix time exceeds JavaScript's safe integer range.
|
|
"sensor_timestamp_ns": match.group("sensor_timestamp_ns"),
|
|
}
|
|
|
|
|
|
def _valid_review_metrics(value: object) -> bool:
|
|
if not isinstance(value, dict):
|
|
return False
|
|
for key in (
|
|
"ground_iou",
|
|
"natural_ground_recall",
|
|
"obstacle_non_ground_recall",
|
|
):
|
|
metric = value.get(key)
|
|
if (
|
|
not isinstance(metric, (int, float))
|
|
or not math.isfinite(metric)
|
|
or not 0 <= metric <= 1
|
|
):
|
|
return False
|
|
latency = value.get("latency_ms")
|
|
return isinstance(latency, (int, float)) and math.isfinite(latency) and latency >= 0
|
|
|
|
|
|
def _verified_review_frame_path(pack_root: Path, frame: dict[str, Any]) -> Path:
|
|
relative = Path(frame["relative_path"])
|
|
if relative.is_absolute() or relative.parts[:1] != ("frames",):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Кадр визуального прогона нарушает границу.",
|
|
)
|
|
path = pack_root / relative
|
|
try:
|
|
resolved = path.resolve(strict=True)
|
|
resolved.relative_to(pack_root)
|
|
if (
|
|
path.is_symlink()
|
|
or not resolved.is_file()
|
|
or resolved.stat().st_size != frame["byte_length"]
|
|
):
|
|
raise OSError
|
|
except (OSError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Файл визуального прогона нарушает границу.",
|
|
) from exc
|
|
if hashlib.sha256(resolved.read_bytes()).hexdigest() != frame["sha256"]:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Кадр визуального прогона не прошёл SHA-256.",
|
|
)
|
|
return resolved
|
|
|
|
|
|
def _run_summary(run: QualificationRun) -> dict[str, Any]:
|
|
return {
|
|
"run_id": run.run_id,
|
|
"episode_id": run.episode_id,
|
|
"kind": run.kind.value,
|
|
"state": run.state.value,
|
|
"created_at_utc": run.created_at_utc,
|
|
"started_at_utc": run.started_at_utc,
|
|
"ended_at_utc": run.ended_at_utc,
|
|
"terminal_reason": run.terminal_reason,
|
|
"scenario_generation": run.scenario_generation,
|
|
"profile_generation": run.profile_generation,
|
|
"mission_core_commit": run.mission_core_commit,
|
|
"host_profile_id": run.host_profile_id,
|
|
"reproducibility_tier": run.reproducibility_tier.value,
|
|
"clock_domain": run.clock_domain,
|
|
"revision": run.revision,
|
|
"provider_ids": [provider.identifier for provider in run.providers],
|
|
"artifact_count": len(run.artifacts),
|
|
}
|
|
|
|
|
|
def _required_worker(provider: WorkerProvider) -> PolygonWorkerGateway:
|
|
gateway = provider()
|
|
if gateway is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Simulation Worker не зарегистрирован на этом экземпляре Mission Core.",
|
|
)
|
|
return gateway
|
|
|
|
|
|
def _control_context(
|
|
worker_provider: WorkerProvider,
|
|
control_provider: ControlProvider,
|
|
commit_provider: CommitProvider,
|
|
) -> tuple[PolygonWorkerGateway, str]:
|
|
if not control_provider():
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Lifecycle Полигона отключён на этом экземпляре Mission Core.",
|
|
)
|
|
gateway = _required_worker(worker_provider)
|
|
commit = commit_provider()
|
|
if commit is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Mission Core не привязан к точной Git-ревизии.",
|
|
)
|
|
return gateway, commit
|
|
|
|
|
|
def _unavailable_worker_status() -> dict[str, Any]:
|
|
return {
|
|
"schema_version": STATUS_SCHEMA,
|
|
"worker_id": "mission-gpu-s1",
|
|
"transport": "unix",
|
|
"mode": "simulation",
|
|
"available": False,
|
|
"control_available": False,
|
|
"active_run_id": None,
|
|
"run_state": None,
|
|
"active_provider_ids": [],
|
|
"provider_profile": None,
|
|
"isolation": {
|
|
"network": "unavailable",
|
|
"process_identity": "missioncore",
|
|
"artifact_policy": "d-only",
|
|
},
|
|
"authority": {
|
|
"scope": "virtual-only",
|
|
"actuator_authority": False,
|
|
"direct_actuator_setpoints_allowed": False,
|
|
},
|
|
}
|
|
|
|
|
|
def _new_run_id(commit: str) -> str:
|
|
stamp = datetime.now(UTC).strftime("%Y%m%dt%H%M%Sz").lower()
|
|
return f"s1c-{commit[:7]}-{stamp}-{secrets.token_hex(3)}"
|