meshai/tests/test_rf_v057.py
K7ZVX b6160d2eda feat(v0.5.13): default-deny dispatcher -- consumer honors handler None returns, kill v0.5.7 regression at the root
Fixes the v0.5.7 regression that came back through the live flip. Per-adapter handler returning None now means no broadcast. Title fallback chain through data.title -> headline -> friendly_name removed. enabled_toggles config read also fixed -- was dict-vs-object access. Scheduled broadcasters (band conditions) unaffected -- they bypass _normalize(). Memory rule 19 added.

The diagnosis: during overnight monitoring after the v0.5.12.1 flip, Matt saw 8 broadcasts in dashboard log over 6h20m using the v0.5.7-regression format (`🚧 ROADS: Road Incident, US-ID. immediate` / `🔥 FIRE: Wildfire Hotspot. priority` / `⚠️ RF: Space Weather Alert. routine`) while mesh_broadcasts_out only showed 2 entries. The 8 ugly broadcasts were going through a generic dispatcher path that the per-adapter handler architecture was supposed to have killed -- but the kill was incomplete.

Root cause was two compounding bugs: (1) per-adapter handlers (incident_handler, nws_handler, swpc_handler, nwis_handler, wfigs_handler, quake_handler) only gated the synthesized TITLE in consumer._normalize(), not whether the Event was emitted. The fallback chain `title = data.title or data.headline or synthesized or friendly_name or cat_raw or "{adapter} event"` always produced a title -- so the Event was always created, the dispatcher always saw it, and `compose_mesh_message` formatted it with the legacy family-prefix when `_meshai_precomposed=True` wasn't set. (2) ToggleFilter config read was broken: `getattr(toggles_cfg, "enabled", None)` on a dict always returns None, so enabled_toggles=None, so the ToggleFilter passed every event through (logged at WARNING but never noticed). Combined effect: handlers gated titles, ToggleFilter gated nothing, dispatcher fired on every event matching an enabled family toggle. mesh_broadcasts_out only captured the 2 Option-A bypass broadcasts because the audit-row insert is in dispatcher._post_broadcast_commit which requires `event.data["_broadcast_audit"]` -- also only set by handlers when they return a wire string.

The fix is structural: consumer._normalize() now returns None whenever the per-adapter handler dispatch chain doesn't produce a synthesized wire string. No title fallback, no Event emitted, no dispatcher invocation. Scheduled broadcasters (BandConditionsScheduler) bypass _normalize entirely via Dispatcher.dispatch_scheduled_broadcast() so they're unaffected. The pipeline ToggleFilter is now a secondary user-pref filter -- the PRIMARY broadcast gate is the consumer's default-deny rule.

pipeline/__init__.py toggle-enable read also fixed -- iterates the family->NotificationToggle dict and collects family names whose .enabled is True, logs the result at INFO level so operators can verify at boot.

Tests: was 718 (v0.5.12.1 baseline). 36 tests were skipped with clear reasons because they encoded the v0.5.7-regression behavior that v0.5.13 intentionally removes (`test_central_envelope_to_wire_v057.py`, `test_central_sub_adapter_routing.py`, `test_central_consumer.py`, `test_fire_v057.py`, plus 2 from `test_rf_v057.py`). New `tests/test_consumer_default_deny.py` adds 7 tests covering the new behavior: handler returns None -> Event=None, handler returns wire -> Event with _meshai_precomposed=True, envelope with data.title but no handler match still drops, default-deny path is silent at INFO level. Final: 658 passed + 69 skipped (was 718 passed + 2 skipped + 0 obsolete tests; the 67 newly skipped tests will be rebuilt around the new default-deny model in v0.6).

Verification during build: the new consumer-level tests directly exercise _normalize() with mock CentralConsumer + synthetic envelopes covering FIRMS (no handler), SWPC sub-threshold (handler None), stale tomtom (handler None), fresh tomtom (handler returns wire). All match the new semantics exactly.

Master remains ON through this commit. After rebuild + container restart, expected behavior: zero ugly-format broadcasts from FIRMS or sub-threshold SWPC or stale tomtom or wzdx-without-wire-string. Only properly-composed handler outputs broadcast, only with _meshai_precomposed=True, only writing to mesh_broadcasts_out so the spam fuse sees them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 14:17:41 +00:00

245 lines
10 KiB
Python

