fix(ai): bind model scope to immutable geometry
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-08-09 10:54:52 +02:00
parent f41392a415
commit b76cd1837b
19 changed files with 431 additions and 58 deletions
+5
View File
@@ -118,6 +118,11 @@ YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
YOLO_MODEL_VERSION=
YOLO_MODEL_CLASSES=building
YOLO_ENFORCE_VALIDATION_SCOPE=false
# Required when scope enforcement is enabled. The manifest is bound to exact
# model bytes and contains the allowed EPSG:4326 validation geometry.
YOLO_VALIDATION_SCOPE_MANIFEST_PATH=
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
# Deprecated display metadata; never used as an inference authorization gate.
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
YOLO_CONFIG_DIR=./storage/ultralytics
YOLO_DEVICE=cpu
+11
View File
@@ -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")
+20 -5
View File
@@ -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
+57 -4
View File
@@ -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:
+1
View File
@@ -113,6 +113,7 @@ COPY scripts/provision_buildings_addresses_register.py /app/scripts/provision_bu
COPY scripts/provision_regional_timeseries.py /app/scripts/provision_regional_timeseries.py
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
COPY scripts/build_model_validation_scope_manifest.py /app/scripts/build_model_validation_scope_manifest.py
COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/provision_belgium_north_sea_scope.py
COPY scripts/provision_release_golden_areas.py /app/scripts/provision_release_golden_areas.py
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
+4 -2
View File
@@ -137,8 +137,10 @@
<Config Name="YOLO Device" Target="YOLO_DEVICE" Default="cuda:0" Mode="" Description="Required NVIDIA CUDA inference device." Type="Variable" Display="advanced" Required="true" Mask="false">cuda:0</Config>
<Config Name="Require CUDA" Target="YOLO_REQUIRE_CUDA" Default="true" Mode="" Description="Fail closed instead of silently falling back to CPU when NVIDIA CUDA is unavailable." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="YOLO Classes" Target="YOLO_MODEL_CLASSES" Default="building" Mode="" Description="Comma-separated classes proven for the active model; the current promoted model is building-only." Type="Variable" Display="advanced" Required="true" Mask="false">building</Config>
<Config Name="Enforce YOLO Scope" Target="YOLO_ENFORCE_VALIDATION_SCOPE" Default="true" Mode="" Description="Reject inference outside persisted validated Areas." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Validated YOLO Areas" Target="YOLO_VALIDATED_AREA_NAMES" Default="Mol,Kempen" Mode="" Description="Persisted Area name tokens with promotion evidence for the active model." Type="Variable" Display="advanced" Required="true" Mask="false">Mol,Kempen</Config>
<Config Name="Enforce YOLO Scope" Target="YOLO_ENFORCE_VALIDATION_SCOPE" Default="true" Mode="" Description="Reject inference outside the checksum-bound model validation geometry." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="YOLO Scope Manifest" Target="YOLO_VALIDATION_SCOPE_MANIFEST_PATH" Default="/app/storage/operator-data/model-validation-scopes/active-building-model.json" Mode="" Description="Immutable model-bound EPSG:4326 validation-scope manifest." Type="Variable" Display="advanced" Required="true" Mask="false">/app/storage/operator-data/model-validation-scopes/active-building-model.json</Config>
<Config Name="YOLO Scope Manifest SHA256" Target="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256" Default="" Mode="" Description="Exact lowercase SHA-256 of the validation-scope manifest." Type="Variable" Display="advanced" Required="true" Mask="false"></Config>
<Config Name="Validated YOLO Areas (Display Only)" Target="YOLO_VALIDATED_AREA_NAMES" Default="Mol,Kempen" Mode="" Description="Deprecated display metadata; never authorizes inference." Type="Variable" Display="advanced" Required="false" Mask="false">Mol,Kempen</Config>
<Config Name="YOLO Image Size" Target="YOLO_IMAGE_SIZE" Default="640" Mode="" Description="Inference image size in pixels." Type="Variable" Display="advanced" Required="true" Mask="false">640</Config>
<Config Name="YOLO Maximum Tiles" Target="YOLO_MAX_TILES" Default="100" Mode="" Description="Hard tile limit per detection run." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>
<Config Name="YOLO Maximum Detections" Target="YOLO_MAX_DETECTIONS" Default="1000" Mode="" Description="Hard persisted detection limit per run." Type="Variable" Display="advanced" Required="true" Mask="false">1000</Config>
+3
View File
@@ -166,6 +166,9 @@ YOLO_DEVICE=cuda:0
YOLO_REQUIRE_CUDA=true
YOLO_MODEL_CLASSES=building
YOLO_ENFORCE_VALIDATION_SCOPE=true
YOLO_VALIDATION_SCOPE_MANIFEST_PATH=/app/storage/operator-data/model-validation-scopes/active-building-model.json
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
# Deprecated display metadata; never used as an inference authorization gate.
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
YOLO_IMAGE_SIZE=640
YOLO_MAX_TILES=100
+4
View File
@@ -136,6 +136,8 @@ YOLO_MODEL_DISPLAY_NAME="${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector}"
YOLO_MODEL_VERSION="${YOLO_MODEL_VERSION:-}"
YOLO_MODEL_CLASSES="${YOLO_MODEL_CLASSES:-building}"
YOLO_ENFORCE_VALIDATION_SCOPE="${YOLO_ENFORCE_VALIDATION_SCOPE:-true}"
YOLO_VALIDATION_SCOPE_MANIFEST_PATH="${YOLO_VALIDATION_SCOPE_MANIFEST_PATH:-}"
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256="${YOLO_VALIDATION_SCOPE_MANIFEST_SHA256:-}"
YOLO_VALIDATED_AREA_NAMES="${YOLO_VALIDATED_AREA_NAMES:-Mol,Kempen}"
YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-/app/storage/ultralytics}"
YOLO_DEVICE="${YOLO_DEVICE:-cuda:0}"
@@ -413,6 +415,8 @@ docker run -d \
-e YOLO_MODEL_VERSION="$YOLO_MODEL_VERSION" \
-e YOLO_MODEL_CLASSES="$YOLO_MODEL_CLASSES" \
-e YOLO_ENFORCE_VALIDATION_SCOPE="$YOLO_ENFORCE_VALIDATION_SCOPE" \
-e YOLO_VALIDATION_SCOPE_MANIFEST_PATH="$YOLO_VALIDATION_SCOPE_MANIFEST_PATH" \
-e YOLO_VALIDATION_SCOPE_MANIFEST_SHA256="$YOLO_VALIDATION_SCOPE_MANIFEST_SHA256" \
-e YOLO_VALIDATED_AREA_NAMES="$YOLO_VALIDATED_AREA_NAMES" \
-e YOLO_CONFIG_DIR="$YOLO_CONFIG_DIR" \
-e YOLO_DEVICE="$YOLO_DEVICE" \
+26 -2
View File
@@ -121,8 +121,13 @@ Environment variables:
- `YOLO_REQUIRE_CUDA` (set to `true` on the production server; inference then
fails closed when CUDA is unavailable or `YOLO_DEVICE` selects CPU)
- `YOLO_MODEL_CLASSES` (the active promoted detector is `building` only)
- `YOLO_ENFORCE_VALIDATION_SCOPE` and `YOLO_VALIDATED_AREA_NAMES` (production
rejects inference when the raster is not bound to a persisted validated Area)
- `YOLO_ENFORCE_VALIDATION_SCOPE` (keep `true` in production)
- `YOLO_VALIDATION_SCOPE_MANIFEST_PATH` and
`YOLO_VALIDATION_SCOPE_MANIFEST_SHA256` (production accepts inference only
when the exact active model bytes match the manifest and the complete
persisted Dataset AOI is covered by its valid EPSG:4326 geometry)
- `YOLO_VALIDATED_AREA_NAMES` is deprecated display metadata and never grants
inference access
- `YOLO_IMAGE_SIZE`
- `YOLO_MAX_TILES`
- `YOLO_MAX_DETECTIONS`
@@ -136,6 +141,25 @@ the upstream default would cap recall before QA/QC begins. Operators may lower
the value for small rasters or raise it for dense urban tiles after reviewing
runtime and false-positive behavior.
Create a new immutable scope artifact whenever either the model bytes or the
governed validation boundary changes:
```bash
python /app/scripts/build_model_validation_scope_manifest.py \
--model /app/models/active-building.pt \
--model-id yolo-configured \
--scope-geojson /app/storage/operator-data/geographic-scopes/kempen-transport-region/kempen_transport_region_boundary_YYYY-MM-DD.geojson \
--scope-key kempen-transport-region \
--authority "Digitaal Vlaanderen VRBG/Refgem" \
--snapshot-date YYYY-MM-DD \
--output /app/storage/operator-data/model-validation-scopes/active-building-model.json
```
The command refuses to overwrite an existing manifest and prints the checksum
for `YOLO_VALIDATION_SCOPE_MANIFEST_SHA256`. Area names are intentionally not
part of this decision: they are mutable presentation text, not accuracy or
authorization evidence.
After YOLO boxes are georeferenced, configured-YOLO runs apply a GeoIntel
cross-tile duplicate suppression pass before persistence. Candidates are grouped
by canonical class and sorted by confidence; lower-confidence same-class
+28
View File
@@ -12444,3 +12444,31 @@ Open:
`not_evaluable` without governed evidence. Phase 4 remains **in progress**,
Phase 5 remains **not ready**, and promotion/training feedback from protected
data is not authorized.
## 2026-08-09 - Model accuracy boundary and truthful evidence labels
### Changed
- Replaced the configured-YOLO Area-name substring gate with a fail-closed,
checksum-bound validation-scope manifest. The exact model SHA-256 must match
and the immutable EPSG:4326 scope geometry must cover the complete persisted
Dataset AOI. Renaming an Area can no longer widen model applicability.
- Added an operator tool that builds the model-bound manifest from exact model
bytes and governed scope GeoJSON without overwriting prior evidence.
- Corrected the detection UI: legacy Mol/Kempen profile scores are now labelled
historical calibration context, spatial independence is explicitly unproven,
and no profile is marked approved or promotion-ready.
### Verified
- Targeted backend/UI contract selection: 34 passed.
- `git diff --check`: passed before the documentation update; final check is
part of the handoff verification.
### Remaining limitations
- The current active model is still not nationally validated. A new governed
corpus, independent spatial split, representative human review and immutable
product benchmark remain required before any production-accuracy claim.
- Every deployed model asset needs its own generated scope manifest and exact
configured manifest checksum before enforced inference is available.
+4 -2
View File
@@ -1115,8 +1115,10 @@ This file now starts with the current implementation status. Older preparation/b
112 Ruff findings and add real frontend lint.
- [ ] P2-02: fix CRS ingest, metre buffering and Area geometry/CRS updates;
auditably quarantine or repair the four legacy Geel detections.
- [ ] P2-03: isolate coverage by source/theme/layer/zone, make official source
identity server-attested and replace mutable-name legal/model scope checks.
- [ ] P2-03: isolate coverage by source/theme/layer/zone and make official
source identity server-attested. The mutable-name YOLO scope bypass is fixed
with a model/checksum-bound geometry manifest; equivalent legal-scope checks
still require the same review.
- [ ] P2-04: make derived persistence transactional, require complete
RunManifest hashes and expose every fallback/persistence failure.
- [ ] P2-05: remove every protected-test feedback path, introduce a test vault
@@ -21,11 +21,8 @@ const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
function detectionQualityInterpretation(f1: number | null | undefined): string {
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen gevalideerde kwaliteitsmeting.'
if (f1 >= 0.85) return 'Sterk resultaat; steekproefcontrole blijft vereist.'
if (f1 >= 0.70) return 'Bruikbaar met gerichte handmatige controle.'
if (f1 >= 0.50) return 'Verkennend resultaat; beoordeel fouten voor operationeel gebruik.'
return 'Onvoldoende betrouwbaar voor operationeel gebruik.'
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen onafhankelijke kwaliteitsmeting beschikbaar.'
return `Historische F1 ${f1.toFixed(3)} is alleen kalibratiecontext. Een actuele, ruimtelijk onafhankelijke QA-run bepaalt of dit resultaat lokaal bruikbaar is.`
}
function detectionStatusLabel(status: string): string {
@@ -311,8 +308,8 @@ export function DetectionLab({
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
</div>
<div className="ai-user-summary-card">
<span>Validatiescope</span>
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
<span>Bewijsstatus</span>
<strong>{selectedOperatorProfile ? `Historische F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
<p>{selectedOperatorProfile ? selectedOperatorProfile.validationScope : 'Kies een modelprofiel met gedocumenteerd evaluatiebewijs.'}</p>
</div>
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
@@ -141,7 +141,7 @@ export function DetectionModelManagement({
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="operator-profile-grid" aria-label="Gevalideerde YOLO-profielen">
<div className="operator-profile-grid" aria-label="Historische YOLO-controleprofielen">
{DETECTION_OPERATOR_PROFILES.map((profile) => {
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
const profileSelected =
@@ -154,18 +154,18 @@ export function DetectionModelManagement({
>
<div className="operator-profile-card-header">
<strong>{profile.displayName}</strong>
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat, extra controle vereist'}
<span className="status-badge">
{profile.independentTestProven ? 'onafhankelijk getoetst' : 'historisch, geen releasebewijs'}
</span>
</div>
<p>{profile.description}</p>
<div className="operator-profile-metrics">
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
<span>precisie {profile.precision.toFixed(3)}</span>
<span>herkenningsgraad {profile.recall.toFixed(3)}</span>
<span>F1 {profile.f1.toFixed(3)}</span>
<span>testgebieden {profile.positiveSampleCount}</span>
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
<span>historische precisie {profile.precision.toFixed(3)}</span>
<span>historische herkenningsgraad {profile.recall.toFixed(3)}</span>
<span>historische F1 {profile.f1.toFixed(3)}</span>
<span>positieve controles {profile.positiveSampleCount}</span>
<span>gemeten achtergrondfouten {profile.maxBackgroundDetections}</span>
</div>
<p className="field-guidance">{profile.limitationMessage}</p>
<button
@@ -3,8 +3,7 @@ export interface DetectionOperatorProfile {
displayName: string
modelAssetId: string
confidenceThreshold: number
defaultApproved: boolean
promotionRecommendation: 'none' | 'promote_candidate'
independentTestProven: boolean
precision: number
recall: number
f1: number
@@ -21,51 +20,48 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
displayName: 'Aanbevolen controleprofiel kleine gebouwen',
modelAssetId: 'geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt',
confidenceThreshold: 0.15,
defaultApproved: true,
promotionRecommendation: 'promote_candidate',
independentTestProven: false,
precision: 0.6140895327792112,
recall: 0.6062221049337548,
f1: 0.6068607646002744,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
description:
'Aanbevolen profiel met een evenwicht tussen gevonden en gemiste kleine gebouwen, opnieuw gemeten over zeven onafhankelijke testgebieden in Mol en de Kempen.',
'Historisch controleprofiel met een evenwicht tussen gevonden en gemiste kleine gebouwen. Gebruik dit als startpunt voor lokale QA, niet als vrijgavebewijs.',
limitationMessage:
'De drie lege-achtergrondtests zijn geslaagd. Postel blijft met 47,5% F1 het moeilijkste testgebied; behandel elke detectie als een controlekandidaat en niet als grondwaarheid.',
'Slechts drie pure-achtergrondbeelden en geen onafhankelijke hold-out ondersteunen deze historische meting. Postel bleef het moeilijkste gebied; behandel elke detectie als controlekandidaat en niet als grondwaarheid.',
},
{
id: 'expanded-balanced-review',
displayName: 'Voorgaand gebalanceerd controleprofiel',
modelAssetId: 'geointel-building-yolov8s-aoi1024expandedminpx4vis035e50-pt',
confidenceThreshold: 0.15,
defaultApproved: true,
promotionRecommendation: 'promote_candidate',
independentTestProven: false,
precision: 0.6470590036169351,
recall: 0.4699913836847832,
f1: 0.5432865390636915,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
description: 'Voorgaand profiel voor controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
description: 'Historisch profiel voor lokale controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
limitationMessage:
'De lege-achtergrondtest is geslaagd. Dit profiel vindt minder onterechte objecten, maar mist meer kleine gebouwen dan het aanbevolen profiel.',
'Dit profiel vond historisch minder onterechte objecten, maar miste meer kleine gebouwen. De meting is geen onafhankelijke productbenchmark.',
},
{
id: 'conservative-review',
displayName: 'Conservatief controleprofiel',
modelAssetId: 'geointel-building-yolov8s-aoi1024bg512r3e50-pt',
confidenceThreshold: 0.35,
defaultApproved: true,
promotionRecommendation: 'promote_candidate',
independentTestProven: false,
precision: 0.840006,
recall: 0.202135,
f1: 0.32086574003576274,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
description: 'Profiel met hoge precisie voor controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
description: 'Historisch profiel voor lokale controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
limitationMessage:
'Goedgekeurd na de lege-achtergrondtest. Resultaten in dun bebouwde context blijven altijd controlebewijs en geen automatische waarheid.',
'De historische precisie gaat samen met zeer lage herkenningsgraad. Resultaten blijven controlebewijs en geen automatische waarheid of releasebewijs.',
},
]
@@ -0,0 +1,89 @@
"""Build an immutable geographic validation scope bound to exact model bytes."""
from __future__ import annotations
import argparse
from hashlib import sha256
import json
from pathlib import Path
from typing import Any
from shapely.geometry import mapping, shape
from shapely.ops import unary_union
SCHEMA_VERSION = "geointel.model-validation-scope/v1"
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()
def read_scope_geometry(path: Path):
payload = json.loads(path.read_text(encoding="utf-8"))
payload_type = payload.get("type") if isinstance(payload, dict) else None
if payload_type == "FeatureCollection":
geometries = [shape(feature.get("geometry")) for feature in payload.get("features", [])]
geometry = unary_union([item for item in geometries if not item.is_empty])
elif payload_type == "Feature":
geometry = shape(payload.get("geometry"))
else:
geometry = shape(payload)
if geometry.is_empty or not geometry.is_valid or geometry.geom_type not in {"Polygon", "MultiPolygon"}:
raise ValueError("Scope input must resolve to one non-empty valid Polygon or MultiPolygon")
return geometry
def build_manifest(args: argparse.Namespace) -> dict[str, Any]:
model_path = args.model.expanduser().resolve()
scope_path = args.scope_geojson.expanduser().resolve()
if not model_path.is_file():
raise FileNotFoundError(f"Model file not found: {model_path}")
if not scope_path.is_file():
raise FileNotFoundError(f"Scope GeoJSON not found: {scope_path}")
return {
"schema_version": SCHEMA_VERSION,
"model_id": args.model_id,
"model_sha256": file_sha256(model_path),
"scope_key": args.scope_key,
"crs": "EPSG:4326",
"geometry": mapping(read_scope_geometry(scope_path)),
"source": {
"scope_geojson_path": str(scope_path),
"scope_geojson_sha256": file_sha256(scope_path),
"authority": args.authority,
"snapshot_date": args.snapshot_date,
},
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", type=Path, required=True)
parser.add_argument("--model-id", required=True)
parser.add_argument("--scope-geojson", type=Path, required=True)
parser.add_argument("--scope-key", required=True)
parser.add_argument("--authority", required=True)
parser.add_argument("--snapshot-date", required=True)
parser.add_argument("--output", type=Path, required=True)
return parser.parse_args()
def main() -> int:
args = parse_args()
output = args.output.expanduser().resolve()
if output.exists():
raise FileExistsError(f"Refusing to overwrite immutable scope manifest: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
encoded = (json.dumps(build_manifest(args), ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
output.write_bytes(encoded)
print(json.dumps({"manifest_path": str(output), "manifest_sha256": sha256(encoded).hexdigest()}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())