Use split background reports in promotion gate
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-10 02:44:11 +02:00
parent 251aa7b044
commit dea65666ec
7 changed files with 360 additions and 3 deletions
+7
View File
@@ -7,6 +7,13 @@
# Changelog
## Sprint 158 Split-aware promotion report (2026-07-10)
- Added `--background-split-summary` support to `scripts/build_detection_model_promotion_report.py`.
- The promotion report now resolves the split summary's `pure_empty_negative` source as the strict default-promotion background gate and records `sparse_building_context` as review-only evidence.
- Added regression coverage proving sparse-context detections do not block default promotion when the pure-empty gate passes.
- No model default, backend API, database migration, provider fetching, fake detection output or model download behavior changed.
## Sprint 157 Background split matrix runner (2026-07-10)
- Added `scripts/run_background_corpus_split_matrix.sh` to run pure-empty and sparse-context hard-negative matrices separately from one operator command.
@@ -0,0 +1,174 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def write_hard_negative_summary(
path: Path,
*,
category: str,
detection_counts: list[int],
) -> None:
items = [
{
"sample_slug": f"{category}_{index}",
"background_category": category,
"model_asset_id": "candidate-context-sensitive",
"tile_size": 512,
"tile_overlap": 64,
"threshold": 0.35,
"tile_count": 4,
"detection_count": detection_count,
"false_positive_pressure": detection_count / 4,
}
for index, detection_count in enumerate(detection_counts, start=1)
]
path.write_text(
json.dumps(
{
"generated_at": "2026-07-10T00:00:00+00:00",
"background_category_counts": {category: len(items)},
"items": items,
}
),
encoding="utf-8",
)
def test_promotion_report_uses_split_pure_empty_as_gate_and_sparse_context_as_review(
tmp_path: Path,
) -> None:
script_path = ROOT / "scripts" / "build_detection_model_promotion_report.py"
positive_path = tmp_path / "positive_portfolio.json"
pure_summary_path = tmp_path / "pure_empty_summary.json"
sparse_summary_path = tmp_path / "sparse_context_summary.json"
split_summary_path = tmp_path / "background_corpus_split_summary.json"
output_dir = tmp_path / "promotion-report"
positive_path.write_text(
json.dumps(
{
"items": [
{
"sample_slug": "geel",
"model_asset_id": "candidate-context-sensitive",
"tile_size": 512,
"tile_overlap": 64,
"threshold": 0.35,
"precision": 0.72,
"recall": 0.5,
"f1_score": 0.59,
},
{
"sample_slug": "mol",
"model_asset_id": "candidate-context-sensitive",
"tile_size": 512,
"tile_overlap": 64,
"threshold": 0.35,
"precision": 0.68,
"recall": 0.48,
"f1_score": 0.56,
},
]
}
),
encoding="utf-8",
)
write_hard_negative_summary(
pure_summary_path,
category="pure_empty_negative",
detection_counts=[0, 0],
)
write_hard_negative_summary(
sparse_summary_path,
category="sparse_building_context",
detection_counts=[4, 7],
)
split_summary_path.write_text(
json.dumps(
{
"schema_version": 1,
"source_summaries": {
"pure_empty_negative": str(pure_summary_path),
"sparse_building_context": str(sparse_summary_path),
},
"strict_default_gate": {
"category": "pure_empty_negative",
"review_only": False,
"sample_count": 2,
"run_count": 2,
"total_detection_count": 0,
"max_detection_count": 0,
"passes_zero_detection_gate": True,
},
"context_review": {
"category": "sparse_building_context",
"review_only": True,
"sample_count": 2,
"run_count": 2,
"total_detection_count": 11,
"max_detection_count": 7,
},
}
),
encoding="utf-8",
)
result = subprocess.run(
[
"python",
str(script_path),
"--positive-portfolio",
str(positive_path),
"--background-split-summary",
str(split_summary_path),
"--output-dir",
str(output_dir),
"--min-positive-samples",
"2",
"--min-background-samples",
"2",
"--min-mean-f1",
"0.5",
"--max-background-detections-per-sample",
"0",
],
cwd=ROOT,
check=True,
text=True,
capture_output=True,
)
assert "Detection model promotion report passed" in result.stdout
report = json.loads((output_dir / "detection_model_promotion_report.json").read_text(encoding="utf-8"))
decision = report["candidate_decisions"][0]
assert report["hard_negative_summary_paths"] == [str(pure_summary_path)]
assert report["background_split_summary_paths"] == [str(split_summary_path)]
assert report["background_context_reviews"] == [
{
"source_split_summary_path": str(split_summary_path),
"source_summary_path": str(sparse_summary_path),
"category": "sparse_building_context",
"review_only": True,
"sample_count": 2,
"run_count": 2,
"total_detection_count": 11,
"max_detection_count": 7,
}
]
assert decision["candidate_key"] == "candidate-context-sensitive|512|64|0.35"
assert decision["background_sample_count"] == 2
assert decision["max_background_detections"] == 0
assert decision["promotion_status"] == "promote_candidate"
assert report["recommended_candidate"]["candidate_key"] == decision["candidate_key"]
markdown = (output_dir / "detection_model_promotion_report.md").read_text(encoding="utf-8")
assert "Background split summaries: 1" in markdown
assert "Sparse-context review evidence" in markdown
assert "not used as a default-promotion gate" in markdown
+18
View File
@@ -260,6 +260,24 @@ The split runner writes `background_corpus_split_summary.json` and Markdown
handoff output with a strict `pure_empty_negative` gate and a separate
review-only `sparse_building_context` block.
Use that split summary directly in the model promotion report:
```bash
python scripts/build_detection_model_promotion_report.py \
--positive-portfolio artifacts/detection-quality-matrix/multi-sample/aoi1024bg512r3e50-positive/multi_sample_quality_summary.json \
--background-split-summary artifacts/detection-hard-negatives/aoi1024bg512r3e50-split/background_corpus_split_summary.json \
--output-dir artifacts/detection-model-promotion/aoi1024bg512r3e50-split-aware \
--min-positive-samples 7 \
--min-background-samples 2 \
--min-mean-f1 0.25 \
--max-background-detections-per-sample 0
```
The promotion report follows the split contract: `pure_empty_negative` is the
only strict background gate for default promotion, while
`sparse_building_context` remains review-only evidence in the report. This keeps
contextual buildings from being treated as empty-background false positives.
The underlying single-category matrix remains available:
```bash
+37
View File
@@ -6275,3 +6275,40 @@ Open:
- Rebuild/redeploy the runtime, regenerate the operator manifest if needed, then run `scripts/run_background_corpus_split_matrix.sh` against `http://192.168.10.150:1202`.
- Use the emitted split report to decide whether to retrain, recalibrate thresholds or keep the AOI1024 candidate operator-only.
# Sprint 158 - Split-aware promotion report
## What changed
- Added `--background-split-summary` support to `scripts/build_detection_model_promotion_report.py`.
- The promotion report now resolves a split report's `pure_empty_negative` source summary as the strict default-promotion background gate.
- The same report records `sparse_building_context` as review-only evidence, including source path and detection-pressure context, without counting it as a default-promotion blocker.
- Kept direct `--hard-negative-summary` support unchanged for older operator workflows.
- Updated operator pipeline docs, TODO and changelog.
## What was tested
- Red step: `python -m pytest tests/test_sprint158_promotion_report_split_background.py -q` failed because the promotion report required `--hard-negative-summary` and did not yet accept `--background-split-summary`.
- Ran `python -m pytest tests/test_sprint158_promotion_report_split_background.py -q`: 1 passed.
- Ran `python -m pytest tests/test_sprint143_detection_model_promotion_report.py tests/test_sprint157_background_split_matrix_runner.py -q`: 7 passed.
- Ran `python -m py_compile scripts/build_detection_model_promotion_report.py scripts/build_background_corpus_split_report.py`.
- Ran `python -m pytest tests/test_sprint158_promotion_report_split_background.py tests/test_sprint143_detection_model_promotion_report.py tests/test_sprint157_background_split_matrix_runner.py -q`: 8 passed.
- Ran `python -m compileall backend/app`.
- Ran `python -m pytest` in `backend`: 444 passed, 17 existing Pydantic namespace warnings.
- Ran `cd frontend && npm run typecheck`.
- Ran `cd frontend && npm run build`.
- Ran `bash scripts/run_readiness_check.sh`: passed.
- Ran `cd backend && python -m alembic heads`: `202606120900 (head)`.
- Ran `cd backend && python -m alembic upgrade head --sql`.
- Ran `bash -n scripts/live_migration_smoke.sh`.
## Known limitations
- This pass is report/tooling only. It does not run live split matrices on Tower, retrain YOLO, change a model default, change API contracts, change migrations, fetch providers, download model weights or create fake detections.
- The AOI1024 background-aware local model remains explicit operator-review only until the split matrices plus positive-AOI QA/QC evidence pass the documented gates.
## Next recommended pass
- After redeploy, run `scripts/run_background_corpus_split_matrix.sh` on Tower for `geointel-building-yolov8s-aoi1024bg512r3e50-pt`.
- Feed the generated `background_corpus_split_summary.json` into `scripts/build_detection_model_promotion_report.py --background-split-summary` together with the seven-AOI positive summary.
- If pure-empty false-positive pressure still fails, retrain or recalibrate before any default activation. If pure-empty passes, inspect sparse-context review evidence before deciding whether to keep the model operator-only or prepare a guarded default-candidate decision.
+1
View File
@@ -123,6 +123,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Add explicit operator detection profiles for local model assets: balanced review around threshold `0.15` and conservative high-precision review around threshold `0.35`, both clearly marked as non-default-approved until promotion gates pass.
- [x] Add pure-empty versus sparse-building contextual background corpus classification to operator manifests, hard-negative matrix filters and YOLO tile provenance.
- [x] Add a split background-corpus matrix runner and report builder that runs pure-empty and sparse-context matrices separately.
- [x] Teach the model promotion report to consume split background summaries so only `pure_empty_negative` blocks default promotion and `sparse_building_context` stays review-only.
- [ ] Rerun split background matrices on Tower after rebuild, then retrain or recalibrate against the cleaner pure-empty gate plus separate sparse-context inspection matrix.
- [ ] Promote a V1 default building detector only after it passes seven positive AOIs, clean hard-negative/background gates and persisted QA/QC evidence without fake detections or model downloads.
+20
View File
@@ -679,6 +679,26 @@ and maximum background detections per sample. It is evidence/report tooling
only: it does not run inference, mutate application data, download models or
change the active YOLO configuration.
When the background corpus has been split with
`run_background_corpus_split_matrix.sh`, pass the combined split summary instead
of manually wiring both category summaries:
```bash
python scripts/build_detection_model_promotion_report.py \
--positive-portfolio /mnt/user/appdata/geointel/artifacts/detection-quality-matrix/multi-sample/aoi1024bg512r3e50-positive/multi_sample_quality_summary.json \
--background-split-summary /mnt/user/appdata/geointel/artifacts/detection-hard-negatives/aoi1024bg512r3e50-split/background_corpus_split_summary.json \
--output-dir /mnt/user/appdata/geointel/artifacts/detection-model-promotion/aoi1024bg512r3e50-split-aware \
--min-positive-samples 7 \
--min-background-samples 2 \
--min-mean-f1 0.25 \
--max-background-detections-per-sample 0
```
The promotion report resolves the split summary's `pure_empty_negative` source
summary as the strict default-promotion false-positive gate. The
`sparse_building_context` source remains visible in the JSON/Markdown report as
review evidence only and is not counted as a default-promotion gate.
If a legacy positive evidence portfolio records `model_asset_id` at portfolio
level but does not include per-run tile size/overlap, pass explicit tile
defaults instead of letting the report guess:
@@ -18,6 +18,10 @@ from statistics import mean
from typing import Any
PURE_EMPTY_CATEGORY = "pure_empty_negative"
SPARSE_CONTEXT_CATEGORY = "sparse_building_context"
@dataclass(frozen=True)
class CandidateKey:
model_asset_id: str
@@ -39,9 +43,18 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--hard-negative-summary",
action="append",
required=True,
default=[],
help="Path to hard_negative_matrix_summary.json. May be supplied multiple times.",
)
parser.add_argument(
"--background-split-summary",
action="append",
default=[],
help=(
"Path to background_corpus_split_summary.json. The pure-empty source summary is used as the "
"strict default gate; sparse-context evidence is kept as review-only context."
),
)
parser.add_argument("--output-dir", required=True, help="Directory for JSON and Markdown report output.")
parser.add_argument("--min-positive-samples", type=int, default=3)
parser.add_argument("--min-background-samples", type=int, default=3)
@@ -59,7 +72,10 @@ def parse_args() -> argparse.Namespace:
default=None,
help="Tile overlap to use for positive portfolio runs that do not record tile_overlap.",
)
return parser.parse_args()
args = parser.parse_args()
if not args.hard_negative_summary and not args.background_split_summary:
parser.error("at least one --hard-negative-summary or --background-split-summary is required")
return args
def read_json(path: Path) -> dict[str, Any]:
@@ -168,6 +184,65 @@ def collect_background_runs(summary_paths: list[Path]) -> dict[CandidateKey, lis
return grouped
def resolve_split_source_path(source_path: Any, split_summary_path: Path) -> Path:
path = Path(str(source_path))
if path.is_file():
return path
if not path.is_absolute():
candidate = split_summary_path.parent / path
if candidate.is_file():
return candidate
return path
def collect_split_background_inputs(
split_summary_paths: list[Path],
) -> tuple[list[Path], list[dict[str, Any]]]:
strict_background_paths: list[Path] = []
context_reviews: list[dict[str, Any]] = []
for split_summary_path in split_summary_paths:
payload = read_json(split_summary_path)
source_summaries = payload.get("source_summaries") or {}
pure_empty_source = source_summaries.get(PURE_EMPTY_CATEGORY)
if not pure_empty_source:
raise SystemExit(f"Split summary has no {PURE_EMPTY_CATEGORY} source: {split_summary_path}")
strict_block = payload.get("strict_default_gate") or {}
strict_category = strict_block.get("category")
if strict_category and strict_category != PURE_EMPTY_CATEGORY:
raise SystemExit(
f"Split summary strict gate must be {PURE_EMPTY_CATEGORY}; found {strict_category}: {split_summary_path}"
)
strict_background_paths.append(resolve_split_source_path(pure_empty_source, split_summary_path))
context_block = payload.get("context_review") or {}
sparse_context_source = source_summaries.get(SPARSE_CONTEXT_CATEGORY)
if context_block or sparse_context_source:
context_entry = dict(context_block)
context_entry["source_split_summary_path"] = str(split_summary_path)
if sparse_context_source:
context_entry["source_summary_path"] = str(
resolve_split_source_path(sparse_context_source, split_summary_path)
)
context_reviews.append(context_entry)
return strict_background_paths, context_reviews
def unique_paths(paths: list[Path]) -> list[Path]:
seen: set[str] = set()
unique: list[Path] = []
for path in paths:
marker = str(path)
if marker in seen:
continue
seen.add(marker)
unique.append(path)
return unique
def average_metric(runs: list[dict[str, Any]], metric: str) -> float | None:
values = [numeric(run, metric) for run in runs]
values = [value for value in values if value is not None]
@@ -177,7 +252,10 @@ def average_metric(runs: list[dict[str, Any]], metric: str) -> float | None:
def build_decisions(args: argparse.Namespace) -> dict[str, Any]:
portfolio_path = Path(args.positive_portfolio)
positive_portfolio = read_json(portfolio_path)
background_paths = [Path(path) for path in args.hard_negative_summary]
direct_background_paths = [Path(path) for path in args.hard_negative_summary]
split_summary_paths = [Path(path) for path in args.background_split_summary]
split_background_paths, context_reviews = collect_split_background_inputs(split_summary_paths)
background_paths = unique_paths([*direct_background_paths, *split_background_paths])
positive = collect_positive_runs(
positive_portfolio,
default_tile_size=args.default_positive_tile_size,
@@ -249,6 +327,8 @@ def build_decisions(args: argparse.Namespace) -> dict[str, Any]:
"generated_at": datetime.now(timezone.utc).isoformat(),
"positive_portfolio_path": str(portfolio_path),
"hard_negative_summary_paths": [str(path) for path in background_paths],
"background_split_summary_paths": [str(path) for path in split_summary_paths],
"background_context_reviews": context_reviews,
"gates": {
"min_positive_samples": args.min_positive_samples,
"min_background_samples": args.min_background_samples,
@@ -268,6 +348,7 @@ def write_markdown(report: dict[str, Any], path: Path) -> None:
f"- Generated: {report['generated_at']}",
f"- Positive portfolio: `{report['positive_portfolio_path']}`",
f"- Hard-negative summaries: {len(report['hard_negative_summary_paths'])}",
f"- Background split summaries: {len(report['background_split_summary_paths'])}",
f"- Candidates: {report['candidate_count']}",
"",
"## Gates",
@@ -281,6 +362,25 @@ def write_markdown(report: dict[str, Any], path: Path) -> None:
lines.append(f"- Promote candidate for operator review: `{recommended['candidate_key']}`")
else:
lines.append("- No candidate passed all positive and hard-negative gates.")
if report.get("background_context_reviews"):
lines.extend(
[
"",
"## Sparse-context review evidence",
"",
(
f"- Sparse-context review evidence: {len(report['background_context_reviews'])} "
"(not used as a default-promotion gate)"
),
]
)
for item in report["background_context_reviews"]:
lines.append(
"- "
f"`{item.get('category', SPARSE_CONTEXT_CATEGORY)}` from "
f"`{item.get('source_summary_path', 'unknown')}`; "
f"max detections `{item.get('max_detection_count')}`"
)
lines.extend(["", "## Candidate Decisions", ""])
for item in report["candidate_decisions"]:
reasons = ", ".join(item["rejection_reasons"]) or "none"