diff --git a/backend/tests/test_failure_driven_yolo_sampling.py b/backend/tests/test_failure_driven_yolo_sampling.py index 4a56f854..e613be79 100644 --- a/backend/tests/test_failure_driven_yolo_sampling.py +++ b/backend/tests/test_failure_driven_yolo_sampling.py @@ -207,3 +207,35 @@ def test_region_cap_drops_only_repeats_and_preserves_every_unique_tile() -> None assert metadata["sampled_entries_by_region"]["flanders"] == 7 assert metadata["dropped_region_repeat_count"] == 13 assert metadata["sampled_entries_by_region"]["flanders"] / len(paths) <= .65 + + +def test_coastal_precision_failure_targets_port_and_dunes_negatives() -> None: + manifest = {"samples": [ + {"sample_slug": "coastal-train", "split": "train", "region": "flanders", "context": "coastal-urban"}, + {"sample_slug": "port-negative", "split": "train", "region": "flanders", "context": "port-hard-negative"}, + {"sample_slug": "dunes-negative", "split": "train", "region": "flanders", "context": "dunes-negative"}, + {"sample_slug": "coastal-cal", "split": "calibration", "region": "flanders", "context": "coastal-urban"}, + ]} + summary = {"tiles": [ + {"sample_slug": "coastal-train", "split": "train", "label_count": 2, "image_path": "/tmp/coastal.png"}, + {"sample_slug": "port-negative", "split": "train", "label_count": 0, "image_path": "/tmp/port.png"}, + {"sample_slug": "dunes-negative", "split": "train", "label_count": 0, "image_path": "/tmp/dunes.png"}, + ]} + assessment = { + "status": "continue_training_loop", + "gates": {"min_region_f1": .45, "min_region_precision": .5, "min_region_recall": .4, + "max_pure_empty_false_positives": 0}, + "calibration": { + "regions": {"flanders": {"f1": .3, "precision": .2, "recall": .4}}, + "samples": {"coastal-cal": {"f1": .1, "precision": .05, "recall": .2}}, + }, + } + + paths, metadata = MODULE.build_sampling( + summary=summary, manifest=manifest, assessment=assessment, max_region_share=1.0 + ) + + assert paths.count(str(Path("/tmp/port.png").resolve())) == 6 + assert paths.count(str(Path("/tmp/dunes.png").resolve())) == 6 + assert "flanders:port-hard-negative" in metadata["targeted_negative_contexts"] + assert "flanders:dunes-negative" in metadata["targeted_negative_contexts"] diff --git a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md index e6565c3f..8e62ec22 100644 --- a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md +++ b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md @@ -113,6 +113,12 @@ The deterministic cap removes only repeated entries and retains every unique train tile at least once; manifests record pre-cap counts, final counts and the number of dropped repeats. This keeps a weak region prominent without turning the national detector into a single-region expert. +Precision correction expands failed semantic contexts into related negative +families: coastal urban failures target port/dunes negatives, industrial +failures target industrial/rail/port negatives, and ribbon/rural/regional +architecture failures target their governed farmland, forest or quarry +counterparts. These diagnostic negative repeats are ordered ahead of generic +repeats so the regional cap cannot discard them first. The checkpointed orchestrator invokes this builder after every rejected iteration, stores its checksum in `training-loop-state.json`, and uses the resulting dataset YAML for the next checkpoint. A restart resumes both the diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 407ef8ab..ba2a42b3 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -19,6 +19,11 @@ sampling assigned 75.5% of entries to Flanders. The cap retains every unique tile, removes repeats only and writes pre/post regional counts into the checksummed sampling evidence. +- Added semantic hard-negative families after the v31 calibration isolated + Oostende coastal/port precision as the dominant Flemish error. Coastal, + industrial, ribbon, rural and regional-architecture failures now target + related train-only negative contexts, with diagnostic repeats preserved + ahead of generic repeats when the regional cap applies. ## 2026-07-27 - Guest demo and product professionalization diff --git a/scripts/build_failure_driven_yolo_sampling.py b/scripts/build_failure_driven_yolo_sampling.py index 08eea99e..c2e6a247 100644 --- a/scripts/build_failure_driven_yolo_sampling.py +++ b/scripts/build_failure_driven_yolo_sampling.py @@ -12,6 +12,15 @@ from pathlib import Path from typing import Any +PRECISION_NEGATIVE_CONTEXTS = { + "coastal-urban": {"port-hard-negative", "dunes-negative"}, + "industrial": {"industrial-hard-negative", "rail-hard-negative", "port-hard-negative"}, + "ribbon-development": {"farmland-hard-negative", "forest-hard-negative"}, + "rural-town": {"farmland-hard-negative", "forest-hard-negative", "quarry-hard-negative"}, + "regional-architecture": {"forest-hard-negative", "quarry-hard-negative"}, +} + + def file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: @@ -94,6 +103,12 @@ def build_sampling( and metrics["precision"] < gates["min_region_precision"] ): weak_precision_contexts.add(key) + targeted_negative_contexts = set(weak_precision_contexts) + for region, context in weak_precision_contexts: + targeted_negative_contexts.update( + (region, related) + for related in PRECISION_NEGATIVE_CONTEXTS.get(context, set()) + ) base_paths_by_region: dict[str, list[str]] = {} extra_paths_by_region: dict[str, list[str]] = {} @@ -120,12 +135,19 @@ def build_sampling( if tile["label_count"] == 0 and (background_failed or region in weak_precision_regions): repeat = ( context_negative_repeat - if context_key in weak_precision_contexts + if context_key in targeted_negative_contexts else negative_repeat ) path = str(Path(tile["image_path"]).resolve()) base_paths_by_region.setdefault(region, []).append(path) - extra_paths_by_region.setdefault(region, []).extend([path] * (repeat - 1)) + extras = extra_paths_by_region.setdefault(region, []) + repeated = [path] * (repeat - 1) + if tile["label_count"] == 0 and context_key in targeted_negative_contexts: + # Preserve the most diagnostic hard-negative repeats when the + # regional cap has to remove lower-priority repetition. + extras[:0] = repeated + else: + extras.extend(repeated) selected_samples.add(tile["sample_slug"]) pre_cap_counts = Counter({ @@ -163,6 +185,9 @@ def build_sampling( "weak_precision_regions": sorted(weak_precision_regions), "weak_recall_contexts": [f"{region}:{context}" for region, context in sorted(weak_recall_contexts)], "weak_precision_contexts": [f"{region}:{context}" for region, context in sorted(weak_precision_contexts)], + "targeted_negative_contexts": [ + f"{region}:{context}" for region, context in sorted(targeted_negative_contexts) + ], "background_gate_failed": background_failed, "positive_repeat": positive_repeat, "negative_repeat": negative_repeat,