feat(polygon): qualify GOOSE ground providers

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 16:02:20 +03:00
parent 60ba64004b
commit d6decbe05c
14 changed files with 2718 additions and 22 deletions
+7 -2
View File
@@ -44,7 +44,7 @@ from k1link.web.plugin_runtime import (
PluginNotFoundError,
PluginRuntimeUnavailableError,
)
from k1link.web.polygon_api import build_polygon_router
from k1link.web.polygon_api import build_polygon_router, configured_polygon_runs_root
from k1link.web.session_api import build_session_router
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
@@ -398,7 +398,12 @@ app.include_router(
point_color_renderers=plugin_environment.point_color_renderers,
)
)
app.include_router(build_polygon_router())
app.include_router(
build_polygon_router(
root_provider=lambda: configured_polygon_runs_root()
or REPOSITORY_ROOT / ".runtime" / "polygon-runs"
)
)
app.include_router(
build_lidar_router(
root_provider=lambda: (
+170
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import secrets
@@ -33,6 +35,13 @@ 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"
GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview"
GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1"
GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1"
MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"Архивный UI-0 публикует только квалификационные доказательства; "
@@ -165,6 +174,90 @@ def build_polygon_router(
"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,
expected_schema=GROUND_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("/worker")
def get_polygon_worker() -> dict[str, Any]:
gateway = worker_provider()
@@ -315,6 +408,83 @@ def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
) 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,
expected_schema: str,
frame_id: str | None = None,
) -> dict[str, Any]:
candidates = [artifact for artifact in run.artifacts if artifact.kind == artifact_kind]
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") != expected_schema:
raise HTTPException(
status_code=500,
detail="Квалификационный артефакт имеет неизвестную схему.",
)
return value
def _run_summary(run: QualificationRun) -> dict[str, Any]:
return {
"run_id": run.run_id,