fix(ai): bind model scope to immutable geometry
This commit is contained in:
@@ -359,6 +359,17 @@ class Settings(BaseSettings):
|
||||
yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION")
|
||||
yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES")
|
||||
yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE")
|
||||
yolo_validation_scope_manifest_path: str | None = Field(
|
||||
default=None,
|
||||
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH",
|
||||
)
|
||||
yolo_validation_scope_manifest_sha256: str | None = Field(
|
||||
default=None,
|
||||
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256",
|
||||
)
|
||||
# Deprecated compatibility field. Mutable Area names are never an
|
||||
# inference authorization boundary; deployments must use the immutable
|
||||
# checksum-bound scope manifest above.
|
||||
yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES")
|
||||
yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE")
|
||||
yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA")
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.services.detection_qa_service import DetectionQaService
|
||||
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
|
||||
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
from app.services.model_validation_scope_service import ModelValidationScopeService
|
||||
from app.services.qa_service import QaService
|
||||
from app.services.quality_service import QualityService
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
||||
@@ -235,16 +236,30 @@ class DetectionService:
|
||||
|
||||
@staticmethod
|
||||
def _validate_model_area_scope(db, dataset: Dataset, settings: Settings) -> None:
|
||||
allowed_names = [value.strip().casefold() for value in settings.yolo_validated_area_names.split(",") if value.strip()]
|
||||
area = db.get(Area, dataset.area_id) if dataset.area_id else None
|
||||
area_name = area.name.strip() if area is not None else ""
|
||||
if not area_name or not any(token in area_name.casefold() for token in allowed_names):
|
||||
if area is None or area.geometry is None:
|
||||
raise AppError(
|
||||
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
||||
message="Configured YOLO inference is not validated for this Dataset area.",
|
||||
details={"dataset_id": str(dataset.id), "dataset_area": area_name or None, "validated_area_names": allowed_names},
|
||||
message="Configured YOLO inference requires a persisted Dataset area geometry.",
|
||||
details={"dataset_id": str(dataset.id)},
|
||||
status_code=422,
|
||||
)
|
||||
try:
|
||||
area_geometry = to_shape(area.geometry)
|
||||
except Exception as exc:
|
||||
raise AppError(
|
||||
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
||||
message="The persisted Dataset area geometry cannot be validated for model inference.",
|
||||
details={"dataset_id": str(dataset.id), "error_type": type(exc).__name__},
|
||||
status_code=422,
|
||||
) from exc
|
||||
ModelValidationScopeService.assert_area_covered(
|
||||
area_geometry=area_geometry,
|
||||
manifest_path=settings.yolo_validation_scope_manifest_path,
|
||||
expected_manifest_sha256=settings.yolo_validation_scope_manifest_sha256,
|
||||
model_id=settings.yolo_model_id,
|
||||
model_path=settings.yolo_model_path,
|
||||
)
|
||||
@staticmethod
|
||||
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Checksum-bound geographic validation scope for production model inference."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ModelValidationScopeService:
|
||||
"""Load an immutable, model-bound AOI and prove that an input is covered."""
|
||||
|
||||
SCHEMA_VERSION = "geointel.model-validation-scope/v1"
|
||||
|
||||
@classmethod
|
||||
def assert_area_covered(
|
||||
cls,
|
||||
*,
|
||||
area_geometry: BaseGeometry,
|
||||
manifest_path: str | None,
|
||||
expected_manifest_sha256: str | None,
|
||||
model_id: str,
|
||||
model_path: str | None,
|
||||
) -> dict[str, str]:
|
||||
path = Path(manifest_path).expanduser() if manifest_path else None
|
||||
expected_checksum = (expected_manifest_sha256 or "").strip().lower()
|
||||
if path is None or not expected_checksum:
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_NOT_CONFIGURED",
|
||||
"Configured YOLO inference requires a checksum-bound geographic validation-scope manifest.",
|
||||
)
|
||||
if not _SHA256.fullmatch(expected_checksum):
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_INVALID",
|
||||
"The configured validation-scope checksum must be a lowercase SHA-256 digest.",
|
||||
manifest_path=str(path),
|
||||
)
|
||||
try:
|
||||
raw_manifest = path.read_bytes()
|
||||
payload = json.loads(raw_manifest.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_INVALID",
|
||||
"The configured validation-scope manifest is missing or unreadable.",
|
||||
manifest_path=str(path),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
observed_manifest_sha256 = sha256(raw_manifest).hexdigest()
|
||||
if observed_manifest_sha256 != expected_checksum:
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_CHECKSUM_MISMATCH",
|
||||
"The validation-scope manifest does not match its configured checksum.",
|
||||
manifest_path=str(path),
|
||||
expected=expected_checksum,
|
||||
observed=observed_manifest_sha256,
|
||||
)
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != cls.SCHEMA_VERSION:
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_INVALID",
|
||||
"The validation-scope manifest has an unsupported schema.",
|
||||
manifest_path=str(path),
|
||||
)
|
||||
if payload.get("model_id") != model_id:
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_MODEL_MISMATCH",
|
||||
"The validation scope is not bound to the selected model identity.",
|
||||
expected_model_id=model_id,
|
||||
observed_model_id=payload.get("model_id"),
|
||||
)
|
||||
configured_model_path = Path(model_path).expanduser() if model_path else None
|
||||
if configured_model_path is None or not configured_model_path.is_file():
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_MODEL_UNAVAILABLE",
|
||||
"The model bytes bound by the validation scope are unavailable.",
|
||||
)
|
||||
declared_model_sha256 = str(payload.get("model_sha256") or "").strip().lower()
|
||||
observed_model_sha256 = cls._file_sha256(configured_model_path)
|
||||
if not _SHA256.fullmatch(declared_model_sha256) or declared_model_sha256 != observed_model_sha256:
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_MODEL_MISMATCH",
|
||||
"The validation scope is not bound to the exact selected model bytes.",
|
||||
expected=declared_model_sha256 or None,
|
||||
observed=observed_model_sha256,
|
||||
)
|
||||
if payload.get("crs") != "EPSG:4326":
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_INVALID",
|
||||
"The validation-scope geometry must explicitly use EPSG:4326.",
|
||||
observed_crs=payload.get("crs"),
|
||||
)
|
||||
try:
|
||||
scope_geometry = shape(payload.get("geometry"))
|
||||
except Exception as exc:
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_INVALID",
|
||||
"The validation-scope geometry is not valid GeoJSON.",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
if (
|
||||
scope_geometry.is_empty
|
||||
or not scope_geometry.is_valid
|
||||
or scope_geometry.geom_type not in {"Polygon", "MultiPolygon"}
|
||||
):
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_INVALID",
|
||||
"The validation scope must be a non-empty valid Polygon or MultiPolygon.",
|
||||
geometry_type=scope_geometry.geom_type,
|
||||
)
|
||||
if area_geometry.is_empty or not area_geometry.is_valid or not scope_geometry.covers(area_geometry):
|
||||
cls._raise(
|
||||
"DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
||||
"Configured YOLO inference is not validated for the complete Dataset area.",
|
||||
scope_key=payload.get("scope_key"),
|
||||
)
|
||||
return {
|
||||
"scope_key": str(payload.get("scope_key") or "unspecified"),
|
||||
"manifest_path": str(path.resolve()),
|
||||
"manifest_sha256": observed_manifest_sha256,
|
||||
"model_sha256": observed_model_sha256,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _raise(code: str, message: str, **details: Any) -> None:
|
||||
raise AppError(code=code, message=message, details=details, status_code=422)
|
||||
@@ -25,9 +25,9 @@ def test_detection_lab_explains_explicit_model_asset_and_threshold_selection() -
|
||||
|
||||
assert "Lokaal modelbestand" in lab
|
||||
assert "GeoIntel kiest automatisch het actieve lokale model" in lab
|
||||
assert "Gevalideerde YOLO-profielen" in lab
|
||||
assert "Historische YOLO-controleprofielen" in lab
|
||||
assert "DETECTION_OPERATOR_PROFILES" in lab
|
||||
assert "kandidaat, extra controle vereist" in lab
|
||||
assert "historisch, geen releasebewijs" in lab
|
||||
assert "will_download_models" in lab
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promoted_profile() -> None:
|
||||
def test_detection_operator_profiles_define_explicit_historical_yolo_controls_without_promotion_claim() -> None:
|
||||
profiles = ROOT / "frontend" / "src" / "components" / "detection" / "detectionProfiles.ts"
|
||||
source = profiles.read_text(encoding="utf-8")
|
||||
|
||||
@@ -17,16 +17,17 @@ def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promote
|
||||
assert "conservative-review" in source
|
||||
assert "confidenceThreshold: 0.15" in source
|
||||
assert "confidenceThreshold: 0.35" in source
|
||||
assert "defaultApproved: true" in source
|
||||
assert "promotionRecommendation: 'promote_candidate'" in source
|
||||
assert "defaultApproved" not in source
|
||||
assert "promotionRecommendation" not in source
|
||||
assert "independentTestProven: false" in source
|
||||
assert "positiveSampleCount: 7" in source
|
||||
assert "precision: 0.6140895327792112" in source
|
||||
assert "recall: 0.6062221049337548" in source
|
||||
assert "f1: 0.6068607646002744" in source
|
||||
assert "f1: 0.5432865390636915" in source
|
||||
assert "maxBackgroundDetections: 0" in source
|
||||
assert "lege-achtergrondtest is geslaagd" in source
|
||||
assert "Postel blijft met 47,5% F1" in source
|
||||
assert "Slechts drie pure-achtergrondbeelden" in source
|
||||
assert "ruimtelijke onafhankelijkheid niet bewezen" in source
|
||||
assert "controlekandidaat en niet als grondwaarheid" in source
|
||||
|
||||
|
||||
@@ -39,11 +40,11 @@ def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> Non
|
||||
)
|
||||
|
||||
assert "DETECTION_OPERATOR_PROFILES" in lab
|
||||
assert "Gevalideerde YOLO-profielen" in lab
|
||||
assert "Historische YOLO-controleprofielen" in lab
|
||||
assert "profile.displayName" in lab
|
||||
assert "profile.confidenceThreshold" in lab
|
||||
assert "kandidaat, extra controle vereist" in lab
|
||||
assert "standaardprofiel" in lab
|
||||
assert "historisch, geen releasebewijs" in lab
|
||||
assert "historische F1" in lab
|
||||
assert "Profiel gebruiken" in lab
|
||||
assert "onApplyOperatorProfile(profile)" in lab
|
||||
assert "Recommended starting threshold: 0.25" not in lab
|
||||
|
||||
@@ -72,7 +72,7 @@ def test_visible_ai_and_quality_labels_are_end_user_facing() -> None:
|
||||
providers = read("frontend/src/components/providers/ProviderPanel.tsx")
|
||||
|
||||
assert "Aanbevolen controleprofiel kleine gebouwen" in profiles
|
||||
assert "Postel blijft met 47,5% F1" in profiles
|
||||
assert "Slechts drie pure-achtergrondbeelden" in profiles
|
||||
assert "controlekandidaat en niet als grondwaarheid" in profiles
|
||||
assert "qualityStatusLabel" in quality
|
||||
assert "nog niet uitgevoerd" in quality
|
||||
|
||||
@@ -8,6 +8,8 @@ from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from geoalchemy2.shape import from_shape
|
||||
from shapely.geometry import box, mapping
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import AppError
|
||||
@@ -15,6 +17,7 @@ from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, Sour
|
||||
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
from app.services.model_validation_scope_service import ModelValidationScopeService
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
||||
from app.services.yolo_adapter import YoloDetectionAdapter
|
||||
|
||||
@@ -207,6 +210,28 @@ def _settings(tmp_path: Path, **overrides) -> Settings:
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def _scope_settings(tmp_path: Path, scope_geometry=None, **overrides) -> Settings:
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"scope-bound-model")
|
||||
payload = {
|
||||
"schema_version": ModelValidationScopeService.SCHEMA_VERSION,
|
||||
"model_id": "yolo-configured",
|
||||
"model_sha256": sha256(model_path.read_bytes()).hexdigest(),
|
||||
"scope_key": "mol-kempen-test",
|
||||
"crs": "EPSG:4326",
|
||||
"geometry": mapping(scope_geometry or box(4.0, 50.8, 5.5, 52.0)),
|
||||
}
|
||||
manifest_path = tmp_path / "model-validation-scope.json"
|
||||
manifest_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
||||
values = {
|
||||
"yolo_model_path": str(model_path),
|
||||
"yolo_validation_scope_manifest_path": str(manifest_path),
|
||||
"yolo_validation_scope_manifest_sha256": sha256(manifest_path.read_bytes()).hexdigest(),
|
||||
}
|
||||
values.update(overrides)
|
||||
return _settings(tmp_path, **values)
|
||||
|
||||
|
||||
def _write_model_sidecar(
|
||||
model_path: Path,
|
||||
settings: Settings,
|
||||
@@ -399,21 +424,49 @@ def test_yolo_runtime_rejects_cpu_device_when_cuda_is_required(tmp_path: Path, m
|
||||
|
||||
def test_yolo_validation_scope_requires_persisted_validated_area(tmp_path: Path) -> None:
|
||||
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
||||
wrong_area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Brussels", geometry="MULTIPOLYGON EMPTY")
|
||||
wrong_area = Area(
|
||||
id=dataset.area_id,
|
||||
project_id=dataset.project_id,
|
||||
name="Mol validation bypass",
|
||||
geometry=from_shape(box(-74.1, 40.6, -73.8, 40.9), srid=4326),
|
||||
)
|
||||
db = FakeSession(objects={(Area, dataset.area_id): wrong_area})
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
|
||||
DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
|
||||
|
||||
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_yolo_validation_scope_accepts_bound_mol_area(tmp_path: Path) -> None:
|
||||
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
||||
area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Gemeente Mol", geometry="MULTIPOLYGON EMPTY")
|
||||
area = Area(
|
||||
id=dataset.area_id,
|
||||
project_id=dataset.project_id,
|
||||
name="Een wijzigbare weergavenaam",
|
||||
geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
|
||||
)
|
||||
db = FakeSession(objects={(Area, dataset.area_id): area})
|
||||
|
||||
DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
|
||||
DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
|
||||
|
||||
|
||||
def test_yolo_validation_scope_rejects_tampered_manifest(tmp_path: Path) -> None:
|
||||
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
|
||||
area = Area(
|
||||
id=dataset.area_id,
|
||||
project_id=dataset.project_id,
|
||||
name="Gemeente Mol",
|
||||
geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
|
||||
)
|
||||
settings = _scope_settings(tmp_path)
|
||||
Path(settings.yolo_validation_scope_manifest_path).write_text("{}", encoding="utf-8")
|
||||
db = FakeSession(objects={(Area, dataset.area_id): area})
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
DetectionService._validate_model_area_scope(db, dataset, settings)
|
||||
|
||||
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_CHECKSUM_MISMATCH"
|
||||
|
||||
|
||||
def test_yolo_run_requires_tile_manifest_path(tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user