diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c91a45..446d52d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Added `--preflight-only` to `scripts/run_split_background_promotion_workflow.sh`. - Preflight validates local positive portfolio and operator manifest files, confirms required `pure_empty_negative` and `sparse_building_context` background categories, checks Python/curl availability and verifies the runtime API proxy returns the canonical envelope. +- Hardened preflight compatibility for older operator manifests by deriving missing background categories from `reference_feature_count`, matching the existing matrix-runner behavior. - Documented the quick post-redeploy preflight command before starting long configured-YOLO matrix inference. - No model default, backend API, database migration, provider fetching, fake detection output or model download behavior changed. diff --git a/backend/tests/test_sprint159_split_promotion_workflow.py b/backend/tests/test_sprint159_split_promotion_workflow.py index c47c7fd7..b4b2f269 100644 --- a/backend/tests/test_sprint159_split_promotion_workflow.py +++ b/backend/tests/test_sprint159_split_promotion_workflow.py @@ -1,11 +1,32 @@ from __future__ import annotations +import json +import os +import shlex +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +def _bash_path(path: Path) -> str: + raw_path = str(path) + if os.name != "nt": + return raw_path + + result = subprocess.run( + ["bash", "-lc", f"wslpath -a {shlex.quote(raw_path)}"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return raw_path + + def test_split_background_promotion_workflow_runs_split_then_split_aware_report() -> None: script_path = ROOT / "scripts" / "run_split_background_promotion_workflow.sh" assert script_path.exists() @@ -35,6 +56,54 @@ def test_split_background_promotion_workflow_has_safe_preflight_mode() -> None: assert "Split-background promotion preflight passed" in source +def test_split_background_preflight_derives_missing_background_categories(tmp_path: Path) -> None: + positive_portfolio = tmp_path / "positive.json" + positive_portfolio.write_text('{"items":[]}', encoding="utf-8") + manifest = tmp_path / "operator_samples_manifest.json" + manifest.write_text( + json.dumps( + { + "samples": [ + { + "sample_slug": "postel_bos", + "sample_role": "background_candidate", + "reference_feature_count": 0, + }, + { + "sample_slug": "kasterlee_bos", + "sample_role": "background_candidate", + "reference_feature_count": 7, + }, + ] + } + ), + encoding="utf-8", + ) + + api_root = tmp_path / "api-root" + projects_endpoint = api_root / "api" / "v1" / "projects" + projects_endpoint.parent.mkdir(parents=True) + projects_endpoint.write_text('{"data":{"items":[]}}', encoding="utf-8") + + command = ( + f"PROMOTION_POSITIVE_PORTFOLIO_PATH={shlex.quote(_bash_path(positive_portfolio))} " + f"OPERATOR_SAMPLE_MANIFEST_PATH={shlex.quote(_bash_path(manifest))} " + f"bash scripts/run_split_background_promotion_workflow.sh --preflight-only " + f"{shlex.quote(f'file://{_bash_path(api_root)}')}" + ) + result = subprocess.run( + ["bash", "-lc", command], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "Split-background promotion preflight passed" in result.stdout + + def test_readiness_checks_split_background_promotion_workflow_syntax() -> None: readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index 56a99673..17521bde 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -301,6 +301,11 @@ For a quick post-redeploy check before the long matrix starts, use checks local paths, required background categories and the runtime API envelope without running inference: +Legacy operator manifests that do not yet contain explicit `background_category` +remain supported: the preflight derives `pure_empty_negative` from +`reference_feature_count == 0` and `sparse_building_context` from background +samples with persisted reference features, matching the matrix runner. + ```bash PROMOTION_POSITIVE_PORTFOLIO_PATH=artifacts/detection-quality-matrix/multi-sample/aoi1024bg512r3e50-positive/multi_sample_quality_summary.json \ OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/operator_samples_manifest.json \ diff --git a/scripts/README.md b/scripts/README.md index bb844302..e53a5e31 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -724,6 +724,11 @@ presence, required `pure_empty_negative` and `sparse_building_context` background categories, Python/curl availability and the frontend API proxy envelope without starting inference: +For older operator manifests that predate explicit `background_category`, +preflight uses the same fallback as the matrix runner: background samples with +`reference_feature_count == 0` are treated as `pure_empty_negative`, and +background samples with references are treated as `sparse_building_context`. + ```bash PROMOTION_POSITIVE_PORTFOLIO_PATH=/mnt/user/appdata/geointel/artifacts/detection-quality-matrix/multi-sample/aoi1024bg512r3e50-positive/multi_sample_quality_summary.json \ OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/operator_samples_manifest.json \ diff --git a/scripts/run_split_background_promotion_workflow.sh b/scripts/run_split_background_promotion_workflow.sh index c67c60ec..35b57e14 100644 --- a/scripts/run_split_background_promotion_workflow.sh +++ b/scripts/run_split_background_promotion_workflow.sh @@ -110,11 +110,24 @@ samples = payload.get("samples") or payload.get("items") or [] if not isinstance(samples, list) or not samples: raise SystemExit(f"Operator sample manifest has no samples: {manifest_path}") -background_categories = { - str(sample.get("background_category") or "") - for sample in samples - if str(sample.get("sample_role") or "") == "background_candidate" -} +background_categories = set() +for sample in samples: + if str(sample.get("sample_role") or "") != "background_candidate": + continue + + category = str(sample.get("background_category") or "").strip() + if not category: + try: + reference_feature_count = int(sample.get("reference_feature_count") or 0) + except (TypeError, ValueError): + reference_feature_count = 0 + category = ( + "pure_empty_negative" + if reference_feature_count == 0 + else "sparse_building_context" + ) + + background_categories.add(category) missing = {"pure_empty_negative", "sparse_building_context"} - background_categories if missing: raise SystemExit(