From 3f1a686bef70751f9776a7009b9cc7c114481e6a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 01:59:00 +0200 Subject: [PATCH] Harden YOLO compatibility preflight --- CHANGELOG.md | 9 +++ backend/README.md | 9 +++ .../app/services/yolo_preflight_service.py | 26 ++++++- backend/tests/test_readiness_gate.py | 7 ++ backend/tests/test_sprint13_yolo_preflight.py | 67 +++++++++++++++++++ docs/AI_PIPELINES.md | 12 ++++ docs/CODEX_EXECUTION_LOG.md | 20 ++++++ docs/TODO.md | 4 +- scripts/README.md | 10 +++ scripts/run_readiness_check.sh | 1 + scripts/yolo_preflight.py | 8 +++ 11 files changed, 170 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99896fd4..3bfc968b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/README.md b/backend/README.md index cd49dfba..37c12543 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/services/yolo_preflight_service.py b/backend/app/services/yolo_preflight_service.py index 3b721fe4..bdd67c3c 100644 --- a/backend/app/services/yolo_preflight_service.py +++ b/backend/app/services/yolo_preflight_service.py @@ -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" - result["message"] = "Configured YOLO preflight passed. No model was loaded and no inference was run." + 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 diff --git a/backend/tests/test_readiness_gate.py b/backend/tests/test_readiness_gate.py index e72c329a..d49331e4 100644 --- a/backend/tests/test_readiness_gate.py +++ b/backend/tests/test_readiness_gate.py @@ -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") diff --git a/backend/tests/test_sprint13_yolo_preflight.py b/backend/tests/test_sprint13_yolo_preflight.py index bf4f994c..7c57a766 100644 --- a/backend/tests/test_sprint13_yolo_preflight.py +++ b/backend/tests/test_sprint13_yolo_preflight.py @@ -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 diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index 8b23be91..74f447ac 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -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` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 21c57889..71050c52 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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: diff --git a/docs/TODO.md b/docs/TODO.md index ecda7374..899cdef5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/scripts/README.md b/scripts/README.md index 7a444dba..4d5c38b4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index ae343390..bf26a7f6 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -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 diff --git a/scripts/yolo_preflight.py b/scripts/yolo_preflight.py index 3b99858d..69f019ac 100644 --- a/scripts/yolo_preflight.py +++ b/scripts/yolo_preflight.py @@ -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: