fire: remove fire digest feature and drop out-of-coverage fires at ingest (#107)

Two fire-scope cleanups:

1. Remove the fire digest feature entirely -- scheduler
   (notifications/scheduled/fire_digest.py), pipeline wiring, the
   fires.digest_* adapter_config key registrations, and the Fire Digest
   dashboard UI (ScheduledBroadcasts / Environment / Reference /
   AdapterConfig / ActivityLog). The unrelated generic per-rule
   notification digest is kept. Orphaned fires.digest_* config rows and the
   fire_digest_broadcasts table are left as inert data (v16 migration
   untouched).

2. Add a coverage-scope gate at fire ingest: _ingest_fires now skips any
   fire whose coordinates fall outside all configured coverage areas (same
   areas_from_config + classify_geom_areas membership the dispatch-level
   CoverageFilter uses), so out-of-coverage fires are never stored, tracked,
   alerted, reminded, or re-ingested. Fails open when coverage is disabled,
   has no areas, or excludes the fires adapter.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-09 13:46:12 -06:00 committed by GitHub
commit b0b0697bac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 189 additions and 960 deletions

View file

@ -129,20 +129,20 @@ def test_wfigs_broadcast_on_acres_bool_roundtrip(client):
assert type(val) is bool
def test_fires_digest_enabled_bool_roundtrip(client):
"""Third adapter (fires.digest_enabled) -- additional proof of generic handling."""
def test_reminders_wfigs_enabled_bool_roundtrip(client):
"""Third adapter (reminders_wfigs.enabled) -- additional proof of generic handling."""
# Read default
default_val = adapter_config.fires.digest_enabled
default_val = adapter_config.reminders_wfigs.enabled
# Flip
new_val = not default_val
r = client.put(
"/api/adapter-config/fires/digest_enabled",
"/api/adapter-config/reminders_wfigs/enabled",
json={"value": new_val},
)
assert r.status_code == 200
assert r.json()["value"] is new_val
# Accessor returns the correct bool
assert adapter_config.fires.digest_enabled is new_val
assert type(adapter_config.fires.digest_enabled) is bool
assert adapter_config.reminders_wfigs.enabled is new_val
assert type(adapter_config.reminders_wfigs.enabled) is bool

View file

@ -1,73 +0,0 @@
"""Tests for the fire-digest broadcast kill-switch (fires.digest_broadcast_enabled).
The scheduler wiring stays intact; only the mesh EMISSION is gated. Disabled by
default -> fire_slot() dispatches nothing. Flipping the flag True re-enables it
cleanly. Per-fire wfigs alerts are a separate path and are unaffected (covered in
test_wfigs_handler.py).
"""
from __future__ import annotations
import asyncio
import time
from meshai.persistence import get_db
from meshai.notifications.scheduled.fire_digest import FireDigestScheduler
class _RecordingDispatcher:
def __init__(self):
self.calls = []
async def dispatch_scheduled_broadcast(self, *, text, source_event_table,
source_event_pk):
self.calls.append(
{"text": text, "table": source_event_table, "pk": source_event_pk})
return True
def _seed_active_fire(conn):
now = int(time.time())
conn.execute(
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
"current_acres, current_contained_pct, lat, lon, county, state, "
"declared_at, last_event_at, tombstoned_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
("GATE-01", "Gatekeeper Fire", "WF", 900, None, 43.6, -116.2,
"Ada", "ID", now, now - 600, None),
)
def test_fire_slot_disabled_by_default_dispatches_nothing():
"""With defaults (digest_broadcast_enabled=False) fire_slot emits nothing."""
conn = get_db()
_seed_active_fire(conn) # a broadcast WOULD be produced if the gate were open
now = int(time.time())
dispatcher = _RecordingDispatcher()
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
result = asyncio.run(sched.fire_slot(now, "06:00"))
assert result is False
assert dispatcher.calls == [], "digest broadcast must NOT be dispatched by default"
def test_fire_slot_dispatches_when_flag_enabled():
"""Flipping fires.digest_broadcast_enabled True cleanly re-enables emission."""
from meshai.adapter_config import set_runtime_override
conn = get_db()
_seed_active_fire(conn)
now = int(time.time())
dispatcher = _RecordingDispatcher()
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
set_runtime_override("fires", "digest_broadcast_enabled", True)
try:
result = asyncio.run(sched.fire_slot(now, "06:00"))
finally:
# Reset so the override doesn't leak into other tests in this process.
set_runtime_override("fires", "digest_broadcast_enabled", False)
assert result is True
assert len(dispatcher.calls) == 1
assert dispatcher.calls[0]["table"] == "fire_digest_broadcasts"
assert "Gatekeeper Fire" in dispatcher.calls[0]["text"]

View file

@ -1,230 +0,0 @@
"""Tests for the fire digest deterministic renderer.
Validates recency ordering, contained/tombstoned exclusion, the
"Name in County Co, ST" line format, correct tail count after budget
trimming, singular/plural grammar, and the 140-byte universal mesh budget.
"""
from __future__ import annotations
import asyncio
import time
import pytest
from meshai.persistence import get_db
def _seed_fire(conn, *, irwin_id, name, acres, contained=None, lat=43.6, lon=-116.2,
county="Ada", state="ID", declared_at=None, last_event_at=None,
tombstoned_at=None):
now = int(time.time())
conn.execute(
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
"current_acres, current_contained_pct, lat, lon, county, state, "
"declared_at, last_event_at, tombstoned_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(irwin_id, name, "WF", acres, contained, lat, lon, county, state,
declared_at or now, last_event_at or now, tombstoned_at),
)
def _seed_scenario(conn):
"""Seed fires: 3 active, 1 contained, 1 tombstoned."""
now = int(time.time())
day = 86400
_seed_fire(conn, irwin_id="F-01", name="Alpha Fire",
acres=500, contained=None,
last_event_at=now - 3600,
county="Ada", state="ID")
_seed_fire(conn, irwin_id="F-02", name="Bravo Fire",
acres=200, contained=25,
last_event_at=now - 7200,
county="Boise", state="ID")
_seed_fire(conn, irwin_id="F-03", name="Charlie Fire",
acres=1000, contained=None,
last_event_at=now - 2 * day,
county="Elmore", state="ID")
_seed_fire(conn, irwin_id="F-04", name="Contained Fire",
acres=800, contained=100,
last_event_at=now - 1800,
county="Gem", state="ID")
_seed_fire(conn, irwin_id="F-05", name="Tombstoned Fire",
acres=3000, contained=50,
last_event_at=now - day,
tombstoned_at=now - 3600,
county="Owyhee", state="ID")
class TestFireDigestRecency:
"""Deterministic fire digest renderer tests."""
def test_top_2_listed_in_recency_order(self):
"""The most recent active fire is listed first.
With the universal 140-byte budget, the header + 1 fire line + tail
fits; the second fire line does not. Alpha (most recent) must appear;
Bravo belongs to the tail count.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
# Most recent fire must appear in the wire body.
assert "Alpha Fire" in wire
# Bravo does not fit within 140 bytes alongside the header + tail.
assert "Bravo Fire" not in wire
def test_line_format_name_in_county_co_state(self):
"""Fire lines render as 'Name in County Co, ST'.
Only the most recent fire fits within the 140-byte budget.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Alpha Fire in Ada Co, ID" in wire
def test_missing_county_renders_name_in_state(self):
"""Fire with no county renders as 'Name in ST'."""
conn = get_db()
now = int(time.time())
_seed_fire(conn, irwin_id="NC-01", name="No County Blaze",
acres=100, contained=None,
last_event_at=now - 3600,
county=None, state="MT")
_seed_fire(conn, irwin_id="NC-02", name="Second Fire",
acres=50, contained=None,
last_event_at=now - 7200,
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "No County Blaze in MT" in wire
def test_contained_excluded(self):
"""100%-contained fires are excluded from the digest."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Contained Fire" not in wire
def test_tombstoned_excluded(self):
"""Tombstoned fires are excluded from the digest."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Tombstoned Fire" not in wire
def test_n1_grammar_singular(self):
"""N == 1 renders 'There is 1 additional wildfire.'."""
conn = get_db()
now = int(time.time())
# 3 fires; short names + no county → lines are short enough that
# 2 fit within the 140-byte budget, leaving 1 in the tail.
for irwin, name, offset in [
("SG-01", "A", 3600),
("SG-02", "B", 7200),
("SG-03", "C", 10800),
]:
_seed_fire(conn, irwin_id=irwin, name=name, acres=100,
contained=None, last_event_at=now - offset,
county=None, state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 3 active total, 2 shown (budget), 1 remaining
assert "There is 1 additional wildfire. DM me for the full list." in wire
def test_n_plural_grammar(self):
"""N > 1 renders 'There are N additional wildfires.'."""
conn = get_db()
now = int(time.time())
for i in range(4):
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
acres=100 + i, contained=None,
last_event_at=now - 3600 * (i + 1),
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 4 active; "Fire N in Ada Co, ID" lines total ~142 bytes for 2 lines +
# header + tail → only 1 line fits in the 140-byte budget → 3 remaining.
assert "There are 3 additional wildfires. DM me for the full list." in wire
def test_n_zero_omits_sentence(self):
"""When N == 0, the tail sentence is omitted entirely."""
conn = get_db()
now = int(time.time())
_seed_fire(conn, irwin_id="X-01", name="Fire One",
acres=100, contained=None,
last_event_at=now - 3600,
county="Ada", state="ID")
_seed_fire(conn, irwin_id="X-02", name="Fire Two",
acres=50, contained=10,
last_event_at=now - 7200,
county="Boise", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
assert "additional" not in wire
assert "Fire One" in wire
assert "Fire Two" in wire
def test_tail_count_correct_after_budget_trim(self):
"""When budget trims a line, N reflects actual shown count."""
conn = get_db()
now = int(time.time())
# Long names force the second line to be trimmed
long_name_1 = "A" * 60
long_name_2 = "B" * 60
_seed_fire(conn, irwin_id="LN-01", name=long_name_1,
acres=500, contained=None,
last_event_at=now - 3600,
county="Bonneville", state="ID")
_seed_fire(conn, irwin_id="LN-02", name=long_name_2,
acres=200, contained=None,
last_event_at=now - 7200,
county="Bannock", state="ID")
_seed_fire(conn, irwin_id="LN-03", name="Short Fire",
acres=100, contained=None,
last_event_at=now - 3 * 86400,
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 140, f"Wire is {byte_len} bytes, exceeds 140"
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
# Either way the tail count must match (total - shown)
lines = wire.split("\n")
shown_fires = [l for l in lines if long_name_1 in l or long_name_2 in l]
remaining = 3 - len(shown_fires)
if remaining == 1:
assert "There is 1 additional wildfire." in wire
elif remaining > 1:
assert f"There are {remaining} additional wildfires." in wire
def test_rendered_within_200_bytes(self):
"""Rendered output must be <= 200 bytes for LoRa budget."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 140, f"Digest is {byte_len} bytes, exceeds 140-byte budget"
def test_no_fires_returns_empty(self):
"""No active fires -> empty wire, 'no_fires' source."""
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest())
assert wire == ""
assert source == "no_fires"

View file

@ -110,16 +110,27 @@ def _raw_fire(*, name="MORA", irwin=_IRWIN, acres=2410, contained=10,
}
def _make_store():
def _make_store(coverage_areas=None, coverage_excluded=None):
bus = EventBus()
captured: list = []
bus.subscribe(lambda e: captured.append(e))
store = EnvironmentalStore(EnvironmentalConfig(), event_bus=bus)
store = EnvironmentalStore(
EnvironmentalConfig(), event_bus=bus,
coverage_areas=coverage_areas,
coverage_excluded=coverage_excluded,
)
adapter = _FakeFires()
store._adapters["nifc"] = adapter
return store, adapter, captured
# A coverage area covering SW Idaho (the default _raw_fire lat/lon 44.0,-115.0
# sits inside this box; a fire near 0,0 or on the US east coast is outside).
_SW_IDAHO_AREA = {
"name": "sw-id", "west": -117.0, "south": 42.0, "east": -114.0, "north": 45.0,
}
def _seed_row(conn, *, acres, contained, last_bcast_at):
"""Manually insert an already-broadcast fires row (skips cold-start)."""
conn.execute(
@ -309,3 +320,80 @@ def test_to_event_stamps_canonical_data(env):
assert ev.data["contained_pct"] == 30
assert ev.data["declared_at_epoch"] == _NOW
assert ev.data["lat"] == 44.0 and ev.data["state"] == "US-ID"
# ── 7. Coverage-scope ingest gate ────────────────────────────────────────────
# Fires OUTSIDE every configured coverage area are NEVER stored (so they are
# never tracked / alerted / reminded / re-ingested). The gate mirrors the
# dispatch-level CoverageFilter's set-union membership and applies to BOTH the
# cold-start seed and the live INSERT/UPDATE path. Fail-OPEN when coverage is
# disabled or has no areas (unchanged current behaviour).
def _fires_row(conn, irwin):
return conn.execute(
"SELECT irwin_id FROM fires WHERE irwin_id=?", (irwin,)).fetchone()
def test_coverage_gate_inside_area_is_stored(env):
"""A fire INSIDE a coverage box is stored (cold-start seed row present)."""
conn, _clk = env
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
# Default coords 44.0,-115.0 are inside _SW_IDAHO_AREA.
adapter.set_batch([_raw_fire(irwin="IRWIN-IN-1", lat=44.0, lon=-115.0)])
store._ingest("nifc", adapter)
assert captured == [], "cold-start seed must broadcast nothing"
assert _fires_row(conn, "IRWIN-IN-1") is not None, \
"in-area fire must be stored"
def test_coverage_gate_outside_area_is_not_stored(env):
"""A fire OUTSIDE all coverage boxes is NOT stored (no row) with coverage
enabled + areas defined neither the cold-start seed nor a live upsert."""
conn, _clk = env
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
# 0,0 (Gulf of Guinea) is far outside the SW-Idaho box.
adapter.set_batch([_raw_fire(irwin="IRWIN-OUT-1", lat=0.0, lon=0.0)])
store._ingest("nifc", adapter)
assert captured == [], "out-of-area fire must broadcast nothing"
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
"out-of-area fire must NOT be stored (cold-start seed dropped)"
# A later (already-seeded) poll must ALSO refuse to INSERT the out-of-area
# fire via the live path — it must never latch a row.
store._fires_seeded = True
store._ingest("nifc", adapter)
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
"out-of-area fire must NOT be stored on the live upsert path either"
def test_coverage_gate_fail_open_when_no_areas(env):
"""Coverage DISABLED / no areas -> an out-of-box fire IS stored (fail-open,
unchanged behaviour)."""
conn, _clk = env
store, adapter, captured = _make_store() # no coverage areas
assert store._fire_coverage_areas == [], \
"no coverage areas -> gate is a no-op list"
adapter.set_batch([_raw_fire(irwin="IRWIN-OPEN-1", lat=0.0, lon=0.0)])
store._ingest("nifc", adapter)
assert _fires_row(conn, "IRWIN-OPEN-1") is not None, \
"with no coverage areas, every fire is stored (fail-open)"
def test_coverage_gate_excluded_adapter_fails_open(env):
"""When 'fires' is on the coverage opt-out list, the gate is disabled even
with areas configured mirrors the _coverage_for fetch-scope escape hatch;
an out-of-box fire IS stored."""
conn, _clk = env
store, adapter, captured = _make_store(
coverage_areas=[_SW_IDAHO_AREA], coverage_excluded=["fires"])
assert store._fire_coverage_areas == [], \
"excluded 'fires' adapter -> no gate built"
adapter.set_batch([_raw_fire(irwin="IRWIN-EXCL-1", lat=0.0, lon=0.0)])
store._ingest("nifc", adapter)
assert _fires_row(conn, "IRWIN-EXCL-1") is not None, \
"excluded adapter falls back to no coverage gating (fire stored)"

View file

@ -59,79 +59,6 @@ def test_router_scope_type_defined_before_env_check():
or "scope_type:" in preceding
# ===========================================================================
# adapter_config seed + categories registration
# ===========================================================================
def test_adapter_config_seeds_digest_keys():
from meshai.persistence import get_db
rows = {
(r["adapter"], r["key"]): r["default_json"]
for r in get_db().execute(
"SELECT adapter, key, default_json FROM adapter_config "
"WHERE adapter='fires' AND key LIKE 'digest%'"
)
}
assert rows[("fires", "digest_enabled")] == "true"
assert rows[("fires", "digest_schedule")] == '["06:00", "18:00"]'
assert rows[("fires", "digest_timezone")] == '"America/Boise"'
assert rows[("fires", "digest_max_chars")] == "140"
# ===========================================================================
# Digest renderer
# ===========================================================================
def test_render_digest_returns_no_fires_when_table_empty():
from meshai.notifications.scheduled.fire_digest import render_digest
async def _run():
return await render_digest(now=None)
wire, source = asyncio.run(_run())
assert wire == ""
assert source == "no_fires"
def test_render_digest_terse_fallback_when_no_llm():
_seed_fire(irwin_id="ID-A", name="Cache Peak",
lat=42.0, lon=-114.0, acres=1847, contained=23)
_seed_fire(irwin_id="ID-B", name="Twin Peaks",
lat=43.0, lon=-115.0, acres=320, contained=5)
from meshai.notifications.scheduled.fire_digest import render_digest
async def _run():
return await render_digest(now=None)
wire, source = asyncio.run(_run())
assert source == "deterministic"
assert wire
assert "Cache Peak" in wire
assert len(wire) <= 140
def test_render_digest_uses_llm_when_available():
"""When the LLM backend returns a string, that string IS the wire."""
_seed_fire(irwin_id="ID-A", name="Cache Peak",
lat=42.0, lon=-114.0, acres=1847)
class StubLLM:
async def generate(self, *, messages, system_prompt, max_tokens):
# The renderer must give us a single-line wire derived from
# the LLM output, with markdown stripped + cap applied.
return "Cache Peak 1847 ac stable; no spotting today."
from meshai.notifications.scheduled.fire_digest import render_digest
async def _run():
return await render_digest(now=None)
wire, source = asyncio.run(_run())
assert source == "deterministic"
# render_digest is now fully deterministic (no LLM backend).
assert "Cache Peak" in wire
assert "1,847 ac" in wire
# ===========================================================================
# Natural-language fire DMs route to the LLM (no ?status fallback)
# ===========================================================================

View file

@ -670,16 +670,11 @@ def test_wfigs_discovery_is_date_only():
# ============================================================================
# Fire-digest kill-switch does NOT touch the per-fire wfigs path: a new-fire
# envelope still produces a broadcast even though digest broadcast is disabled.
# A new-fire envelope produces a per-fire wfigs broadcast.
# ============================================================================
def test_per_fire_wfigs_broadcasts_while_digest_disabled(mem_db, no_photon):
from meshai.adapter_config import adapter_config
# Default posture: the twice-daily digest broadcast is OFF.
assert bool(adapter_config.fires.digest_broadcast_enabled) is False
# ... yet a new per-fire wfigs alert still broadcasts.
def test_per_fire_wfigs_broadcasts_new_fire(mem_db, no_photon):
env = _make_active_envelope(geocoder_city="Burley")
data = {}
wire = handle_wfigs(cn.normalize(env), env, env["subject"],