mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 03:21:14 +00:00
[AZ-307] c6 FreshnessGate: active-conflict reject + stable-rear downgrade
Replaces the AZ-305 pass-through _evaluate_freshness hook with the production FreshnessGate. Loads tile_freshness_rules + sector classifications once at construction, builds an rtree index, and on every evaluate() either returns metadata unchanged (FRESH), stamps freshness_label=DOWNGRADED (stable_rear + stale), or raises FreshnessRejectionError carrying tile_id / age_seconds / classification / rule diagnostics (active_conflict + stale). Constructed inside PostgresFilesystemStore.from_config; the public storage_factory signature is preserved so AZ-305 unit tests still build the store with freshness_gate=None for the pass-through path. FDR schema bumped to v1.2.0: adds c6.freshness.rejected and c6.freshness.downgraded kinds (non-breaking; v1.1 readers route them opaquely). Operator CLI `python -m c6_tile_cache.freshness_gate explain` dry-runs the decision for a (lat, lon, capture_ts). Adjacent hygiene: c6_tile_cache.tools._dump_tile now passes os.environ to load_config (AZ-305 regression — load_config requires the env mapping). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,14 @@ than the in-flight read envelope.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c6_tile_cache._types import (
|
||||
SectorClassification,
|
||||
TileId,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ContentHashMismatchError",
|
||||
"FreshnessRejectionError",
|
||||
@@ -73,8 +81,31 @@ class FreshnessRejectionError(TileCacheError):
|
||||
Raised when the tile's ``(lat, lon)`` falls in an ``ACTIVE_CONFLICT``
|
||||
sector AND ``capture_timestamp < now() - active_conflict_max_age``.
|
||||
See ``tile_metadata_store.md`` Invariant I-2.
|
||||
|
||||
The exception carries the diagnostic fields the AZ-307 freshness
|
||||
gate populates on rejection (AC-8): ``tile_id`` is the rejected
|
||||
tile's spatial identity, ``age_seconds`` is the integer-rounded
|
||||
age at decision time, ``classification`` is the sector
|
||||
classification that drove the reject, and ``rule`` carries the
|
||||
exact ``FreshnessRule`` row that fired. Tests and FDR consumers
|
||||
treat these as part of the public exception surface.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
tile_id: TileId | None = None,
|
||||
age_seconds: int | None = None,
|
||||
classification: SectorClassification | None = None,
|
||||
rule: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.tile_id = tile_id
|
||||
self.age_seconds = age_seconds
|
||||
self.classification = classification
|
||||
self.rule = rule
|
||||
|
||||
|
||||
class IndexUnavailableError(TileCacheError):
|
||||
"""The descriptor index could not satisfy a read.
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
"""C6 freshness gate (AZ-307).
|
||||
|
||||
Replaces the pass-through ``PostgresFilesystemStore._evaluate_freshness``
|
||||
hook AZ-305 ships. Loads the two ``tile_freshness_rules`` rows + every
|
||||
``sector_classifications`` row with a populated bbox at construction;
|
||||
on every :meth:`FreshnessGate.evaluate` call, looks up the sector
|
||||
covering ``(metadata.tile_id.lat, metadata.tile_id.lon)`` via an
|
||||
in-memory ``rtree`` index (sub-microsecond for the few-hundred-sector
|
||||
flights operators ship), picks the smallest-area sector on overlap, and:
|
||||
|
||||
- raises :class:`FreshnessRejectionError` when the rule ``action`` is
|
||||
``"reject"`` and the tile age exceeds ``rule.max_age_seconds``
|
||||
(AC-1, AC-8); the exception carries ``tile_id``, ``age_seconds``,
|
||||
``classification`` and ``rule`` (AC-8);
|
||||
- returns a ``dataclasses.replace`` copy of the metadata with
|
||||
``freshness_label = FreshnessLabel.DOWNGRADED`` when the rule
|
||||
``action`` is ``"downgrade"`` and the tile is stale (AC-3, AC-5);
|
||||
- returns the original ``metadata`` unchanged when the tile is within
|
||||
the rule's ``max_age_seconds`` budget (AC-2, AC-4).
|
||||
|
||||
A tile whose ``(lat, lon)`` falls outside every loaded sector defaults
|
||||
to ``SectorClassification.STABLE_REAR`` (AC-5) — the safer default
|
||||
documented in the spec; operators wanting fail-closed behaviour add an
|
||||
explicit ``ACTIVE_CONFLICT`` whole-world sector.
|
||||
|
||||
Every reject or downgrade emits one FDR record per the
|
||||
``c6.freshness.rejected`` / ``c6.freshness.downgraded`` kinds (AC-9)
|
||||
and one WARN (reject) / INFO (downgrade) log line.
|
||||
|
||||
The gate is constructed ONCE per flight by the composition root after
|
||||
the migration runner has populated ``tile_freshness_rules`` (AZ-304).
|
||||
Sector / rule changes mid-flight require a process restart (Risk 2 of
|
||||
the AZ-307 spec).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import psycopg
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from gps_denied_onboard.components.c6_tile_cache._types import (
|
||||
Bbox,
|
||||
FreshnessLabel,
|
||||
SectorClassification,
|
||||
TileId,
|
||||
TileMetadata,
|
||||
)
|
||||
from gps_denied_onboard.components.c6_tile_cache.errors import (
|
||||
FreshnessRejectionError,
|
||||
TileMetadataError,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
from gps_denied_onboard.fdr_client.records import CURRENT_SCHEMA_VERSION, FdrRecord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.clock.interface import Clock
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
|
||||
__all__ = [
|
||||
"FreshnessGate",
|
||||
"FreshnessRule",
|
||||
]
|
||||
|
||||
|
||||
_PRODUCER_ID: Final[str] = "c6_tile_cache.freshness"
|
||||
_VALID_ACTIONS: Final[frozenset[str]] = frozenset({"reject", "downgrade"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FreshnessRule:
|
||||
"""One row of ``tile_freshness_rules`` (AZ-304).
|
||||
|
||||
``classification`` is the lookup key, ``max_age_seconds`` is the
|
||||
rule's age budget, ``action`` is the policy verb the gate executes
|
||||
when ``tile_age_seconds > max_age_seconds`` (``"reject"`` raises
|
||||
:class:`FreshnessRejectionError`, ``"downgrade"`` stamps
|
||||
``FreshnessLabel.DOWNGRADED``).
|
||||
"""
|
||||
|
||||
classification: SectorClassification
|
||||
max_age_seconds: int
|
||||
action: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.action not in _VALID_ACTIONS:
|
||||
raise ConfigError(
|
||||
f"FreshnessRule.action must be one of {sorted(_VALID_ACTIONS)}; "
|
||||
f"got {self.action!r} (classification={self.classification.value})"
|
||||
)
|
||||
if self.max_age_seconds <= 0:
|
||||
raise ConfigError(
|
||||
f"FreshnessRule.max_age_seconds must be > 0; got {self.max_age_seconds} "
|
||||
f"(classification={self.classification.value})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Sector:
|
||||
"""Loaded sector — bbox + classification + cached area for tie-break."""
|
||||
|
||||
sector_id: str
|
||||
bbox: Bbox
|
||||
classification: SectorClassification
|
||||
|
||||
@property
|
||||
def area_deg2(self) -> float:
|
||||
return (self.bbox.max_lat - self.bbox.min_lat) * (self.bbox.max_lon - self.bbox.min_lon)
|
||||
|
||||
|
||||
def _iso_ts_now() -> str:
|
||||
"""RFC 3339 UTC timestamp with microsecond precision and ``Z`` suffix.
|
||||
|
||||
Used only on the FDR record envelope ``ts`` field, which is wall-clock
|
||||
metadata about WHEN the record was emitted — distinct from the
|
||||
Clock-driven ``age_seconds`` payload which uses the injected clock.
|
||||
"""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
|
||||
|
||||
class FreshnessGate:
|
||||
"""Per-flight freshness gate (AZ-307).
|
||||
|
||||
Construction:
|
||||
|
||||
1. ``SELECT classification, max_age_seconds, action FROM
|
||||
tile_freshness_rules`` — must return exactly the two operator-
|
||||
expected classifications; missing rows or malformed actions raise
|
||||
:class:`ConfigError` at construction (Reliability — fail-fast).
|
||||
2. ``SELECT sector_id, classification, min_lat, min_lon, max_lat,
|
||||
max_lon FROM sector_classifications WHERE min_lat IS NOT NULL
|
||||
AND min_lon IS NOT NULL AND max_lat IS NOT NULL AND max_lon IS
|
||||
NOT NULL`` — rows missing any bbox column are ignored (operators
|
||||
set bbox columns only on sectors they want the gate to police).
|
||||
3. Build the rtree.
|
||||
|
||||
Evaluation:
|
||||
|
||||
- Point-in-rect lookup against the rtree.
|
||||
- Smallest-area tie-break for overlapping sectors (AC-6).
|
||||
- Implicit STABLE_REAR default when the lookup is empty (AC-5).
|
||||
- Idempotent — calling :meth:`evaluate` twice returns equal results
|
||||
(the FDR/log side-effects fire each call, by design).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
postgres_pool: ConnectionPool,
|
||||
fdr_client: FdrClient,
|
||||
logger: logging.Logger,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
self._pool = postgres_pool
|
||||
self._fdr_client = fdr_client
|
||||
self._logger = logger
|
||||
self._clock = clock
|
||||
try:
|
||||
self._rules = self._load_rules()
|
||||
self._sectors = self._load_sectors()
|
||||
except psycopg.Error as exc:
|
||||
raise TileMetadataError(
|
||||
f"FreshnessGate: pool/query error on construction: {exc}"
|
||||
) from exc
|
||||
self._rtree = self._build_rtree(self._sectors)
|
||||
self._logger.info(
|
||||
"c6.freshness.loaded",
|
||||
extra={
|
||||
"kind": "c6.freshness.loaded",
|
||||
"kv": {
|
||||
"n_sectors": len(self._sectors),
|
||||
"rules": {
|
||||
cls.value: {
|
||||
"max_age_seconds": rule.max_age_seconds,
|
||||
"action": rule.action,
|
||||
}
|
||||
for cls, rule in self._rules.items()
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate(self, metadata: TileMetadata) -> TileMetadata:
|
||||
"""Return the (possibly downgraded) metadata or raise.
|
||||
|
||||
See :class:`FreshnessGate` class docstring for the decision table.
|
||||
"""
|
||||
classification = self._classify(metadata.tile_id)
|
||||
rule = self._rules[classification]
|
||||
age_seconds = self._age_seconds(metadata.capture_timestamp)
|
||||
if age_seconds <= rule.max_age_seconds:
|
||||
return metadata
|
||||
if rule.action == "reject":
|
||||
self._emit_rejected(metadata, age_seconds=age_seconds, rule=rule)
|
||||
self._logger.warning(
|
||||
"c6.freshness.rejected",
|
||||
extra={
|
||||
"kind": "c6.freshness.rejected",
|
||||
"kv": {
|
||||
"tile_id_str": str(metadata.tile_id),
|
||||
"age_seconds": age_seconds,
|
||||
"classification": classification.value,
|
||||
"rule_max_age_seconds": rule.max_age_seconds,
|
||||
},
|
||||
},
|
||||
)
|
||||
raise FreshnessRejectionError(
|
||||
f"Tile rejected by freshness gate: tile_id={metadata.tile_id} "
|
||||
f"age_seconds={age_seconds} classification={classification.value} "
|
||||
f"rule.max_age_seconds={rule.max_age_seconds}",
|
||||
tile_id=metadata.tile_id,
|
||||
age_seconds=age_seconds,
|
||||
classification=classification,
|
||||
rule=rule,
|
||||
)
|
||||
# action == "downgrade" — validated by FreshnessRule.__post_init__.
|
||||
self._emit_downgraded(metadata, age_seconds=age_seconds, rule=rule)
|
||||
self._logger.info(
|
||||
"c6.freshness.downgraded",
|
||||
extra={
|
||||
"kind": "c6.freshness.downgraded",
|
||||
"kv": {
|
||||
"tile_id_str": str(metadata.tile_id),
|
||||
"age_seconds": age_seconds,
|
||||
"classification": classification.value,
|
||||
"rule_max_age_seconds": rule.max_age_seconds,
|
||||
},
|
||||
},
|
||||
)
|
||||
return replace(metadata, freshness_label=FreshnessLabel.DOWNGRADED)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction-time DB I/O
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_rules(self) -> Mapping[SectorClassification, FreshnessRule]:
|
||||
"""Read all rows of ``tile_freshness_rules`` and return a frozen dict.
|
||||
|
||||
Missing classifications and unknown action values raise
|
||||
:class:`ConfigError` (fail-fast at construction per Reliability).
|
||||
"""
|
||||
rules: dict[SectorClassification, FreshnessRule] = {}
|
||||
with self._pool.connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT classification, max_age_seconds, action FROM tile_freshness_rules"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for cls_value, max_age, action in rows:
|
||||
try:
|
||||
cls = SectorClassification(cls_value)
|
||||
except ValueError as exc:
|
||||
raise ConfigError(
|
||||
f"FreshnessGate: unknown sector classification {cls_value!r} in "
|
||||
f"tile_freshness_rules"
|
||||
) from exc
|
||||
rules[cls] = FreshnessRule(
|
||||
classification=cls,
|
||||
max_age_seconds=int(max_age),
|
||||
action=str(action),
|
||||
)
|
||||
for cls in SectorClassification:
|
||||
if cls not in rules:
|
||||
raise ConfigError(
|
||||
f"FreshnessGate: tile_freshness_rules is missing a row for "
|
||||
f"classification={cls.value!r}; run the AZ-304 migration before "
|
||||
"constructing the gate"
|
||||
)
|
||||
return dict(rules)
|
||||
|
||||
def _load_sectors(self) -> list[_Sector]:
|
||||
"""Read every ``sector_classifications`` row with a populated bbox."""
|
||||
sectors: list[_Sector] = []
|
||||
with self._pool.connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT sector_id, classification, min_lat, min_lon, max_lat, max_lon "
|
||||
"FROM sector_classifications "
|
||||
"WHERE min_lat IS NOT NULL AND min_lon IS NOT NULL "
|
||||
"AND max_lat IS NOT NULL AND max_lon IS NOT NULL"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for sector_id, cls_value, min_lat, min_lon, max_lat, max_lon in rows:
|
||||
try:
|
||||
cls = SectorClassification(cls_value)
|
||||
except ValueError as exc:
|
||||
raise ConfigError(
|
||||
f"FreshnessGate: sector_id={sector_id!r} has unknown classification "
|
||||
f"{cls_value!r}"
|
||||
) from exc
|
||||
try:
|
||||
bbox = Bbox(
|
||||
min_lat=float(min_lat),
|
||||
min_lon=float(min_lon),
|
||||
max_lat=float(max_lat),
|
||||
max_lon=float(max_lon),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ConfigError(
|
||||
f"FreshnessGate: sector_id={sector_id!r} has invalid bbox: {exc}"
|
||||
) from exc
|
||||
sectors.append(_Sector(sector_id=str(sector_id), bbox=bbox, classification=cls))
|
||||
return sectors
|
||||
|
||||
@staticmethod
|
||||
def _build_rtree(sectors: list[_Sector]) -> Any:
|
||||
# Local import — keeps `import gps_denied_onboard.components.c6_tile_cache`
|
||||
# cheap when the gate is not used in that runtime path (e.g. unit
|
||||
# tests for sibling modules that never construct the gate).
|
||||
from rtree import index
|
||||
|
||||
prop = index.Property()
|
||||
prop.dimension = 2
|
||||
rtree_index = index.Index(properties=prop, interleaved=True)
|
||||
for i, sector in enumerate(sectors):
|
||||
# rtree.interleaved=True expects (min_x, min_y, max_x, max_y);
|
||||
# we encode (min_lon, min_lat, max_lon, max_lat).
|
||||
rtree_index.insert(
|
||||
i,
|
||||
(
|
||||
sector.bbox.min_lon,
|
||||
sector.bbox.min_lat,
|
||||
sector.bbox.max_lon,
|
||||
sector.bbox.max_lat,
|
||||
),
|
||||
)
|
||||
return rtree_index
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _classify(self, tile_id: TileId) -> SectorClassification:
|
||||
"""Look up the smallest-area sector covering ``tile_id``.
|
||||
|
||||
Returns :attr:`SectorClassification.STABLE_REAR` when no sector
|
||||
contains the point (AC-5 implicit default).
|
||||
"""
|
||||
point_bbox = (tile_id.lon, tile_id.lat, tile_id.lon, tile_id.lat)
|
||||
candidate_ids = list(self._rtree.intersection(point_bbox))
|
||||
if not candidate_ids:
|
||||
return SectorClassification.STABLE_REAR
|
||||
best: _Sector | None = None
|
||||
for i in candidate_ids:
|
||||
sector = self._sectors[i]
|
||||
if not _bbox_contains_point(sector.bbox, tile_id.lat, tile_id.lon):
|
||||
# rtree.intersection can return touch-only bboxes (the
|
||||
# query is closed on both ends); double-check the geometric
|
||||
# containment so the smallest-area tie-break is precise.
|
||||
continue
|
||||
if best is None or sector.area_deg2 < best.area_deg2:
|
||||
best = sector
|
||||
return best.classification if best is not None else SectorClassification.STABLE_REAR
|
||||
|
||||
def _age_seconds(self, capture_timestamp: datetime) -> int:
|
||||
now_dt = datetime.fromtimestamp(self._clock.time_ns() / 1_000_000_000, tz=timezone.utc)
|
||||
return int((now_dt - capture_timestamp).total_seconds())
|
||||
|
||||
def _emit_rejected(
|
||||
self, metadata: TileMetadata, *, age_seconds: int, rule: FreshnessRule
|
||||
) -> None:
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_now(),
|
||||
producer_id=_PRODUCER_ID,
|
||||
kind="c6.freshness.rejected",
|
||||
payload={
|
||||
"tile_id": str(metadata.tile_id),
|
||||
"age_seconds": age_seconds,
|
||||
"classification": rule.classification.value,
|
||||
"rule_action": rule.action,
|
||||
"rule_max_age_seconds": rule.max_age_seconds,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _emit_downgraded(
|
||||
self, metadata: TileMetadata, *, age_seconds: int, rule: FreshnessRule
|
||||
) -> None:
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_now(),
|
||||
producer_id=_PRODUCER_ID,
|
||||
kind="c6.freshness.downgraded",
|
||||
payload={
|
||||
"tile_id": str(metadata.tile_id),
|
||||
"age_seconds": age_seconds,
|
||||
"classification": rule.classification.value,
|
||||
"rule_action": rule.action,
|
||||
"rule_max_age_seconds": rule.max_age_seconds,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _bbox_contains_point(bbox: Bbox, lat: float, lon: float) -> bool:
|
||||
"""Closed-on-min, closed-on-max point-in-rect test for sector lookup.
|
||||
|
||||
Sector boundaries are inclusive on both ends here so an operator
|
||||
placing a tile exactly on the edge gets a deterministic classification
|
||||
rather than an implicit STABLE_REAR fallback.
|
||||
"""
|
||||
return bbox.min_lat <= lat <= bbox.max_lat and bbox.min_lon <= lon <= bbox.max_lon
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Operator CLI — `python -m c6_tile_cache.freshness_gate explain ...`
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="c6_tile_cache.freshness_gate",
|
||||
description=(
|
||||
"Operator-side dry-run of the freshness gate. Constructs the gate "
|
||||
"from the active config and reports the classification + decision "
|
||||
"the gate would make for a single (lat, lon, capture_iso) triple."
|
||||
),
|
||||
)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
explain = sub.add_parser(
|
||||
"explain",
|
||||
help="Print the classification + decision for a (lat, lon, capture_iso).",
|
||||
)
|
||||
explain.add_argument("lat", type=float, help="Tile latitude in degrees [-90, 90].")
|
||||
explain.add_argument("lon", type=float, help="Tile longitude in degrees [-180, 180].")
|
||||
explain.add_argument(
|
||||
"capture_iso",
|
||||
type=str,
|
||||
help="Capture timestamp in ISO-8601 UTC (e.g. 2024-01-15T12:34:56Z).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _parse_iso(s: str) -> datetime:
|
||||
# `fromisoformat` accepts the trailing 'Z' starting in Python 3.11; we
|
||||
# support 3.10 by normalising the Zulu suffix to '+00:00' first.
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _explain(args: argparse.Namespace) -> int:
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.components.c6_tile_cache._types import (
|
||||
TileSource,
|
||||
VotingStatus,
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
from gps_denied_onboard.fdr_client.client import make_fdr_client
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
config = load_config(os.environ)
|
||||
block = config.components["c6_tile_cache"]
|
||||
dsn = block.postgres_dsn
|
||||
if not dsn:
|
||||
raise TileMetadataError(
|
||||
"freshness_gate.explain: no DSN — set "
|
||||
"config.components['c6_tile_cache'].postgres_dsn or the DB_URL env var"
|
||||
)
|
||||
pool = ConnectionPool(
|
||||
dsn,
|
||||
min_size=1,
|
||||
max_size=block.postgres_pool_size,
|
||||
open=True,
|
||||
kwargs={"autocommit": False},
|
||||
)
|
||||
gate = FreshnessGate(
|
||||
postgres_pool=pool,
|
||||
fdr_client=make_fdr_client(_PRODUCER_ID, config),
|
||||
logger=get_logger(_PRODUCER_ID),
|
||||
clock=WallClock(),
|
||||
)
|
||||
|
||||
capture_ts = _parse_iso(args.capture_iso)
|
||||
if capture_ts.tzinfo is None:
|
||||
capture_ts = capture_ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Synthesise just enough metadata to drive the gate — `evaluate` reads
|
||||
# `tile_id.lat/lon` + `capture_timestamp` + `freshness_label` only.
|
||||
metadata = TileMetadata(
|
||||
tile_id=TileId(zoom_level=20, lat=args.lat, lon=args.lon),
|
||||
tile_size_meters=1.0,
|
||||
tile_size_pixels=256,
|
||||
capture_timestamp=capture_ts,
|
||||
source=TileSource.GOOGLEMAPS,
|
||||
content_sha256_hex="0" * 64,
|
||||
freshness_label=FreshnessLabel.FRESH,
|
||||
flight_id=None,
|
||||
companion_id=None,
|
||||
quality_metadata=None,
|
||||
voting_status=VotingStatus.TRUSTED,
|
||||
)
|
||||
classification = gate._classify(metadata.tile_id)
|
||||
age = gate._age_seconds(metadata.capture_timestamp)
|
||||
rule = gate._rules[classification]
|
||||
print(f"classification: {classification.value}")
|
||||
print(f"rule.max_age_seconds: {rule.max_age_seconds}")
|
||||
print(f"rule.action: {rule.action}")
|
||||
print(f"age_seconds: {age}")
|
||||
if age <= rule.max_age_seconds:
|
||||
decision = "FRESH (passes unchanged)"
|
||||
elif rule.action == "reject":
|
||||
decision = "REJECT (FreshnessRejectionError would be raised)"
|
||||
else:
|
||||
decision = "DOWNGRADE (freshness_label -> DOWNGRADED)"
|
||||
print(f"decision: {decision}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "explain":
|
||||
return _explain(args)
|
||||
parser.error(f"unknown subcommand {args.cmd!r}")
|
||||
return 2 # unreachable; argparse exits non-zero on error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -69,6 +69,7 @@ from gps_denied_onboard.components.c6_tile_cache.errors import (
|
||||
TileMetadataError,
|
||||
TileNotFoundError,
|
||||
)
|
||||
from gps_denied_onboard.components.c6_tile_cache.freshness_gate import FreshnessGate
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
@@ -180,6 +181,7 @@ class PostgresFilesystemStore:
|
||||
wgs_converter: type[WgsConverter],
|
||||
fdr_client: FdrClient,
|
||||
logger: logging.Logger,
|
||||
freshness_gate: FreshnessGate | None = None,
|
||||
) -> None:
|
||||
self._root_dir = Path(root_dir)
|
||||
self._tiles_dir = self._root_dir / "tiles"
|
||||
@@ -188,6 +190,10 @@ class PostgresFilesystemStore:
|
||||
self._wgs_converter = wgs_converter
|
||||
self._fdr_client = fdr_client
|
||||
self._logger = logger
|
||||
# AZ-307: optional freshness gate replaces the pass-through hook.
|
||||
# ``None`` keeps the AZ-305-only test path working (no gate wiring
|
||||
# required for unit tests of the store in isolation).
|
||||
self._freshness_gate = freshness_gate
|
||||
try:
|
||||
self._tiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
@@ -215,7 +221,15 @@ class PostgresFilesystemStore:
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: Config) -> PostgresFilesystemStore:
|
||||
"""Composition-root convenience: build pool + FdrClient + logger from config."""
|
||||
"""Composition-root convenience: build pool + FdrClient + logger from config.
|
||||
|
||||
AZ-307: also constructs a :class:`FreshnessGate` against the same
|
||||
pool and a producer-local :class:`FdrClient` (``producer_id=
|
||||
"c6_tile_cache.freshness"``), then injects it into ``__init__``.
|
||||
The gate is the production path; unit tests can still construct
|
||||
``PostgresFilesystemStore`` directly with ``freshness_gate=None``.
|
||||
"""
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.fdr_client.client import make_fdr_client
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
@@ -240,6 +254,12 @@ class PostgresFilesystemStore:
|
||||
) from exc
|
||||
fdr_client = make_fdr_client(_PRODUCER_ID, config)
|
||||
logger = get_logger(_PRODUCER_ID)
|
||||
freshness_gate = FreshnessGate(
|
||||
postgres_pool=pool,
|
||||
fdr_client=make_fdr_client("c6_tile_cache.freshness", config),
|
||||
logger=get_logger("c6_tile_cache.freshness"),
|
||||
clock=WallClock(),
|
||||
)
|
||||
return cls(
|
||||
root_dir=Path(block.root_dir),
|
||||
postgres_pool=pool,
|
||||
@@ -247,6 +267,7 @@ class PostgresFilesystemStore:
|
||||
wgs_converter=WgsConverter,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger,
|
||||
freshness_gate=freshness_gate,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -340,11 +361,13 @@ class PostgresFilesystemStore:
|
||||
f"declared {metadata.content_sha256_hex}, computed {actual_hash}"
|
||||
)
|
||||
|
||||
# Freshness gate hook — pass-through impl in this task. The
|
||||
# freshness-gate task replaces _evaluate_freshness; it may raise
|
||||
# FreshnessRejectionError, which propagates to write_tile's
|
||||
# except-FreshnessRejectionError arm above.
|
||||
self._evaluate_freshness(metadata)
|
||||
# Freshness gate (AZ-307): may raise FreshnessRejectionError, which
|
||||
# propagates to write_tile's except-FreshnessRejectionError arm
|
||||
# above; may return a `dataclasses.replace` copy with
|
||||
# `freshness_label=DOWNGRADED` which we adopt for the row insert
|
||||
# so the row reflects the policy outcome, not the caller-declared
|
||||
# label.
|
||||
metadata = self._evaluate_freshness(metadata)
|
||||
|
||||
tile_x, tile_y = self._tile_xy(metadata.tile_id)
|
||||
path = self._tile_path(metadata.tile_id.zoom_level, tile_x, tile_y)
|
||||
@@ -707,16 +730,23 @@ class PostgresFilesystemStore:
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _evaluate_freshness(self, metadata: TileMetadata) -> FreshnessLabel:
|
||||
"""Freshness-gate hook — trivial pass-through (replaced by AZ-307).
|
||||
def _evaluate_freshness(self, metadata: TileMetadata) -> TileMetadata:
|
||||
"""Freshness-gate hook (AZ-305 + AZ-307).
|
||||
|
||||
AZ-307 (``c6_freshness_gate``) overrides this method to read the
|
||||
``tile_freshness_rules`` table + the sector classification and
|
||||
raise :class:`FreshnessRejectionError` for active-conflict-stale
|
||||
inserts. For now the pass-through preserves the caller-declared
|
||||
label so AZ-305's tests are independent of the gate logic.
|
||||
When the constructor was given a :class:`FreshnessGate`, delegate
|
||||
to :meth:`FreshnessGate.evaluate` which may:
|
||||
- return ``metadata`` unchanged (FRESH);
|
||||
- return a ``dataclasses.replace`` copy with
|
||||
``freshness_label=FreshnessLabel.DOWNGRADED`` (stable_rear-stale);
|
||||
- raise :class:`FreshnessRejectionError` (active_conflict-stale).
|
||||
|
||||
With no gate (the AZ-305-only unit-test wiring), this is a
|
||||
pass-through that returns the caller-declared metadata so the
|
||||
store can be exercised in isolation.
|
||||
"""
|
||||
return metadata.freshness_label
|
||||
if self._freshness_gate is not None:
|
||||
return self._freshness_gate.evaluate(metadata)
|
||||
return metadata
|
||||
|
||||
def _tile_xy(self, tile_id: TileId) -> tuple[int, int]:
|
||||
return self._wgs_converter.latlon_to_tile_xy(tile_id.zoom_level, tile_id.lat, tile_id.lon)
|
||||
|
||||
@@ -55,7 +55,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
|
||||
def _dump_tile(zoom: int, lat: float, lon: float, output: Path | None) -> int:
|
||||
config = load_config()
|
||||
config = load_config(os.environ)
|
||||
store = PostgresFilesystemStore.from_config(config)
|
||||
tile_id = TileId(zoom_level=zoom, lat=lat, lon=lon)
|
||||
handle = store.read_tile_pixels(tile_id)
|
||||
|
||||
@@ -127,6 +127,24 @@ KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
|
||||
# `message` is the rewrapped exception's str (truncated to keep the
|
||||
# record inline).
|
||||
"c6.write_failed": frozenset({"tile_id", "source", "reason", "error_class", "message"}),
|
||||
# AZ-307 / E-C6: emitted by the FreshnessGate on every reject decision
|
||||
# (active_conflict sector + tile_age > rule.max_age_seconds).
|
||||
# `tile_id` is the canonical UUIDv5; `age_seconds` is the integer-rounded
|
||||
# `(now - capture_timestamp).total_seconds()`; `classification` is the
|
||||
# `SectorClassification` enum value that drove the decision;
|
||||
# `rule_action` ("reject") and `rule_max_age_seconds` document which
|
||||
# rule fired so FDR consumers reproduce the decision without joining
|
||||
# back to `tile_freshness_rules`.
|
||||
"c6.freshness.rejected": frozenset(
|
||||
{"tile_id", "age_seconds", "classification", "rule_action", "rule_max_age_seconds"}
|
||||
),
|
||||
# AZ-307 / E-C6: emitted on every downgrade decision (stable_rear sector
|
||||
# — explicit or implicit-default — + tile_age > rule.max_age_seconds).
|
||||
# Same payload shape as `c6.freshness.rejected` so reject/downgrade
|
||||
# traces are line-for-line comparable. `rule_action` is "downgrade".
|
||||
"c6.freshness.downgraded": frozenset(
|
||||
{"tile_id", "age_seconds", "classification", "rule_action", "rule_max_age_seconds"}
|
||||
),
|
||||
}
|
||||
|
||||
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())
|
||||
|
||||
Reference in New Issue
Block a user