meshai/meshai/notifications/pipeline/inhibitor.py
Matt Johnson (via Claude) 914d38c907 feat(v0.6-3b): wire every handler to adapter_config + v7.sql firms dedup_key column
Replaces module-level magic numbers in 12 handlers with reads via the
v0.6-3a.1 typed accessor. Every default matches the prior hardcoded
value exactly, so first-deploy behavior is unchanged.

Handlers wired (43 keys across the 43-row registry):

  wfigs            cooldown_seconds, anchor_max_mi, broadcast_on_acres,
                   broadcast_on_contained
  nws              broadcast_severities, tombstone_msgtypes,
                   warning_suffix_promotes
  usgs_quake       regional_centroid, regional_radius_mi,
                   broadcast_pager_alerts, global_mag_floor,
                   regional_mag_floor, escalate_mag_floor
  swpc             geomag_kp_floor (extends G-scale down to Kp 5 when
                                    lowered), flare_class_floor (R-scale
                                    extended to M-class when lowered),
                   proton_pfu_floor
  usgs_nwis        parameter_codes, broadcast_on_recede
  incident         freshness_seconds, broadcast_on_update (Update path
                                    re-implemented when toggled True:
                                    magnitude step-up / delay doubling /
                                    icon_category change)
  tomtom_incidents drop_zero_magnitude, drop_non_present
  state_511_atis   skipped_states (case-insensitive match against both
                                    state_code and primary_region suffix)
  central          severity_thresholds (immediate_min check ordered before
                                    priority_max so the +inf clamp still
                                    works)
  dispatcher       dedup_lru_max, cooldown_prune_size,
                   cooldown_prune_multiplier, dedup_db_retention_days
  band_conditions  swpc_freshness_seconds, hamqsl_url, hamqsl_timeout_s
  geocoder         (photon_url/timeout/radius/limit/town_osm_values/
                   h3_cache_max -- module-level constants kept as
                   backward-compat aliases; runtime reads via accessor)
  pipeline         Inhibitor.ttl_seconds + Grouper.window_seconds now
                   default to None, falling back to
                   adapter_config.pipeline.{inhibitor_ttl_seconds,
                   grouper_window_seconds}. Explicit constructor values
                   still win (test fixtures unchanged).
  firms            confidence_floor, frp_floor, bbox, dedup_distance_m

Schema:
  v7.sql adds firms_pixels.dedup_key column + drops the old hardcoded
  round(lat,5) UNIQUE INDEX, replaces with UNIQUE (dedup_key, acq_time,
  satellite). The firms_handler quantizes lat/lon to
  (dedup_distance_m / 111000) degrees at INSERT time -- meters-based
  precision per Matt s spec, tunable via the GUI without schema changes.

  SCHEMA_VERSION 6 -> 7. firms_pixels has 0 rows in production so no
  backfill needed.

CODE preserved (Matt s rule): sentence templates, emoji literals, the
TomTom icon_map / ITD sub_type_map / Central adapter_map / category_map
translation tables, the band_conditions Kp/SFI -> Good/Fair/Poor
heuristic, anchor-priority ordering, expires-bucket boundaries, the
NOAA G/R/S scale tables. None of these reach the GUI.

Hot-path performance: every accessor read hits the in-memory cache after
the first call; cache hit is one dict get. Per-event reads (e.g. WFIGS
cooldown_seconds on every WFIGS poll-cycle) add a single dict lookup
to existing pipelines.

Backward-compat aliases retained for module-level imports that exist in
test code: WFIGS_BROADCAST_COOLDOWN_S, FIRMS_CONFIDENCE_FLOOR,
FIRMS_FRP_FLOOR, FIRMS_BBOX_OPTIONAL, INCIDENT_FRESHNESS_MAX_S,
PHOTON_BASE_URL/TIMEOUT_S/RADIUS_KM/LIMIT. Handler code reads via
adapter_config; tests can either monkeypatch the module attribute (firms)
or mutate adapter_config DB values.

Test count: 731 -> 731 (no new tests in 3b -- handler wiring is a pure
refactor; coverage comes from the existing handler test suites passing
unchanged).

Refs audit doc v0.6-phase1-audit.md Section A + Matt s CONFIG-vs-CODE rule.
2026-06-05 18:38:21 +00:00

97 lines
3.4 KiB
Python

"""Severity-based event inhibitor.
Suppresses lower-severity events when a higher-severity event for the
same logical incident (matching inhibit_keys) is already active.
Inhibit keys are operator-defined strings on the Event that identify
the underlying incident. Two events sharing an inhibit_key refer to
the same situation. If a critical event for "battery:BLD-MTN" fired
recently, a subsequent warning event with the same key gets suppressed.
"""
import logging
import time
from typing import Callable
from meshai.notifications.events import Event
class Inhibitor:
"""Suppress lower-severity events when higher-severity is active."""
SEVERITY_RANK = {"routine": 0, "priority": 1, "immediate": 2}
def __init__(
self,
next_handler: Callable[[Event], None],
ttl_seconds: float | None = None,
):
"""Initialize.
Args:
next_handler: Callable that receives non-suppressed events.
ttl_seconds: How long an inhibit_key remains active after
the originating event. None -> read from
adapter_config.pipeline.inhibitor_ttl_seconds (default
1800). v0.6-3b: explicit value still wins for tests.
"""
self._next = next_handler
if ttl_seconds is None:
from meshai.adapter_config import adapter_config
ttl_seconds = float(adapter_config.pipeline.inhibitor_ttl_seconds)
self._ttl = ttl_seconds
# {inhibit_key: (rank, expires_at)}
self._active: dict[str, tuple[int, float]] = {}
self._logger = logging.getLogger("meshai.pipeline.inhibitor")
def _now(self) -> float:
# Hookable for tests
return time.time()
def _prune_expired(self, now: float) -> None:
expired = [k for k, (_, exp) in self._active.items() if exp <= now]
for k in expired:
del self._active[k]
def handle(self, event: Event) -> None:
"""Process an event: either suppress it or pass it on.
If any of the event's inhibit_keys is currently active at a
higher-or-equal rank, the event is suppressed. Otherwise, the
event's inhibit_keys are recorded/upgraded, and the event is
passed to the next handler.
"""
now = self._now()
self._prune_expired(now)
event_rank = self.SEVERITY_RANK.get(event.severity, 0)
# Check suppression
for key in event.inhibit_keys:
entry = self._active.get(key)
if entry is not None:
active_rank, _ = entry
if active_rank >= event_rank:
self._logger.info(
f"SUPPRESSED event {event.id} ({event.severity}) "
f"by active key {key!r} at rank {active_rank}"
)
return
# Record / upgrade entries
new_expires = now + self._ttl
for key in event.inhibit_keys:
existing = self._active.get(key)
if existing is None or existing[0] < event_rank:
self._active[key] = (event_rank, new_expires)
# Pass through
self._next(event)
def active_keys(self) -> dict[str, tuple[int, float]]:
"""For tests: snapshot of currently-active inhibit keys."""
return dict(self._active)
def clear(self) -> None:
"""For tests: reset state."""
self._active.clear()