fix(firms): repair the FIRMS fire-fusion Event contract (issues #117-#119) (#120)

Three independent bugs kept firms_handler's growth/spotting/halt/cluster
fusion decisions from reaching a correct mesh Event:

- #117: consumer._normalize() computed `category` from the raw Central
  category BEFORE the per-adapter handler ran and never re-read
  data["category"] afterward, so every firms_handler category stamp was a
  silent no-op. Now re-read post-dispatch, validated against the known
  category registry (unrecognized overrides are logged and ignored).

- #118: consumer.py only ever honors data["_severity_override"], but
  firms_handler's halt/spotting/cluster sites stamped the plain
  data["severity"] key instead (only growth used the right key). Switched
  all three sites to `_severity_override` for one consistent contract.
  This is severity plumbing only -- it does not change which events fire.

- #119: FirePacer's gate only matched source in ("fires","wfigs") at
  severity=="priority", so FIRMS fusion broadcasts (source="firms",
  growth/spotting at "immediate") never reached the pacer. Broadened the
  gate to cover "firms" + {"priority","immediate"}, and gave FirePacer
  head-of-line insertion so an "immediate" event is never stuck behind
  already-queued "priority" events. Still unbounded/never-drops.

Cluster detection is left exactly as main ships it: live, always on, no
toggle (PR #73's curated new-fire cluster broadcasts with cold-start
silent-seeding). Only its severity-override key changes, under #118.

Updated existing tests that asserted the old (buggy) data["severity"]
contract, and added tests/test_firms_fusion_event_contract.py covering
all three fixes end-to-end through consumer._normalize()/_handle() and
FirePacer directly.

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-11 17:41:40 -06:00 committed by GitHub
commit 5dd8266abe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 479 additions and 32 deletions

View file

@ -169,7 +169,7 @@ class TestIngestSpotting:
assert len(out) == 1
wire, data = out[0]
assert data["category"] == "wildfire_spotting"
assert data["severity"] == "immediate"
assert data["_severity_override"] == "immediate"
assert wire.startswith("🔥 Possible spotting ")
# Eager latch stamped during ingest (not-cutover legacy path).
from meshai.persistence import get_db
@ -197,7 +197,7 @@ class TestIngestHalt:
assert len(out) == 1
wire, data = out[0]
assert data["category"] == "wildfire_halted"
assert data["severity"] == "routine"
assert data["_severity_override"] == "routine"
assert wire == "🔥 Cold Fire no growth in 14h"
latch = get_db().execute(
"SELECT halt_broadcast_at FROM fires WHERE irwin_id=?",
@ -230,7 +230,7 @@ class TestIngestNeverRaw:
for wire, data in produced:
assert wire.startswith("🔥 Possible new fire:")
assert data["category"] == "unattributed_hotspot_cluster"
assert data["severity"] == "priority"
assert data["_severity_override"] == "priority"
# ═════════════════════════════════════════════════════════════════════════════