feat(provenance): govern source snapshots and data inputs
This commit is contained in:
@@ -19,10 +19,12 @@ from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, Vect
|
||||
from app.schemas.detection import DetectionListResponse, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunResponse
|
||||
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
|
||||
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.qa_service import QaService
|
||||
from app.services.quality_service import QualityService
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
||||
from app.services.temporal_compatibility_service import TemporalCompatibilityService
|
||||
from app.services.yolo_adapter import YoloDetectionAdapter
|
||||
|
||||
@@ -97,6 +99,18 @@ class DetectionService:
|
||||
if model.model_id == resolved_settings.yolo_model_id and resolved_settings.yolo_enforce_validation_scope:
|
||||
DetectionService._validate_model_area_scope(db, dataset, resolved_settings)
|
||||
|
||||
# Never enter a production inference path with a persisted dataset
|
||||
# that has failed validation, incomplete provenance, or an active
|
||||
# quarantine. Fixture detection is a separate QA/test-only path.
|
||||
if model.model_id == "manual-fixture-detector":
|
||||
DatasetConsumptionGate.assert_eligible(
|
||||
dataset,
|
||||
purpose="quality_assessment",
|
||||
fixture_mode=True,
|
||||
)
|
||||
elif model.model_id == resolved_settings.yolo_model_id and model.configured:
|
||||
DatasetConsumptionGate.assert_eligible(dataset, purpose="production_inference")
|
||||
|
||||
run_parameters = {
|
||||
"model_id": model.model_id,
|
||||
"model_asset_id": selected_model_asset.model_asset_id if selected_model_asset else None,
|
||||
@@ -352,15 +366,6 @@ class DetectionService:
|
||||
reference_dataset,
|
||||
)
|
||||
|
||||
detections = DetectionService._query_detection_rows(
|
||||
db,
|
||||
analysis_run_id=analysis_run_id,
|
||||
dataset_id=run.dataset_id,
|
||||
class_name=class_name,
|
||||
min_confidence=min_confidence,
|
||||
)
|
||||
raw_candidate_geometries = [({"id": str(row.id), "class_name": row.class_name}, to_shape(row.geometry)) for row in detections]
|
||||
candidate_geometries = raw_candidate_geometries
|
||||
run_parameters = run.parameters_json if isinstance(run.parameters_json, dict) else {}
|
||||
manifest_path = DetectionQaService.tile_manifest_path(run_parameters)
|
||||
resolved_settings = get_settings()
|
||||
@@ -375,6 +380,33 @@ class DetectionService:
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
fixture_parameters = run_parameters.get("parameters_json")
|
||||
fixture_mode = bool(
|
||||
run.model_name == "manual-fixture-detector"
|
||||
and isinstance(fixture_parameters, dict)
|
||||
and fixture_parameters.get("fixture_mode") is True
|
||||
)
|
||||
DatasetConsumptionGate.assert_eligible(
|
||||
candidate_dataset,
|
||||
purpose="quality_assessment",
|
||||
fixture_mode=fixture_mode,
|
||||
)
|
||||
DatasetConsumptionGate.assert_eligible(
|
||||
reference_dataset,
|
||||
purpose="reference_validation",
|
||||
reference_task="building_validation",
|
||||
)
|
||||
|
||||
detections = DetectionService._query_detection_rows(
|
||||
db,
|
||||
analysis_run_id=analysis_run_id,
|
||||
dataset_id=run.dataset_id,
|
||||
class_name=class_name,
|
||||
min_confidence=min_confidence,
|
||||
)
|
||||
raw_candidate_geometries = [({"id": str(row.id), "class_name": row.class_name}, to_shape(row.geometry)) for row in detections]
|
||||
candidate_geometries = raw_candidate_geometries
|
||||
|
||||
coverage = None
|
||||
if manifest_path:
|
||||
manifest = DetectionService._load_tile_manifest(manifest_path, resolved_settings.yolo_max_tiles)
|
||||
@@ -728,6 +760,19 @@ class DetectionService:
|
||||
) -> tuple[list[Detection], dict[str, Any]]:
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles)
|
||||
model_path = Path(settings.yolo_model_path or "").expanduser()
|
||||
runtime_model_provenance = RuntimeModelProvenanceService.validate_for_production_runtime(
|
||||
db=db,
|
||||
model_path=model_path,
|
||||
model_id=model_name,
|
||||
task_type="object_detection",
|
||||
expected_model_version=model_version,
|
||||
allowed_frameworks=("ultralytics/pytorch", "ultralytics", "pytorch"),
|
||||
)
|
||||
DetectionService._attach_runtime_model_provenance(
|
||||
analysis_run,
|
||||
job,
|
||||
runtime_model_provenance,
|
||||
)
|
||||
adapter = yolo_adapter_class(settings)
|
||||
model = adapter.load_model(model_path)
|
||||
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
|
||||
@@ -785,7 +830,10 @@ class DetectionService:
|
||||
"y_max": float(bbox[3]),
|
||||
},
|
||||
source_tile_path=candidate["source_tile_path"],
|
||||
properties_json=candidate["properties"],
|
||||
properties_json={
|
||||
**candidate["properties"],
|
||||
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
||||
},
|
||||
)
|
||||
db.add(detection)
|
||||
persisted.append(detection)
|
||||
@@ -796,8 +844,30 @@ class DetectionService:
|
||||
"raw_detection_count": len(candidates),
|
||||
"suppressed_detection_count": len(candidates) - len(filtered_candidates),
|
||||
"duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold),
|
||||
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _attach_runtime_model_provenance(
|
||||
analysis_run: AnalysisRun,
|
||||
job: Job,
|
||||
provenance: RuntimeModelProvenance,
|
||||
) -> None:
|
||||
"""Persist byte-bound model evidence with the run before adapter loading.
|
||||
|
||||
Individual detections retain the same evidence in ``properties_json``;
|
||||
this run-level copy is the compact audit root for a complete inference.
|
||||
Assigning fresh dictionaries matters for SQLAlchemy JSON change tracking.
|
||||
"""
|
||||
|
||||
evidence = provenance.as_dict()
|
||||
analysis_parameters = dict(analysis_run.parameters_json or {})
|
||||
analysis_parameters["runtime_model_provenance"] = evidence
|
||||
analysis_run.parameters_json = analysis_parameters
|
||||
job_parameters = dict(job.parameters_json or {})
|
||||
job_parameters["runtime_model_provenance"] = evidence
|
||||
job.parameters_json = job_parameters
|
||||
|
||||
@staticmethod
|
||||
def _canonical_class_name(value: Any) -> str:
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
Reference in New Issue
Block a user