"""Age-injector for the tile-cache fixture. Clones a ``tile-cache-fixture`` tree and mutates ONLY the manifest's ``capture_date`` column (and the per-tile sidecar JSON's matching field). Tile JPEG bodies are copied bit-identical. AC-3 (AZ-407): given target=7mo, every row's ``capture_date`` becomes ``now - 7 mo`` ± 1 day, exceeding the AC-8.2 active-conflict 6-month threshold. Given target=13mo, every row's ``capture_date`` becomes ``now - 13 mo`` ± 1 day, exceeding the rear 12-month threshold. Used by FT-N-05 / FT-N-06 (stale-tile rejection on freshness violation). Public-boundary discipline: this module does NOT import any ``src/gps_denied_onboard`` symbol. The freshness contract lives in ``_docs/00_problem/restrictions.md`` § Satellite Imagery (AC-8.2). """ from __future__ import annotations import argparse import csv import datetime as _dt import json import logging import shutil import sys from pathlib import Path logger = logging.getLogger(__name__) # 30.44 days/month average — gives `now - N*30 days ± 1 day`, which the # AC's "±1 day" tolerance accepts. _DAYS_PER_MONTH = 30.44 _MANIFEST_HEADERS = ( "zoom_level", "tile_x", "tile_y", "capture_date", "source", "m_per_px", "jpeg_path", "content_hash", "provenance", ) def _shifted_date(now: _dt.date, age_months: int) -> str: delta_days = int(round(age_months * _DAYS_PER_MONTH)) return (now - _dt.timedelta(days=delta_days)).isoformat() def inject( source_dir: Path, output_dir: Path, age_months: int, now: _dt.date | None = None, ) -> dict: """Clone ``source_dir`` into ``output_dir`` and mutate dates. Returns a summary dict: {"row_count": int, "shifted_date": "YYYY-MM-DD", "source_dir": str} """ if age_months <= 0: raise ValueError(f"age_months must be positive; got {age_months}") if now is None: now = _dt.datetime.now(tz=_dt.timezone.utc).date() if output_dir.exists(): shutil.rmtree(output_dir) output_dir.mkdir(parents=True) # Phase 1: clone the tile tree. Pixels copy bit-identical. src_tiles = source_dir / "tiles" if not src_tiles.is_dir(): raise FileNotFoundError( f"{source_dir} does not look like a tile-cache fixture " "(no `tiles/` subdir)" ) shutil.copytree(src_tiles, output_dir / "tiles") shifted = _shifted_date(now, age_months) # Phase 2: mutate per-tile sidecar JSON files. sidecar_count = 0 for sidecar in sorted((output_dir / "tiles").rglob("*.json")): data = json.loads(sidecar.read_text()) data["capture_date"] = shifted sidecar.write_text( json.dumps(data, sort_keys=True, separators=(",", ":")) + "\n" ) sidecar_count += 1 # Phase 3: re-emit manifest.csv with shifted dates. Row order is # preserved (the source manifest is already sorted by builder.py). src_manifest = source_dir / "manifest.csv" if not src_manifest.is_file(): raise FileNotFoundError(f"missing manifest.csv at {src_manifest}") with src_manifest.open() as fp: reader = csv.DictReader(fp) if tuple(reader.fieldnames or ()) != _MANIFEST_HEADERS: raise ValueError( f"unexpected manifest schema: {reader.fieldnames} " f"(expected {list(_MANIFEST_HEADERS)})" ) rows = list(reader) out_manifest = output_dir / "manifest.csv" with out_manifest.open("w", newline="") as fp: writer = csv.writer(fp, lineterminator="\n") writer.writerow(_MANIFEST_HEADERS) for r in rows: writer.writerow( [ r["zoom_level"], r["tile_x"], r["tile_y"], shifted, r["source"], r["m_per_px"], r["jpeg_path"], r["content_hash"], r["provenance"], ] ) # Phase 4: passthrough the descriptors.index if present (FAISS file # is independent of capture_date; copy bit-identical). src_index = source_dir / "descriptors.index" if src_index.is_file(): shutil.copyfile(src_index, output_dir / "descriptors.index") return { "row_count": len(rows), "sidecar_count": sidecar_count, "shifted_date": shifted, "source_dir": str(source_dir), } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Age-inject the tile-cache fixture") parser.add_argument( "--source-dir", type=Path, required=True, help="Path to the source tile-cache-fixture tree", ) parser.add_argument( "--output-dir", type=Path, required=True, help="Path to the aged output tree", ) parser.add_argument( "--age-months", type=int, required=True, help="Shift capture_date by this many months into the past", ) args = parser.parse_args(argv) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) summary = inject(args.source_dir, args.output_dir, args.age_months) json.dump(summary, sys.stdout, sort_keys=True, indent=2) sys.stdout.write("\n") return 0 if __name__ == "__main__": raise SystemExit(main())