Complete temporal detection safety gate
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import Dataset
|
||||
from app.services.temporal_compatibility_service import TemporalCompatibilityService
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def dataset(
|
||||
*,
|
||||
dataset_type: str,
|
||||
source_name: str,
|
||||
observed_at: datetime | None = None,
|
||||
valid_from: datetime | None = None,
|
||||
valid_to: datetime | None = None,
|
||||
temporal_granularity: str | None = None,
|
||||
source_metadata: dict | None = None,
|
||||
) -> Dataset:
|
||||
return Dataset(
|
||||
id=uuid4(),
|
||||
project_id=uuid4(),
|
||||
name="temporal-source",
|
||||
dataset_type=dataset_type,
|
||||
source=source_name,
|
||||
source_name=source_name,
|
||||
observed_at=observed_at,
|
||||
valid_from=valid_from,
|
||||
valid_to=valid_to,
|
||||
temporal_granularity=temporal_granularity,
|
||||
source_metadata=source_metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_historical_orthophoto_is_rejected_for_detection() -> None:
|
||||
historical = dataset(
|
||||
dataset_type="raster",
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
observed_at=datetime(2020, 1, 1, tzinfo=UTC),
|
||||
valid_from=datetime(2020, 1, 1, tzinfo=UTC),
|
||||
valid_to=datetime(2020, 12, 31, 23, 59, 59, tzinfo=UTC),
|
||||
temporal_granularity="year",
|
||||
source_metadata={"product_key": "2020", "supports_detection": False},
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
TemporalCompatibilityService.ensure_detection_source_supported(historical)
|
||||
|
||||
assert exc_info.value.code == "DETECTION_SOURCE_TEMPORALLY_UNSUPPORTED"
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
|
||||
def test_historical_detection_qa_rejects_current_reference() -> None:
|
||||
historical = dataset(
|
||||
dataset_type="raster",
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
valid_from=datetime(2020, 1, 1, tzinfo=UTC),
|
||||
valid_to=datetime(2020, 12, 31, 23, 59, 59, tzinfo=UTC),
|
||||
temporal_granularity="year",
|
||||
source_metadata={"product_key": "2020", "supports_detection": False},
|
||||
)
|
||||
current_reference = dataset(
|
||||
dataset_type="vector",
|
||||
source_name="grb",
|
||||
valid_from=datetime(2026, 7, 1, tzinfo=UTC),
|
||||
valid_to=datetime(2026, 7, 31, 23, 59, 59, tzinfo=UTC),
|
||||
temporal_granularity="month",
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
TemporalCompatibilityService.assess_detection_qa(historical, current_reference)
|
||||
|
||||
assert exc_info.value.code == "DETECTION_QA_TEMPORAL_MISMATCH"
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
|
||||
def test_historical_detection_qa_accepts_overlapping_reference_edition() -> None:
|
||||
historical = dataset(
|
||||
dataset_type="raster",
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
valid_from=datetime(2020, 1, 1, tzinfo=UTC),
|
||||
valid_to=datetime(2020, 12, 31, 23, 59, 59, tzinfo=UTC),
|
||||
temporal_granularity="year",
|
||||
source_metadata={"product_key": "2020", "supports_detection": False},
|
||||
)
|
||||
historical_reference = dataset(
|
||||
dataset_type="vector",
|
||||
source_name="manual",
|
||||
valid_from=datetime(2020, 6, 1, tzinfo=UTC),
|
||||
valid_to=datetime(2020, 6, 30, 23, 59, 59, tzinfo=UTC),
|
||||
temporal_granularity="month",
|
||||
)
|
||||
|
||||
result = TemporalCompatibilityService.assess_detection_qa(historical, historical_reference)
|
||||
|
||||
assert result["status"] == "compatible"
|
||||
assert result["candidate_historical"] is True
|
||||
assert result["candidate_interval"]["start"].startswith("2020-01-01")
|
||||
assert result["reference_interval"]["start"].startswith("2020-06-01")
|
||||
|
||||
|
||||
def test_current_source_with_unbounded_current_reference_remains_supported() -> None:
|
||||
current = dataset(
|
||||
dataset_type="raster",
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
observed_at=datetime(2026, 7, 17, tzinfo=UTC),
|
||||
valid_from=datetime(2026, 7, 17, tzinfo=UTC),
|
||||
temporal_granularity="snapshot",
|
||||
source_metadata={"product_key": "most_recent", "supports_detection": True},
|
||||
)
|
||||
current_reference = dataset(
|
||||
dataset_type="vector",
|
||||
source_name="grb",
|
||||
observed_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
temporal_granularity="snapshot",
|
||||
)
|
||||
|
||||
TemporalCompatibilityService.ensure_detection_source_supported(current)
|
||||
result = TemporalCompatibilityService.assess_detection_qa(current, current_reference)
|
||||
|
||||
assert result["status"] == "compatible"
|
||||
assert result["candidate_historical"] is False
|
||||
|
||||
|
||||
def test_detection_frontend_has_no_implicit_first_raster_fallback() -> None:
|
||||
source = (ROOT / "frontend" / "src" / "hooks" / "useDetectionWorkflow.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "rasterDatasets[0]" not in source
|
||||
assert "setSelectedDetectionDatasetId(rasterDatasets" not in source
|
||||
assert "const datasetId = selectedDetectionDatasetId" in source
|
||||
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def test_valid_request_id_is_returned() -> None:
|
||||
response = TestClient(app).get("/health/live", headers={"x-request-id": "rc3-check.123"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-request-id"] == "rc3-check.123"
|
||||
|
||||
|
||||
def test_unsafe_request_id_is_replaced() -> None:
|
||||
response = TestClient(app).get("/health/live", headers={"x-request-id": "unsafe request/id"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-request-id"] != "unsafe request/id"
|
||||
assert " " not in response.headers["x-request-id"]
|
||||
|
||||
|
||||
def test_runtime_report_is_read_only_by_default_and_requires_confirmation() -> None:
|
||||
source = (ROOT / "scripts" / "runtime_state_report.py").read_text(encoding="utf-8")
|
||||
|
||||
assert '"mode": "read_only"' in source
|
||||
assert "if args.reconcile and args.confirm != RECONCILE_CONFIRMATION" in source
|
||||
assert "RuntimeReconciliationService.reconcile(db)" in source
|
||||
@@ -105,6 +105,25 @@ def test_non_raster_dataset_request_is_rejected() -> None:
|
||||
assert getattr(exc_info.value, "code", None) == "INVALID_DATASET_TYPE"
|
||||
|
||||
|
||||
def test_historical_raster_marked_unsupported_is_rejected_before_run_creation() -> None:
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
source = db.get(Dataset, dataset_id)
|
||||
source.source_name = "digitaal_vlaanderen_orthophoto"
|
||||
source.source_metadata = {"product_key": "2020", "supports_detection": False}
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
DetectionService.run_detection(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
model_id="yolo-placeholder",
|
||||
confidence_threshold=0.5,
|
||||
)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "DETECTION_SOURCE_TEMPORALLY_UNSUPPORTED"
|
||||
assert db.added == []
|
||||
|
||||
|
||||
def test_fixture_detector_persists_detections_only_with_explicit_fixture_mode() -> None:
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -11,7 +12,7 @@ from shapely.geometry import Polygon, box
|
||||
from app.core.errors import AppError
|
||||
from app.main import app
|
||||
from app.db.session import get_db
|
||||
from app.models import AnalysisRun, Dataset, Detection, Job, Metric, Project, QualityCheck, VectorFeature
|
||||
from app.models import AnalysisRun, Dataset, Detection, Metric, QualityCheck, VectorFeature
|
||||
from app.services.detection_service import DetectionService
|
||||
|
||||
|
||||
@@ -89,6 +90,17 @@ def _detection(project_id, dataset_id, analysis_run_id, class_name="building", c
|
||||
)
|
||||
|
||||
|
||||
def _source_dataset(project_id, dataset_id):
|
||||
return Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="source.tif",
|
||||
dataset_type="raster",
|
||||
source="manual",
|
||||
source_name="manual",
|
||||
)
|
||||
|
||||
|
||||
def test_detection_geojson_feature_collection_shape() -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
@@ -206,6 +218,7 @@ def test_detection_qa_persists_quality_check_and_metrics() -> None:
|
||||
db = FakeSession(
|
||||
objects={
|
||||
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="detection", status="success", parameters_json={}),
|
||||
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
|
||||
(Dataset, reference_dataset_id): reference_dataset,
|
||||
},
|
||||
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
|
||||
@@ -227,6 +240,8 @@ def test_detection_qa_persists_quality_check_and_metrics() -> None:
|
||||
assert result["quality_check_id"] == str(quality_checks[0].id)
|
||||
assert quality_checks[0].analysis_run_id == analysis_run_id
|
||||
assert quality_checks[0].reference_dataset_id == reference_dataset_id
|
||||
assert quality_checks[0].parameters_json["temporal_compatibility"]["status"] == "compatible"
|
||||
assert quality_checks[0].findings_json["temporal_compatibility"]["status"] == "compatible"
|
||||
assert [metric.metric_key for metric in metrics] == [
|
||||
"precision",
|
||||
"recall",
|
||||
@@ -237,6 +252,53 @@ def test_detection_qa_persists_quality_check_and_metrics() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_detection_qa_rejects_non_overlapping_historical_reference_editions() -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
reference_dataset_id = uuid4()
|
||||
analysis_run_id = uuid4()
|
||||
source_dataset = _source_dataset(project_id, dataset_id)
|
||||
source_dataset.source_name = "digitaal_vlaanderen_orthophoto"
|
||||
source_dataset.source_metadata = {"product_key": "2020", "supports_detection": False}
|
||||
source_dataset.valid_from = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
source_dataset.valid_to = datetime(2020, 12, 31, 23, 59, 59, tzinfo=UTC)
|
||||
reference_dataset = Dataset(
|
||||
id=reference_dataset_id,
|
||||
project_id=project_id,
|
||||
name="current-grb.geojson",
|
||||
dataset_type="vector",
|
||||
source="grb",
|
||||
source_name="grb",
|
||||
dataset_role="reference",
|
||||
valid_from=datetime(2026, 7, 1, tzinfo=UTC),
|
||||
valid_to=datetime(2026, 7, 31, 23, 59, 59, tzinfo=UTC),
|
||||
)
|
||||
db = FakeSession(
|
||||
objects={
|
||||
(AnalysisRun, analysis_run_id): AnalysisRun(
|
||||
id=analysis_run_id,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
analysis_type="detection",
|
||||
status="success",
|
||||
parameters_json={},
|
||||
),
|
||||
(Dataset, dataset_id): source_dataset,
|
||||
(Dataset, reference_dataset_id): reference_dataset,
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
DetectionService.compare_detections_with_reference(
|
||||
db=db,
|
||||
analysis_run_id=analysis_run_id,
|
||||
reference_dataset_id=reference_dataset_id,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "DETECTION_QA_TEMPORAL_MISMATCH"
|
||||
assert db.added == []
|
||||
|
||||
|
||||
def test_detection_qa_no_match_case_persists_zero_scores() -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
@@ -260,6 +322,7 @@ def test_detection_qa_no_match_case_persists_zero_scores() -> None:
|
||||
db = FakeSession(
|
||||
objects={
|
||||
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="detection", status="success", parameters_json={}),
|
||||
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
|
||||
(Dataset, reference_dataset_id): reference_dataset,
|
||||
},
|
||||
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
|
||||
@@ -311,6 +374,7 @@ def test_configured_yolo_qa_requires_persisted_tile_manifest_provenance() -> Non
|
||||
model_name="yolo-configured",
|
||||
parameters_json={"model_id": "yolo-configured"},
|
||||
),
|
||||
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
|
||||
(Dataset, reference_dataset_id): reference_dataset,
|
||||
},
|
||||
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
|
||||
@@ -391,6 +455,7 @@ def test_detection_qa_excludes_references_outside_persisted_tile_coverage(tmp_pa
|
||||
"tile_manifest_path": str(manifest_path),
|
||||
},
|
||||
),
|
||||
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
|
||||
(Dataset, reference_dataset_id): reference_dataset,
|
||||
},
|
||||
query_rows={Detection: [detection], VectorFeature: [inside_reference, outside_reference]},
|
||||
@@ -452,6 +517,7 @@ def test_detection_qa_reports_box_to_footprint_diagnostic_without_changing_stric
|
||||
"tile_manifest_path": str(manifest_path),
|
||||
},
|
||||
),
|
||||
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
|
||||
(Dataset, reference_dataset_id): reference_dataset,
|
||||
},
|
||||
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
|
||||
|
||||
Reference in New Issue
Block a user