feat(provenance): govern source snapshots and data inputs
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -9,6 +10,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.main import app
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
|
||||
from app.services.yolo_preflight_service import YoloPreflightService
|
||||
|
||||
|
||||
@@ -57,6 +59,43 @@ def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
|
||||
return manifest_path
|
||||
|
||||
|
||||
def _write_model_sidecar(model_path: Path, settings: Settings) -> None:
|
||||
model_sha256 = sha256(model_path.read_bytes()).hexdigest()
|
||||
payload = {
|
||||
"schema_version": RuntimeModelProvenanceService.MANIFEST_SCHEMA_VERSION,
|
||||
"data_contract": {"key": "geointel.model.pytorch", "version": "1.0.0"},
|
||||
"model": {
|
||||
"model_id": settings.yolo_model_id,
|
||||
"task_type": "object_detection",
|
||||
"sha256": model_sha256,
|
||||
"model_format": "pytorch",
|
||||
"framework": "ultralytics/pytorch",
|
||||
"class_mapping": {"0": "building"},
|
||||
"source_version": settings.yolo_model_version or "test-v1",
|
||||
},
|
||||
"source": {
|
||||
"source_registry_id": "11111111-1111-4111-8111-111111111111",
|
||||
"source_snapshot_id": "22222222-2222-4222-8222-222222222222",
|
||||
"source_registry_key": "model",
|
||||
"source_snapshot_checksum_sha256": model_sha256,
|
||||
},
|
||||
"lineage": {
|
||||
"upstream_asset_ids": ["test-training-corpus"],
|
||||
"upstream_checksums_sha256": ["a" * 64],
|
||||
"transformations": [
|
||||
{"name": "test-training", "version": "1.0.0", "checksum_sha256": "b" * 64}
|
||||
],
|
||||
},
|
||||
"metadata": {"training_manifest_sha256": "c" * 64},
|
||||
"imported_at": "2026-08-01T10:00:00+00:00",
|
||||
}
|
||||
payload["metadata"]["runtime_manifest_sha256"] = RuntimeModelProvenanceService.manifest_self_checksum(payload)
|
||||
RuntimeModelProvenanceService.manifest_path_for_model(model_path).write_text(
|
||||
json.dumps(payload, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_yolo_preflight_reports_disabled_without_loading_model(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("YOLO_CONFIG_DIR", str(tmp_path / "ultralytics"))
|
||||
|
||||
@@ -99,9 +138,11 @@ def test_yolo_preflight_validates_model_and_manifest_without_importing_yolo(tmp_
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"weights")
|
||||
manifest_path = _manifest(tmp_path, tile_count=2)
|
||||
settings = Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4)
|
||||
_write_model_sidecar(model_path, settings)
|
||||
|
||||
result = YoloPreflightService.run(
|
||||
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4),
|
||||
settings=settings,
|
||||
tile_manifest_path=str(manifest_path),
|
||||
yolo_adapter_class=AvailableAdapter,
|
||||
)
|
||||
@@ -109,6 +150,7 @@ def test_yolo_preflight_validates_model_and_manifest_without_importing_yolo(tmp_
|
||||
assert result["status"] == "ready"
|
||||
assert result["checks"]["dependencies_available"] is True
|
||||
assert result["checks"]["model_file_exists"] is True
|
||||
assert result["checks"]["model_provenance_valid"] is True
|
||||
assert result["checks"]["manifest_valid"] is True
|
||||
assert result["tile_count"] == 2
|
||||
assert result["will_download_models"] is False
|
||||
@@ -120,9 +162,11 @@ def test_yolo_preflight_marks_assumed_dependencies_in_runtime_details(tmp_path:
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"weights")
|
||||
manifest_path = _manifest(tmp_path, tile_count=1)
|
||||
settings = Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4)
|
||||
_write_model_sidecar(model_path, settings)
|
||||
|
||||
result = YoloPreflightService.run(
|
||||
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4),
|
||||
settings=settings,
|
||||
tile_manifest_path=str(manifest_path),
|
||||
yolo_adapter_class=MissingDependencyAdapter,
|
||||
assume_dependencies=True,
|
||||
@@ -138,9 +182,11 @@ def test_yolo_preflight_can_explicitly_smoke_load_local_model(tmp_path: Path) ->
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"weights")
|
||||
manifest_path = _manifest(tmp_path)
|
||||
settings = Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4)
|
||||
_write_model_sidecar(model_path, settings)
|
||||
|
||||
result = YoloPreflightService.run(
|
||||
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4),
|
||||
settings=settings,
|
||||
tile_manifest_path=str(manifest_path),
|
||||
yolo_adapter_class=AvailableAdapter,
|
||||
check_model_load=True,
|
||||
@@ -156,9 +202,11 @@ def test_yolo_preflight_can_explicitly_smoke_load_local_model(tmp_path: Path) ->
|
||||
def test_yolo_preflight_reports_explicit_model_load_failure(tmp_path: Path) -> None:
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"weights")
|
||||
settings = Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4)
|
||||
_write_model_sidecar(model_path, settings)
|
||||
|
||||
result = YoloPreflightService.run(
|
||||
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4),
|
||||
settings=settings,
|
||||
tile_manifest_path=str(_manifest(tmp_path)),
|
||||
yolo_adapter_class=FailingLoadAdapter,
|
||||
check_model_load=True,
|
||||
@@ -173,6 +221,7 @@ def test_yolo_preflight_script_outputs_json(tmp_path: Path) -> None:
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"weights")
|
||||
manifest_path = _manifest(tmp_path)
|
||||
_write_model_sidecar(model_path, Settings(yolo_enabled=True, yolo_model_path=str(model_path)))
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
@@ -201,6 +250,7 @@ def test_yolo_preflight_script_uses_environment_configuration(tmp_path: Path, mo
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"weights")
|
||||
manifest_path = _manifest(tmp_path)
|
||||
_write_model_sidecar(model_path, Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4))
|
||||
monkeypatch.setenv("YOLO_ENABLED", "true")
|
||||
monkeypatch.setenv("YOLO_MODEL_PATH", str(model_path))
|
||||
monkeypatch.setenv("YOLO_MAX_TILES", "4")
|
||||
@@ -227,6 +277,21 @@ def test_yolo_preflight_script_uses_environment_configuration(tmp_path: Path, mo
|
||||
assert payload["max_tiles"] == 4
|
||||
|
||||
|
||||
def test_yolo_preflight_refuses_unmanifested_local_weights(tmp_path: Path) -> None:
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"unmanifested weights")
|
||||
|
||||
result = YoloPreflightService.run(
|
||||
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path)),
|
||||
tile_manifest_path=str(_manifest(tmp_path)),
|
||||
yolo_adapter_class=AvailableAdapter,
|
||||
)
|
||||
|
||||
assert result["status"] == "contract_incomplete"
|
||||
assert result["checks"]["model_provenance_valid"] is False
|
||||
assert result["error_code"] == "MODEL_PROVENANCE_MANIFEST_MISSING"
|
||||
|
||||
|
||||
def test_yolo_preflight_script_rejects_assumed_dependencies_for_model_load(tmp_path: Path) -> None:
|
||||
result = subprocess.run(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user