meshai/work/tests/test_pipeline_grouper.py
malice 74a5fa44d4
fix(pipeline): let wildfire_spotting skip the grouper (category-scoped) (#131)
wildfire_spotting is the most urgent signal in the system (a fire
throwing embers past its own containment line), but every fire event
carries a group_key (= event_id), so the Grouper held spotting for the
full grouper_window_seconds (60s live) before it could even reach the
dispatcher -- a ~60s latency FLOOR, and ~120s+ once FirePacer queuing
is added on top.

Add a module-level _NEVER_COALESCE_CATEGORIES frozenset and bypass the
coalescing window for the categories in it. Currently: wildfire_spotting
only.

This is safe because spotting is already rate-limited AT THE SOURCE: a
per-fire 1h cooldown (adapter_config.fires.spotting_cooldown_seconds,
default 3600, latched on fires.last_spotting_broadcast_at per irwin_id
in gating/firms.py) gates spotting DETECTION itself, so N active fires
yield at most N spotting alerts per hour. FirePacer (60s interval, with
head-of-line ordering for immediate severity) and the dispatcher's
per-(toggle, category, region) cooldown still apply downstream.

The bypass is scoped by CATEGORY, never by severity. Commit 85d48ce3
deliberately removed a severity == "immediate" bypass from this exact
spot because ALL fire events carry _severity_override="immediate", so a
severity bypass exempts the entire fire family from rate control. The
comment on the constant spells that out so it does not get re-added.

Tests: spotting with a group_key passes straight through; wildfire_growth
and wildfire_incident at immediate severity are STILL held (proving no
severity bypass crept back in); spotting with no group_key still passes
through. PR #129's test_immediate_severity_is_also_coalesced_no_bypass is
untouched and still passes.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:37:07 -06:00

181 lines
6.4 KiB
Python

"""Grouper tests: coalescing (all severities) + periodic flush.
Note: there is deliberately NO immediate-severity bypass -- commit 85d48ce3
removed it so fire broadcasts obey rate control. The only pass-through is
"event has no group_key".
"""
from meshai.notifications.pipeline.grouper import Grouper
from meshai.notifications.events import make_event
class Recorder:
def __init__(self):
self.received = []
def handle(self, event):
self.received.append(event)
def _ev(severity, group_key="gk1"):
return make_event(
source="usgs_quake",
category="earthquake_event",
severity=severity,
title=f"test {severity}",
lat=42.6,
lon=-114.5,
group_key=group_key,
inhibit_keys=[group_key],
)
def test_immediate_severity_is_also_coalesced_no_bypass():
"""An immediate event WITH a group_key is held, like every other severity.
The grouper used to exempt severity == "immediate" from the coalescing
window. Commit 85d48ce3 ("fix(fire): remove immediate-severity exemption
from grouper + cooldown") DELETED that bypass on purpose: fire events
carry _severity_override="immediate", and the exemption meant they
skipped the coalescer and zeroed the dispatcher cooldown, leaving fire
with no rate control at all in normal live operation. Rate control now
applies to ALL severities; the drain-mode pacer covers reconnect bursts.
This test previously asserted the OLD bypass contract and had been red
ever since. Re-adding the bypass to make it pass would re-open the fire
broadcast-spam hole on a public-safety mesh -- so the test is what moves,
not the source.
"""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
g.handle(_ev("immediate"))
# Held for coalescing, NOT delivered straight through.
assert rec.received == []
assert g.held_count() == 1
# The periodic flush (start_pipeline's _grouper_flush_loop) is what
# eventually delivers it, once the window expires.
g2 = Grouper(next_handler=rec.handle, window_seconds=0.0)
g2.handle(_ev("immediate", group_key="gk2"))
assert g2.tick() == 1
assert len(rec.received) == 1
assert rec.received[0].severity == "immediate"
def test_no_group_key_still_passes_through_immediately():
"""The ONE remaining bypass: an event with no group_key isn't coalesced
(there's nothing to coalesce it against)."""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
g.handle(_ev("immediate", group_key=None))
assert len(rec.received) == 1
assert g.held_count() == 0
def test_periodic_flush_drains_routine():
"""A routine event is held, then released by tick() once its window passes."""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=0.0) # 0s window -> tick drains now
g.handle(_ev("routine"))
# Held on arrival, not yet delivered.
assert g.held_count() == 1
assert rec.received == []
# The periodic flush task calls tick(); simulate one tick.
drained = g.tick()
assert drained == 1
assert len(rec.received) == 1
assert rec.received[0].severity == "routine"
assert g.held_count() == 0
def test_priority_is_also_coalesced_not_bypassed():
"""Priority events still buffer (only immediate bypasses)."""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
g.handle(_ev("priority"))
assert rec.received == []
assert g.held_count() == 1
def test_wildfire_spotting_bypasses_the_grouper_even_with_group_key():
"""wildfire_spotting is a CATEGORY-scoped bypass (owner-approved): it
skips the coalescing window entirely, even though it carries a
group_key that would otherwise hold it. This is the one narrow
exemption -- see _NEVER_COALESCE_CATEGORIES in grouper.py for why
it's safe (source-side 1h per-fire cooldown) and why it must stay
category-scoped, not severity-scoped."""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
ev = make_event(
source="firms",
category="wildfire_spotting",
severity="immediate",
title="test spotting",
lat=42.6,
lon=-114.5,
group_key="fire-irwin-123",
)
g.handle(ev)
# Passed straight through -- NOT held for the coalescing window.
assert len(rec.received) == 1
assert rec.received[0].category == "wildfire_spotting"
assert g.held_count() == 0
def test_wildfire_growth_immediate_is_still_held_no_severity_bypass_crept_in():
"""A same-severity, same-fire-family event of a DIFFERENT category
(wildfire_growth, not wildfire_spotting) must still be coalesced.
This proves the new bypass is scoped to category and did not
accidentally reintroduce a severity-based bypass (the exact bug
commit 85d48ce3 removed)."""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
ev = make_event(
source="wfigs",
category="wildfire_growth",
severity="immediate",
title="test growth",
lat=42.6,
lon=-114.5,
group_key="fire-irwin-123",
)
g.handle(ev)
assert rec.received == []
assert g.held_count() == 1
def test_wildfire_incident_immediate_is_still_held_no_severity_bypass_crept_in():
"""Same as above for wildfire_incident: only wildfire_spotting bypasses,
every other fire category (even at immediate severity) is coalesced."""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
ev = make_event(
source="wfigs",
category="wildfire_incident",
severity="immediate",
title="test incident",
lat=42.6,
lon=-114.5,
group_key="fire-irwin-123",
)
g.handle(ev)
assert rec.received == []
assert g.held_count() == 1
def test_wildfire_spotting_with_no_group_key_still_passes_through():
"""Existing no-group_key behavior is preserved for spotting too --
the bypass condition is an `or`, not a replacement."""
rec = Recorder()
g = Grouper(next_handler=rec.handle, window_seconds=60.0)
ev = make_event(
source="firms",
category="wildfire_spotting",
severity="immediate",
title="test spotting no key",
lat=42.6,
lon=-114.5,
group_key=None,
)
g.handle(ev)
assert len(rec.received) == 1
assert g.held_count() == 0