#!/usr/bin/env bash set -euo pipefail usage() { cat >&2 <<'EOF' Usage: PROMOTION_POSITIVE_PORTFOLIO_PATH=artifacts/detection-quality-matrix/multi-sample/aoi1024-positive/multi_sample_quality_summary.json \ OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/operator_samples_manifest.json \ QUALITY_MODEL_ASSET_IDS="geointel-building-yolov8s-aoi1024bg512r3e50-pt" \ QUALITY_TILE_SIZES="512" \ QUALITY_TILE_OVERLAPS="64" \ QUALITY_THRESHOLDS="0.35 0.15" \ bash scripts/run_split_background_promotion_workflow.sh [--preflight-only] [base_url] Optional environment: BACKGROUND_SPLIT_OUTPUT_DIR Split matrix output directory. PROMOTION_OUTPUT_DIR Promotion report output directory. PROMOTION_POSITIVE_PORTFOLIO_PATH Positive AOI portfolio or multi-sample summary. PROMOTION_MIN_POSITIVE_SAMPLES Default: 7. PROMOTION_MIN_BACKGROUND_SAMPLES Default: 2. PROMOTION_MIN_MEAN_F1 Default: 0.25. PROMOTION_MAX_BACKGROUND_DETECTIONS_PER_SAMPLE Default: 0. PROMOTION_DEFAULT_POSITIVE_TILE_SIZE Optional fallback for legacy positive portfolios. PROMOTION_DEFAULT_POSITIVE_TILE_OVERLAP Optional fallback for legacy positive portfolios. GE_INTEL_BASE_URL Default base URL when no argument is supplied. Runs the split background matrix, then builds a split-aware promotion report. The pure-empty background summary is the strict default gate. Sparse-context background evidence is included for operator review only. The script uses existing runtime/API flows and report tooling only. It does not fetch providers, fetch weights, mutate model settings or activate a default. EOF } ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" BASE_URL="${GE_INTEL_BASE_URL:-http://localhost:1202}" PREFLIGHT_ONLY=0 while [ "$#" -gt 0 ]; do case "$1" in -h|--help) usage exit 0 ;; --preflight-only) PREFLIGHT_ONLY=1 ;; *) BASE_URL="$1" ;; esac shift done if [ -z "${PROMOTION_POSITIVE_PORTFOLIO_PATH:-}" ]; then echo "PROMOTION_POSITIVE_PORTFOLIO_PATH is required" >&2 usage exit 1 fi if [ ! -s "${PROMOTION_POSITIVE_PORTFOLIO_PATH}" ]; then echo "Positive portfolio is not readable: ${PROMOTION_POSITIVE_PORTFOLIO_PATH}" >&2 exit 1 fi if [ -z "${OPERATOR_SAMPLE_MANIFEST_PATH:-}" ]; then echo "OPERATOR_SAMPLE_MANIFEST_PATH is required" >&2 usage exit 1 fi if [ ! -s "${OPERATOR_SAMPLE_MANIFEST_PATH}" ]; then echo "Operator sample manifest is not readable: ${OPERATOR_SAMPLE_MANIFEST_PATH}" >&2 exit 1 fi if [ -n "${PYTHON_BIN:-}" ]; then PYTHON_BIN="${PYTHON_BIN}" else PYTHON_BIN="" for candidate in python3 python.exe python; do if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c "import json, sys" >/dev/null 2>&1; then PYTHON_BIN="${candidate}" break fi done fi if [ -z "${PYTHON_BIN}" ]; then echo "A Python interpreter is required for split-background promotion reporting" >&2 exit 1 fi if ! command -v curl >/dev/null 2>&1; then echo "curl is required for split-background promotion preflight" >&2 exit 1 fi "${PYTHON_BIN}" - "${OPERATOR_SAMPLE_MANIFEST_PATH}" <<'PY' import json import sys from pathlib import Path manifest_path = Path(sys.argv[1]) payload = json.loads(manifest_path.read_text(encoding="utf-8-sig")) 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 = 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( "Operator sample manifest is missing background categories " f"{sorted(missing)}; found {sorted(background_categories)}" ) PY api_response="$(curl -fsS "${BASE_URL%/}/api/v1/projects" 2>/dev/null || true)" if ! printf '%s' "${api_response}" | grep -q '"data"'; then echo "Runtime API proxy is not reachable or did not return the canonical envelope: ${BASE_URL%/}/api/v1/projects" >&2 exit 1 fi if [ "${PREFLIGHT_ONLY}" = "1" ]; then echo "Split-background promotion preflight passed" echo "Base URL: ${BASE_URL}" echo "Positive portfolio: ${PROMOTION_POSITIVE_PORTFOLIO_PATH}" echo "Operator sample manifest: ${OPERATOR_SAMPLE_MANIFEST_PATH}" exit 0 fi stamp="$(date -u +%Y%m%dT%H%M%SZ)" BACKGROUND_SPLIT_OUTPUT_DIR="${BACKGROUND_SPLIT_OUTPUT_DIR:-artifacts/detection-hard-negatives/background-split/${stamp}}" PROMOTION_OUTPUT_DIR="${PROMOTION_OUTPUT_DIR:-artifacts/detection-model-promotion/split-aware/${stamp}}" echo "== GeoIntel split-background promotion workflow ==" echo "Base URL: ${BASE_URL}" echo "Positive portfolio: ${PROMOTION_POSITIVE_PORTFOLIO_PATH}" echo "Background split output: ${BACKGROUND_SPLIT_OUTPUT_DIR}" echo "Promotion output: ${PROMOTION_OUTPUT_DIR}" BACKGROUND_SPLIT_OUTPUT_DIR="${BACKGROUND_SPLIT_OUTPUT_DIR}" \ bash scripts/run_background_corpus_split_matrix.sh "${BASE_URL}" split_summary="${BACKGROUND_SPLIT_OUTPUT_DIR}/background_corpus_split_summary.json" if [ ! -s "${split_summary}" ]; then echo "Split summary was not produced: ${split_summary}" >&2 exit 1 fi mkdir -p "${PROMOTION_OUTPUT_DIR}" promotion_args=( scripts/build_detection_model_promotion_report.py --positive-portfolio "${PROMOTION_POSITIVE_PORTFOLIO_PATH}" --background-split-summary "${split_summary}" --output-dir "${PROMOTION_OUTPUT_DIR}" --min-positive-samples "${PROMOTION_MIN_POSITIVE_SAMPLES:-7}" --min-background-samples "${PROMOTION_MIN_BACKGROUND_SAMPLES:-2}" --min-mean-f1 "${PROMOTION_MIN_MEAN_F1:-0.25}" --max-background-detections-per-sample "${PROMOTION_MAX_BACKGROUND_DETECTIONS_PER_SAMPLE:-0}" ) if [ -n "${PROMOTION_DEFAULT_POSITIVE_TILE_SIZE:-}" ]; then promotion_args+=(--default-positive-tile-size "${PROMOTION_DEFAULT_POSITIVE_TILE_SIZE}") fi if [ -n "${PROMOTION_DEFAULT_POSITIVE_TILE_OVERLAP:-}" ]; then promotion_args+=(--default-positive-tile-overlap "${PROMOTION_DEFAULT_POSITIVE_TILE_OVERLAP}") fi "${PYTHON_BIN}" "${promotion_args[@]}" echo "Split summary: ${split_summary}" echo "Promotion report: ${PROMOTION_OUTPUT_DIR}/detection_model_promotion_report.json"