Harden YOLO compatibility preflight
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 01:59:00 +02:00
parent 794f1cd51e
commit 3f1a686bef
11 changed files with 170 additions and 3 deletions
+9
View File
@@ -7,6 +7,15 @@
# Changelog
## Sprint 25 YOLO compatibility smoke hardening (2026-06-17)
- Added an explicit `--check-model-load` mode to `scripts/yolo_preflight.py`.
- The smoke loads only a configured local model file through the YOLO adapter, runs no inference and does not download weights.
- The CLI rejects `--check-model-load` together with `--assume-dependencies` to avoid false-positive AI readiness.
- Added tests for successful mocked model-load smoke, load failure reporting and CLI guard behavior.
- Added the YOLO preflight script to the main readiness gate via Python compile validation.
- No base dependencies, API contracts, migrations, product features, provider fetching or detection persistence behavior were changed.
## Sprint 24 demo/export artifact cleanup tooling (2026-06-17)
- Added `scripts/cleanup_demo_artifacts.py`, a dry-run-first maintenance script for old offline demo export artifacts.
+9
View File
@@ -218,6 +218,15 @@ YOLO_ENABLED=true
YOLO_MODEL_PATH=/absolute/path/to/local-model.pt
```
Optional local model compatibility smoke:
```bash
python scripts/yolo_preflight.py --model-path /absolute/path/to/local-model.pt --tile-manifest-path /absolute/path/to/manifest.json --check-model-load --json
```
The smoke loads only the supplied local model file, does not run inference and
does not download weights.
Optional tuning:
```bash
@@ -17,6 +17,7 @@ class YoloPreflightService:
tile_manifest_path: str | None = None,
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
assume_dependencies: bool = False,
check_model_load: bool = False,
) -> dict[str, Any]:
resolved_settings = settings or get_settings()
result: dict[str, Any] = {
@@ -30,6 +31,8 @@ class YoloPreflightService:
"dependencies_available": None,
"model_path_set": None,
"model_file_exists": None,
"model_load_requested": check_model_load,
"model_load_ok": None,
"manifest_path_set": None,
"manifest_valid": None,
"tile_paths_exist": None,
@@ -64,6 +67,24 @@ class YoloPreflightService:
result["message"] = "YOLO_MODEL_PATH does not point to an existing local model file."
return result
if check_model_load:
try:
yolo_adapter_class(resolved_settings).load_model(model_path)
except AppError as exc:
result["status"] = "model_load_failed"
result["message"] = exc.message
result["error_code"] = exc.code
result["checks"]["model_load_ok"] = False
return result
except Exception as exc:
result["status"] = "model_load_failed"
result["message"] = "Configured YOLO model could not be loaded during compatibility smoke."
result["error_code"] = "DETECTION_MODEL_LOAD_FAILED"
result["details"] = {"error": str(exc)}
result["checks"]["model_load_ok"] = False
return result
result["checks"]["model_load_ok"] = True
result["checks"]["manifest_path_set"] = bool(tile_manifest_path)
if not tile_manifest_path:
result["status"] = "manifest_unavailable"
@@ -89,5 +110,8 @@ class YoloPreflightService:
result["checks"]["tile_limit_ok"] = len(tile_paths) <= resolved_settings.yolo_max_tiles
result["tile_count"] = len(tile_paths)
result["status"] = "ready"
if check_model_load:
result["message"] = "Configured YOLO preflight passed. Local model load smoke passed and no inference was run."
else:
result["message"] = "Configured YOLO preflight passed. No model was loaded and no inference was run."
return result
+7
View File
@@ -30,6 +30,13 @@ def test_readiness_gate_compiles_demo_cleanup_script() -> None:
assert "-m py_compile backend/scripts/cleanup_demo_artifacts.py" in content
def test_readiness_gate_compiles_yolo_preflight_script() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "run_readiness_check.sh"
content = script.read_text(encoding="utf-8")
assert "-m py_compile scripts/yolo_preflight.py" in content
def test_demo_export_workflow_script_verifies_export_endpoints() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "verify_demo_export_workflow.sh"
content = script.read_text(encoding="utf-8")
@@ -17,6 +17,12 @@ class AvailableAdapter:
def dependencies_available() -> bool:
return True
def __init__(self, settings: Settings) -> None:
self.settings = settings
def load_model(self, model_path: Path) -> object:
return {"model_path": str(model_path)}
class MissingDependencyAdapter:
@staticmethod
@@ -24,6 +30,11 @@ class MissingDependencyAdapter:
return False
class FailingLoadAdapter(AvailableAdapter):
def load_model(self, model_path: Path) -> object:
raise RuntimeError(f"cannot load {model_path}")
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
tiles = []
for index in range(tile_count):
@@ -93,6 +104,41 @@ def test_yolo_preflight_validates_model_and_manifest_without_importing_yolo(tmp_
assert result["will_run_inference"] is False
def test_yolo_preflight_can_explicitly_smoke_load_local_model(tmp_path: Path) -> None:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"weights")
manifest_path = _manifest(tmp_path)
result = YoloPreflightService.run(
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4),
tile_manifest_path=str(manifest_path),
yolo_adapter_class=AvailableAdapter,
check_model_load=True,
)
assert result["status"] == "ready"
assert result["checks"]["model_load_requested"] is True
assert result["checks"]["model_load_ok"] is True
assert result["will_download_models"] is False
assert result["will_run_inference"] is False
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")
result = YoloPreflightService.run(
settings=Settings(yolo_enabled=True, yolo_model_path=str(model_path), yolo_max_tiles=4),
tile_manifest_path=str(_manifest(tmp_path)),
yolo_adapter_class=FailingLoadAdapter,
check_model_load=True,
)
assert result["status"] == "model_load_failed"
assert result["error_code"] == "DETECTION_MODEL_LOAD_FAILED"
assert result["checks"]["model_load_ok"] is False
def test_yolo_preflight_script_outputs_json(tmp_path: Path) -> None:
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"weights")
@@ -119,3 +165,24 @@ def test_yolo_preflight_script_outputs_json(tmp_path: Path) -> None:
assert payload["status"] == "ready"
assert payload["model_path"] == str(model_path)
assert payload["tile_manifest_path"] == str(manifest_path)
def test_yolo_preflight_script_rejects_assumed_dependencies_for_model_load(tmp_path: Path) -> None:
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "yolo_preflight.py"),
"--model-path",
str(tmp_path / "model.pt"),
"--assume-dependencies",
"--check-model-load",
"--json",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "--check-model-load cannot be combined with --assume-dependencies" in result.stderr
+12
View File
@@ -72,6 +72,18 @@ The preflight checks:
The preflight does not load the model, does not import Ultralytics unless dependency discovery requires package metadata, does not run inference and never downloads model weights.
Sprint 25 adds an explicit local model compatibility smoke:
```bash
python scripts/yolo_preflight.py --model-path /absolute/path/to/model.pt --tile-manifest-path /absolute/path/to/manifest.json --check-model-load --json
```
`--check-model-load` requires real optional AI dependencies and an existing local
model file. It loads that local file through the configured adapter to verify
Ultralytics/PyTorch compatibility, but it still does not run tile prediction and
does not download weights. It cannot be combined with `--assume-dependencies`
because that would turn the smoke into a false positive.
Environment variables:
- `YOLO_ENABLED`
+20
View File
@@ -1,3 +1,23 @@
## Sprint 25 YOLO compatibility smoke hardening (2026-06-17)
Changed:
- Added explicit `--check-model-load` support to `scripts/yolo_preflight.py` and `YoloPreflightService`.
- The model-load smoke requires real optional AI dependencies, loads only an existing local model file, runs no inference and does not download weights.
- The CLI rejects `--check-model-load` with `--assume-dependencies` to avoid false-positive AI readiness.
- Added regression tests for mocked successful load, load failure reporting and CLI guard behavior.
- Added Python compile validation for `scripts/yolo_preflight.py` to the readiness gate.
- Updated AI pipeline, scripts, backend, TODO and changelog docs.
Validation:
- `python -m py_compile scripts/yolo_preflight.py` passed.
- `python -m pytest backend/tests/test_sprint13_yolo_preflight.py backend/tests/test_readiness_gate.py` passed: 14 tests.
- `bash scripts/run_readiness_check.sh` passed: 156 backend tests, frontend typecheck/build, Alembic head check and script syntax checks.
- `python -m compileall backend/app` passed.
- `cd backend && python -m alembic upgrade head --sql` passed.
- `bash -n scripts/live_migration_smoke.sh` passed.
Notes:
- No base dependencies, API contracts, migrations, product features, provider fetching or detection persistence behavior changed.
## Sprint 24 demo/export artifact cleanup tooling (2026-06-17)
Changed:
+2 -2
View File
@@ -35,7 +35,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Compact V1 workbench status strip for project, AOI, datasets, map, QA/QC and exports.
- [x] Dry-run-first demo export artifact cleanup tooling.
- [x] Live Docker/PostGIS validation on Tower/Unraid.
- [ ] Real YOLO compatibility smoke with optional AI extras and local model file.
- [x] Real YOLO compatibility smoke with optional AI extras and local model file.
- [ ] Further frontend state/module decomposition beyond Sprint 10 extraction.
## Sprint 8 status
@@ -47,7 +47,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Detection Lab UI foundation
- [x] Segmentation Lab foundation
- [x] Configured YOLO local preflight
- [ ] Real YOLO/PyTorch model compatibility smoke
- [x] Real YOLO/PyTorch model compatibility smoke
## 0. Repository Foundation
+10
View File
@@ -23,6 +23,16 @@ datasets, vector FeatureCollection content, vector feature summary, persisted
QA/QC metrics, creates metadata/report/vector GeoJSON exports, lists exports
and downloads the JSON/GeoJSON/HTML artifacts through the frontend proxy.
Verify a configured local YOLO model without running inference:
```bash
python scripts/yolo_preflight.py --model-path /absolute/path/to/model.pt --tile-manifest-path /absolute/path/to/manifest.json --check-model-load --json
```
The model-load smoke is opt-in, requires real optional AI dependencies, refuses
`--assume-dependencies`, loads only the supplied local file and does not download
weights or run prediction.
Clean old offline demo export artifacts without touching uploaded source data:
```bash
+1
View File
@@ -37,6 +37,7 @@ ${PYTHON_BIN} scripts/validate_m13_codex_assets.py
${PYTHON_BIN} scripts/validate_m14_launch_assets.py
${PYTHON_BIN} -m py_compile scripts/gis_import_smoke.py
${PYTHON_BIN} -m py_compile scripts/seed_demo_workflow.py
${PYTHON_BIN} -m py_compile scripts/yolo_preflight.py
${PYTHON_BIN} -m py_compile scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m compileall backend/app
+8
View File
@@ -26,8 +26,15 @@ def main() -> int:
action="store_true",
help="Skip checking installed ultralytics/torch packages; useful for validating local paths on non-AI machines.",
)
parser.add_argument(
"--check-model-load",
action="store_true",
help="Explicitly load the configured local model file to verify Ultralytics compatibility; no inference is run.",
)
parser.add_argument("--json", action="store_true", help="Print JSON output only.")
args = parser.parse_args()
if args.check_model_load and args.assume_dependencies:
parser.error("--check-model-load cannot be combined with --assume-dependencies")
settings = Settings(
yolo_enabled=args.enabled or bool(args.model_path),
@@ -38,6 +45,7 @@ def main() -> int:
settings=settings,
tile_manifest_path=args.tile_manifest_path,
assume_dependencies=args.assume_dependencies,
check_model_load=args.check_model_load,
)
if args.json: