From 8cf085196482d2041f2303c60ff6c24e40d1ed70 Mon Sep 17 00:00:00 2001 From: malice Date: Sun, 5 Jul 2026 01:18:44 -0600 Subject: [PATCH] =?UTF-8?q?fix(firms):=20native=20adapter=20attribution-on?= =?UTF-8?q?ly=20=E2=80=94=20never=20broadcast=20raw=20hotspots=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env/firms.py to_event() now unconditionally returns None. The native FIRMS path was broadcasting raw single-pixel hotspots (new_ignition/wildfire_hotspot, both live "fire"-toggle categories) straight to the mesh — no decider gated them (none registered for those categories) — violating the absolute "we do NOT broadcast hotspots" rule and diverging from the Central handler's storage-only contract. Finding: native FIRMS has NO fusion wiring — it never wrote firms_pixels or did attribution; the growth/spotting/halt fire-tracker lives entirely in central/firms_handler.py driven by NATS. So neutralization loses no fusion (there was none natively). Full native fire-tracking standalone would require feeding native pixels into that attribution engine — a known, deferred gap. Only env/firms.py + tests/test_adapter_firms.py touched; firms_handler.py, gating/firms.py, store.py untouched. Suite at the 10-failure baseline. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/meshai/env/firms.py | 89 ++++++++------------- work/tests/test_adapter_firms.py | 128 +++++++++---------------------- 2 files changed, 69 insertions(+), 148 deletions(-) diff --git a/work/meshai/env/firms.py b/work/meshai/env/firms.py index 3627189..04b9974 100644 --- a/work/meshai/env/firms.py +++ b/work/meshai/env/firms.py @@ -345,64 +345,43 @@ class FIRMSAdapter: return (None, None) def to_event(self, evt: dict) -> Optional["Event"]: - """Translate a stored FIRMS event dict into a pipeline Event. + """Attribution-only: the native FIRMS adapter NEVER broadcasts hotspots. - Args: - evt: Internal event dict from get_events() + Firm rule (Matt): "we do NOT broadcast hotspots." A raw satellite + thermal pixel -- a single-pixel ``wildfire_hotspot`` or + ``new_ignition`` detection -- is noisy and not actionable on its own; + broadcasting it would flood the mesh with unattributed heat. This + mirrors the Central path (``central/firms_handler.py``), which is + storage-only and returns None for every raw pixel. The ONLY FIRMS + signals that ever reach the mesh are the fire-tracker FUSION outputs + (``wildfire_growth`` / ``wildfire_spotting`` / ``wildfire_halted``), + produced by the Central handler's attribution engine -- NOT here. - Returns: - Event instance ready for EventBus emission, or None if - the dict is missing required fields (lat/lon). + This method therefore ALWAYS returns None. The neutralization is + UNCONDITIONAL (not behind any config flag): the no-hotspots rule is + absolute. Previously this emitted ``make_event(category="new_ignition" + if new_ignition else "wildfire_hotspot", ...)``, and because + ``store._emit_event`` only consults a gating decider for cut-over + categories -- and NO decider is registered for the raw hotspot + categories (see ``notifications/gating/__init__.py``: "native + env/fires.py hotspot broadcasts ... are NOT migrated") -- those Events + went straight to the bus and out to the mesh. Returning None removes + that broadcast on the native path entirely. + + The raw hotspot dicts remain available in-memory via ``get_events()`` + / ``get_new_ignitions()`` for LLM context and health reporting; they + just never become a broadcastable Event. + + STANDALONE GAP (not fixed here): unlike the Central handler, the native + adapter has NO fusion wiring -- it does not persist ``firms_pixels`` or + run attribution / clustering / pass-boundary growth. Native FIRMS + therefore feeds NOTHING into the fire tracker today; the cross-ref to + known NIFC fires only sets the (now unused for broadcast) + ``new_ignition`` flag on the in-memory dict. Wiring native pixels into + the attribution engine so growth/spotting/halt can eventually fire is a + separate, larger effort. """ - try: - lat = evt.get("lat") - lon = evt.get("lon") - if lat is None or lon is None: - return None # Can't make a useful Event without coords - - props = evt.get("properties", {}) or {} - is_new_ignition = bool(props.get("new_ignition", False)) - # v0.5.7-fire: 'wildfire_proximity' was removed from ALERT_CATEGORIES - # (parametric: distance threshold isn't configurable on rules until - # v0.5.8). Emit 'wildfire_hotspot' to align with the central FIRMS - # path -- both native and central FIRMS now produce the same category. - category = "new_ignition" if is_new_ignition else "wildfire_hotspot" - - severity = evt.get("severity", "routine") - - title = evt.get("headline", "") or "Fire Hotspot" - - # Build a richer summary including FRP, confidence, distance - summary_parts = [title] - if props.get("frp") is not None: - summary_parts.append(f"FRP {int(props['frp'])} MW") - if props.get("confidence"): - summary_parts.append(f"conf {props['confidence']}") - if props.get("distance_km") is not None and props.get("nearest_anchor"): - summary_parts.append( - f"{int(props['distance_km'])} km from {props['nearest_anchor']}" - ) - summary = " | ".join(summary_parts)[:300] - - spatial_key = f"firms:{round(lat, 2):.2f}:{round(lon, 2):.2f}" - - return make_event( - source="firms", - category=category, - severity=severity, - title=title, - summary=summary, - timestamp=evt.get("fetched_at"), - expires=evt.get("expires"), - region=props.get("nearest_anchor"), - lat=lat, - lon=lon, - group_key=spatial_key, - inhibit_keys=[spatial_key], - ) - except Exception: - logger.exception(f"FIRMS to_event failed for evt: {evt.get('event_id')}") - return None + return None def get_events(self) -> list: """Get current hotspot events.""" diff --git a/work/tests/test_adapter_firms.py b/work/tests/test_adapter_firms.py index 8bc600a..3b30b54 100644 --- a/work/tests/test_adapter_firms.py +++ b/work/tests/test_adapter_firms.py @@ -73,105 +73,48 @@ def make_firms_event( # ============================================================ -# CATEGORY DECISION TESTS +# ATTRIBUTION-ONLY CONTRACT # ============================================================ +# +# Firm rule (Matt): "we do NOT broadcast hotspots." The native FIRMS adapter +# must NEVER produce a broadcastable Event from a raw satellite pixel -- +# neither a near-known-fire `wildfire_hotspot` nor a standalone `new_ignition`. +# `to_event()` is now attribution-only and ALWAYS returns None, mirroring the +# storage-only Central path (central/firms_handler.py). The only FIRMS signals +# that ever reach the mesh are the fusion outputs (wildfire_growth / +# wildfire_spotting / wildfire_halted) produced by the Central handler. +# +# These tests lock that contract: NOTHING the adapter emits reaches the bus. -def test_to_event_new_ignition(adapter): - """New ignition maps to new_ignition category.""" - evt = make_firms_event(new_ignition=True) - event = adapter.to_event(evt) - assert event is not None - assert event.category == "new_ignition" +def test_to_event_hotspot_returns_none(adapter): + """A representative hotspot (near a known fire) never broadcasts.""" + evt = make_firms_event(new_ignition=False, near_fire="Snake River Fire", + frp=85.5, confidence="h") + assert adapter.to_event(evt) is None -def test_to_event_near_known_fire(adapter): - """Hotspot near known fire maps to wildfire_hotspot.""" - evt = make_firms_event(new_ignition=False, near_fire="Snake River Fire") - event = adapter.to_event(evt) - assert event is not None - assert event.category == "wildfire_hotspot" +def test_to_event_new_ignition_returns_none(adapter): + """A representative new-ignition hotspot never broadcasts.""" + evt = make_firms_event(new_ignition=True, frp=120.0, confidence="h", + distance_km=12, nearest_anchor="TFL") + assert adapter.to_event(evt) is None -# ============================================================ -# SEVERITY PASS-THROUGH TESTS -# ============================================================ - -def test_to_event_severity_passes_through(adapter): - """Severity from FIRMS event passes through unchanged.""" +def test_to_event_returns_none_across_severities(adapter): + """No severity tier re-opens the hotspot broadcast path.""" for sev in ["routine", "priority", "immediate"]: evt = make_firms_event(severity=sev) - event = adapter.to_event(evt) - assert event is not None - assert event.severity == sev + assert adapter.to_event(evt) is None -# ============================================================ -# CONTENT TESTS -# ============================================================ - -def test_to_event_summary_includes_frp(adapter): - """Summary includes FRP when present.""" - evt = make_firms_event(frp=85.5) - event = adapter.to_event(evt) - assert event is not None - assert "FRP 85" in event.summary - - -def test_to_event_summary_handles_missing_frp(adapter): - """Missing FRP doesn't break to_event.""" - evt = make_firms_event(frp=None) - event = adapter.to_event(evt) - assert event is not None - assert "FRP" not in event.summary - - -def test_to_event_summary_includes_distance_when_present(adapter): - """Summary includes distance and anchor when present.""" - evt = make_firms_event(distance_km=12, nearest_anchor="TFL") - event = adapter.to_event(evt) - assert event is not None - assert "12 km" in event.summary - assert "TFL" in event.summary - - -def test_to_event_region_uses_nearest_anchor(adapter): - """Region is set from nearest_anchor.""" - evt = make_firms_event(nearest_anchor="MHR") - event = adapter.to_event(evt) - assert event is not None - assert event.region == "MHR" - - -# ============================================================ -# SPATIAL KEY TESTS -# ============================================================ - -def test_to_event_group_key_is_spatial_grid(adapter): - """Group key is spatial grid based on rounded lat/lon.""" - evt = make_firms_event(lat=42.5678, lon=-114.3456) - event = adapter.to_event(evt) - assert event is not None - assert event.group_key == "firms:42.57:-114.35" - - -def test_to_event_inhibit_keys_match_group_key(adapter): - """Inhibit keys contain the same spatial key as group_key.""" - evt = make_firms_event(lat=42.5678, lon=-114.3456) - event = adapter.to_event(evt) - assert event is not None - assert event.group_key in event.inhibit_keys - - -def test_two_nearby_detections_share_group_key(adapter): - """Two detections in same grid cell share group_key.""" - # Both round to 42.57:-114.35 - evt1 = make_firms_event(lat=42.571, lon=-114.351) - evt2 = make_firms_event(lat=42.572, lon=-114.352) - event1 = adapter.to_event(evt1) - event2 = adapter.to_event(evt2) - assert event1 is not None - assert event2 is not None - assert event1.group_key == event2.group_key +def test_to_event_never_broadcasts_even_with_full_payload(adapter): + """A fully-populated hotspot dict still produces no Event.""" + evt = make_firms_event( + lat=42.5678, lon=-114.3456, new_ignition=True, severity="immediate", + headline="NEW HOTSPOT detected", frp=250.0, confidence="h", + distance_km=3, nearest_anchor="MHR", + ) + assert adapter.to_event(evt) is None # ============================================================ @@ -186,8 +129,8 @@ def test_to_event_missing_coords_returns_none(adapter): assert event is None -def test_to_event_missing_properties_returns_event(adapter): - """Missing properties dict defaults to wildfire_hotspot.""" +def test_to_event_missing_properties_returns_none(adapter): + """Missing properties dict still produces no broadcast.""" evt = { "source": "firms", "event_id": "test", @@ -200,8 +143,7 @@ def test_to_event_missing_properties_returns_event(adapter): } # No "properties" key at all event = adapter.to_event(evt) - assert event is not None - assert event.category == "wildfire_hotspot" + assert event is None def test_to_event_does_not_raise_on_corrupted_dict(adapter):