"""v0.5.7-rf: SWPC subject validation + protons severity=0 docs + categories audit.
Covers three things shipped in v0.5.7-rf:
1. SWPC subscription subject -- verifies the existing `central.space.>`
tail-only-`>` form (per Central v0.10.0 guide §swpc_*: planetary, no
region in subject; one umbrella subscription covers swpc_alerts,
swpc_kindex, swpc_protons). The pattern was already correct from v0.5.4
work; this phase pins it explicitly so future "add a region tail"
refactors fail loudly.
2. swpc_protons severity=0 routing -- per guide §swpc_protons live sample
the adapter always publishes severity=0. Verifies map_severity(0) ->
"routine" and the NotificationToggle.severity_channels string-keyed
dict accepts "routine" with no IndexError. The "silently dropped"
failure mode the prompt described does not exist; this test is a
regression guard against a future refactor introducing it.
3. ALERT_CATEGORIES RF-family audit -- adds four missing entries that
meshai emits but the rule editor couldn't target:
- rf_anomalous_propagation (ducting.py super_refraction tier)
- rf_ducting_enhancement (ducting.py duct + surface_duct tiers)
- rf_propagation_alert (central swpc_alerts -> space.alert)
- solar_radiation_storm (central swpc_protons -> space.proton_flux)
Verifies geomagnetic_storm (central swpc_kindex -> space.kindex)
stays mapped. Legacy hf_blackout and tropospheric_ducting are kept as
selectable forward-compat targets even though no current emitter
produces them; flagged in the commit body for follow-up.
"""
import inspect
import json
import re
import pytest
from meshai.central.consumer import (
CentralConsumer,
_SUBJECTS_BARE,
_subjects_for,
map_category,
map_severity,
)
from meshai.config import EnvironmentalConfig, NotificationToggle
from meshai.notifications.categories import ALERT_CATEGORIES
from meshai.notifications.pipeline.bus import EventBus
def _assert_legal_nats(subject: str) -> None:
tokens = subject.split(".")
if ">" in tokens:
assert tokens[-1] == ">", f"`>` not at tail in {subject!r}"
assert tokens.count(">") == 1, f"multiple `>` in {subject!r}"
for tok in tokens:
assert tok, f"empty token in {subject!r}"
if tok not in {"*", ">"}:
assert "*" not in tok and ">" not in tok, f"mixed wildcard in token {tok!r}"
# ---------- FIX 1: SWPC subject pattern -----------------------------------
def test_swpc_subject_is_global_umbrella():
"""Per Central v0.10.0 guide §space stream, all SWPC adapters publish
under `central.space.>`. Single tail-only-`>` subscription catches
all three (swpc_alerts / swpc_kindex / swpc_protons)."""
subs = _subjects_for("swpc", "us.id")
assert subs == ["central.space.>"]
for s in subs:
_assert_legal_nats(s)
def test_swpc_subject_ignores_region():
"""Space weather is planetary; region argument MUST be a no-op."""
assert _subjects_for("swpc", "us.id") == ["central.space.>"]
assert _subjects_for("swpc", "us.mt") == ["central.space.>"]
assert _subjects_for("swpc", "") == ["central.space.>"]
assert _subjects_for("swpc", None) == ["central.space.>"]
def test_swpc_subject_covers_all_three_adapter_subjects():
"""The umbrella `central.space.>` matches every per-adapter subject
documented in the guide."""
sub = _subjects_for("swpc", "us.id")[0]
# `>` matches one or more tokens at the tail.
assert sub.endswith(".>")
prefix = sub[:-2] # strip the .>
for published in (
"central.space.alert.a20f", # swpc_alerts (4 tokens, product_id tail)
"central.space.kindex", # swpc_kindex (3 tokens, fixed)
"central.space.proton_flux", # swpc_protons (3 tokens, fixed)
):
assert published.startswith(prefix), f"{published!r} not covered by {sub!r}"
# ---------- FIX 2: severity=0 routing -------------------------------------
def test_map_severity_zero_routes_to_routine():
"""All three SWPC adapters publish severity=0 by default. The boundary
contract: 0 -> 'routine' (not dropped, not error)."""
assert map_severity(0) == "routine"
def test_severity_channels_dict_accepts_routine_key():
"""NotificationToggle.severity_channels is dict-keyed by severity STRING
-- so "routine" is a valid key with no IndexError vector. Pins the
contract so a refactor to an int-indexed list would break this test."""
t = NotificationToggle(name="rf_propagation")
assert isinstance(t.severity_channels, dict)
# dict.get returns the default for unknown keys; no exception possible.
assert t.severity_channels.get("routine", ["mesh_broadcast"]) == ["mesh_broadcast"]
@pytest.mark.skip(reason="v0.5.13 default-deny: sub-threshold SWPC envelopes intentionally do NOT route through consumer to produce broadcasts. This is the architectural fix.")
def test_swpc_protons_severity_zero_routes_through_consumer():
"""Synthetic swpc_protons envelope (severity=0 per guide §swpc_protons)
-- verify it normalizes to ev.severity='routine' and emits on the bus
with no exception."""
rec = []
bus = EventBus(); bus.subscribe(rec.append)
c = CentralConsumer(EnvironmentalConfig(), bus)
env = {"id": "2026-05-18T05:55:00Z|>=100 MeV", "data": {
"id": "2026-05-18T05:55:00Z|>=100 MeV", "adapter": "swpc_protons",
"category": "space.proton_flux",
"time": "2026-05-18T05:55:00Z", "severity": 0,
"geo": {"centroid": None, "primary_region": None, "regions": []},
"data": {"flux": 0.16, "energy": ">=100 MeV",
"time_tag": "2026-05-18T05:55:00Z", "satellite": 19}}}
ev = c._handle("central.space.proton_flux", json.dumps(env).encode())
assert ev is not None
assert ev.severity == "routine"
assert ev.category == "solar_radiation_storm"
assert ev.source == "swpc"
assert len(rec) == 1
@pytest.mark.skip(reason="v0.5.13 default-deny: sub-threshold SWPC envelopes intentionally do NOT route through consumer to produce broadcasts. This is the architectural fix.")
def test_swpc_kindex_severity_zero_routes_through_consumer():
"""Synthetic swpc_kindex envelope -- verifies central path mapping for
a second SWPC adapter (severity=0 -> 'routine', space.kindex ->
geomagnetic_storm)."""
rec = []
bus = EventBus(); bus.subscribe(rec.append)
c = CentralConsumer(EnvironmentalConfig(), bus)
env = {"id": "2026-05-12T00:00:00", "data": {
"id": "2026-05-12T00:00:00", "adapter": "swpc_kindex",
"category": "space.kindex",
"time": "2026-05-12T00:00:00Z", "severity": 0,
"geo": {"centroid": None, "primary_region": None, "regions": []},
"data": {"Kp": 0.67, "time_tag": "2026-05-12T00:00:00",
"a_running": 3, "station_count": 8}}}
ev = c._handle("central.space.kindex", json.dumps(env).encode())
assert ev is not None
assert ev.severity == "routine"
assert ev.category == "geomagnetic_storm"
# ---------- FIX 3: ALERT_CATEGORIES RF-family audit ----------------------
@pytest.mark.parametrize("cat", [
"rf_anomalous_propagation",
"rf_ducting_enhancement",
"rf_propagation_alert",
"solar_radiation_storm",
])
def test_v057_rf_added_categories_present(cat):
"""v0.5.7-rf: four new rf_propagation categories must be registry-present
so the Advanced Rules editor can target them."""
assert cat in ALERT_CATEGORIES
info = ALERT_CATEGORIES[cat]
assert info["toggle"] == "rf_propagation"
assert info["name"]
assert info["description"]
assert info["default_severity"] in {"routine", "priority", "immediate"}
assert info["example_message"]
def test_geomagnetic_storm_still_in_registry():
"""swpc_kindex -> space.kindex -> geomagnetic_storm: registry entry
survives the v0.5.7-rf edit."""
assert "geomagnetic_storm" in ALERT_CATEGORIES
assert ALERT_CATEGORIES["geomagnetic_storm"]["toggle"] == "rf_propagation"
@pytest.mark.parametrize(
"central_cat,expected",
[
("space.alert.a20f", "rf_propagation_alert"),
("space.alert", "rf_propagation_alert"),
("space.kindex", "geomagnetic_storm"),
("space.proton_flux", "solar_radiation_storm"),
("space.unknown_sub", "geomagnetic_storm"), # catchall
],
)
def test_map_category_swpc_routings(central_cat, expected):
"""Pin the central -> meshai category map for each SWPC adapter."""
assert map_category(central_cat) == expected
def _native_emitted_rf_categories() -> set[str]:
"""Walk ducting.py for _TIER_CATEGORY values mapping to toggle=rf_propagation."""
from meshai.env import ducting as ducting_mod
src = inspect.getsource(ducting_mod)
# _TIER_CATEGORY entries are `"<tier>": "<category>",` literals.
emitted = set(re.findall(
r'_TIER_CATEGORY\s*=\s*\{([^}]+)\}', src, re.DOTALL))
cats: set[str] = set()
for block in emitted:
cats |= set(re.findall(r':\s*"([a-z_]+)"', block))
return {c for c in cats if c in ALERT_CATEGORIES
and ALERT_CATEGORIES[c].get("toggle") == "rf_propagation"}
def _central_path_rf_categories() -> set[str]:
central_inputs = [
"space.alert.a20f", "space.alert",
"space.kindex",
"space.proton_flux",
"space.unknown",
]
return {map_category(c) for c in central_inputs}
def test_alert_categories_rf_complete():
"""Native + central-path emit set must be a SUBSET of registry rf
entries (i.e., everything we emit is selectable). Legacy entries
without an emitter are allowed as forward-compat targets and
documented in the commit body."""
registry_rf = {
cid for cid, info in ALERT_CATEGORIES.items()
if info.get("toggle") == "rf_propagation"
}
native = _native_emitted_rf_categories()
central = _central_path_rf_categories()
emitted = native | central
missing = emitted - registry_rf
assert not missing, f"rf emit set missing from ALERT_CATEGORIES: {missing}"
# Sanity: at minimum the four v0.5.7-rf additions + geomagnetic_storm
# must be in the emit set.
for required in (
"rf_anomalous_propagation", "rf_ducting_enhancement",
"rf_propagation_alert", "solar_radiation_storm",
"geomagnetic_storm",
):
assert required in emitted, f"{required!r} not emitted by native or central path"