feat(v0.6-1): FIRMS handler -- storage-only, closes silent-drop on central.fire.hotspot.>

v0.5.13 default-deny was silently dropping every FIRMS hotspot because no
per-adapter handler existed. firms_pixels table has been empty since v0.5.8b.

This commit adds central/firms_handler.py which stores every passing pixel
that clears the (currently hardcoded, future GUI-driven) confidence + FRP
floors, with dedup on (round(lat,5), round(lon,5), acq_time, satellite) via
a unique partial index added in v4.sql. NO mesh broadcasts emitted by this
handler -- FIRMS data is for LLM context only and will become queryable
when commit #5 (env_reporter) lands.

Defaults baked in:
  FIRMS_CONFIDENCE_FLOOR = "low"   -- store every confidence level
  FIRMS_FRP_FLOOR        = 0.0     -- store every FRP value
  FIRMS_BBOX_OPTIONAL    = None    -- no spatial filter

These become adapter_config GUI rows in commit #3 (per Matt's v0.6 Phase 1
refinement: hardcoded values become GUI default values so first-deploy
behavior is unchanged).

Wiring:
  - meshai/central/firms_handler.py (new, 270 lines)
  - meshai/persistence/migrations/v4.sql (new, unique dedup index)
  - meshai/persistence/db.py (SCHEMA_VERSION 3 -> 4)
  - meshai/central/consumer.py (dispatch ladder gets firms branch before
    default-deny clause; pattern matches handle_swpc / handle_nwis)
  - tests/test_firms_handler.py (new, 22 tests covering confidence floor,
    FRP floor, bbox, dedup, missing fields, end-to-end through consumer)
  - tests/test_consumer_default_deny.py (swap firms -> avalanche for the
    "no handler" examples since FIRMS now has one)

Test count: 658 -> 680 (+22 firms_handler tests, 0 regressions).

Refs audit doc v0.6-phase1-audit.md finding #2.
This commit is contained in:
Matt Johnson (via Claude) 2026-06-05 15:50:33 +00:00
commit b2c4d53b14
6 changed files with 731 additions and 11 deletions

View file

@ -7,8 +7,8 @@ return an Event with that exact title + _meshai_precomposed=True so the
composer bypass kicks in.
Covers four cases:
(a) envelope with NO matching handler (adapter='firms' has no handler)
-> _normalize returns None
(a) envelope with NO matching handler (adapter='avalanche' has no
Central adapter wired) -> _normalize returns None
(b) envelope hits handler, handler returns None (e.g. sub-G3 swpc,
stale tomtom) -> _normalize returns None
(c) envelope hits handler, handler returns wire string
@ -82,9 +82,10 @@ def _make_envelope(adapter, category, *, inner_id="test_001",
def test_no_handler_match_returns_none(consumer, mem_db):
"""FIRMS has no handler; envelope must drop at consumer._normalize."""
env = _make_envelope("firms", "fire.hotspot.viirs",
inner_id="firms_001")
"""Avalanche has no handler (no Central adapter). envelope must
drop at consumer._normalize as the default-deny baseline."""
env = _make_envelope("avalanche", "avalanche.forecast",
inner_id="aval_001")
out = consumer._normalize(env["subject"], env)
assert out is None
@ -193,11 +194,12 @@ def test_handler_returns_wire_event_emitted(consumer, mem_db, monkeypatch):
def test_envelope_with_title_still_drops_without_handler(consumer, mem_db):
"""Regression guard: the v0.5.7-fallback path (data.title -> headline ->\
friendly_name -> cat_raw) is GONE in v0.5.13."""
env = _make_envelope("firms", "fire.hotspot.viirs",
inner_id="firms_with_title",
title="Wildfire Hotspot",
headline="VIIRS Hotspot Detected")
friendly_name -> cat_raw) is GONE in v0.5.13. Uses an unhandled adapter
(avalanche) since v0.6-1 added the FIRMS handler."""
env = _make_envelope("avalanche", "avalanche.forecast",
inner_id="aval_with_title",
title="Avalanche Warning",
headline="Backcountry advisory")
out = consumer._normalize(env["subject"], env)
assert out is None, (
"v0.5.13 default-deny: data.title and data.headline must NOT rescue\n"