From 7f8633aed56573c86e94c2aa9f8464ebce9aef25 Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Thu, 4 Jun 2026 06:55:27 +0000 Subject: [PATCH] fix(avalanche): v0.5.7-avalanche -- Central avalanche check + categories audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh family of the v0.5.7 NATS-and-categories campaign. Smaller scope than prior families (consumer.py unchanged): the Central side is verifiably empty, and the registry-audit gap is a single-entry add. FIX 1 -- Central avalanche adapter check: VERIFIED ABSENT in Central v0.10.0. Searched the consumer integration guide (docs/CONSUMER-INTEGRATION.md at v0.10.0-itd-511) -- zero `avalanche` / `NWAC` / `CAIC` references. Searched the producer source tree (src/central/adapters/) -- no avalanche-named adapter files. meshai already accounts for this: - meshai/central/consumer.py _SUBJECTS_BARE has no `avalanche` key, so _subjects_for("avalanche", *) returns [] regardless of region. - CENTRAL_ADAPTER_TO_SOURCE has no avalanche entry on either side. - _subject_owned() (consumer.py line 334-) explicitly logs a warning if someone flips avalanche.feed_source=central, then skips subscribing. No code change needed for FIX 1. Tests now pin these invariants so a future refactor that introduces an unexpected avalanche Central wire breaks loudly here. FIX 2 -- ALERT_CATEGORIES avalanche-family audit. Native meshai/env/avalanche.py emits two categories from the NWAC/CAIC danger-level tier: danger_level >= 4 (High, Extreme) -> avalanche_warning danger_level == 3 (Considerable) -> avalanche_watch danger_level <= 2 (Low, Moderate) -> silently dropped (not actionable) Pre-v0.5.7-avalanche registry had avalanche_warning + avalanche_considerable. avalanche_warning matched the native emit. avalanche_considerable was a LEGACY name for the Considerable-danger tier -- the native code already emits avalanche_watch for that same semantic (verified at meshai/env/avalanche.py:266; tests/test_adapter_avalanche.py:90 asserts the mapping). So avalanche_watch was MISSING from the registry, leaving the rule editor unable to target danger-level=3 emissions even though they were correctly routed to toggle="avalanche" via the `("avalanche", "avalanche")` prefix fallback. Added avalanche_watch under toggle="avalanche", default_severity="routine", with a description that points at the Considerable-tier semantics and an example_message matching the live NWAC product phrasing. composer._CATEGORY_EMOJI and _CATEGORY_LABEL gained matching entries so live LoRa rendering shows the right glyph (⛷, label "AVY"). Legacy entry kept: avalanche_considerable remains in the registry as a forward-compat target even though no current code path emits it. Reasoning matches the v0.5.7-rf precedent: - router.py source-attribution tables (lines 317, 429) reference it - composer.py emoji + label tables reference it - A future phase might re-emit avalanche_considerable as a finer-grained distinction from the generic Watch label; removing the registry entry would break any user rule currently targeting it. If avalanche_considerable remains un-emitted by v0.6, file a follow-up cleanup phase to remove it together with the rf-family hf_blackout / tropospheric_ducting legacy entries. test_alert_categories_avalanche_complete uses a SUBSET assertion (native emit ⊆ registry) so the legacy entry is allowed. Audit table after v0.5.7-avalanche: Registry avalanche (3): avalanche_warning (native danger_level >= 4) avalanche_watch [v0.5.7-avalanche NEW] (native danger_level == 3) avalanche_considerable (legacy, no current emitter) Native emit: {avalanche_warning, avalanche_watch} ⊆ Registry -- parity for everything emitted. Tests ----- PYTHONPATH=. pytest -q: 442 passed (was 431; +11 net). - tests/test_avalanche_v057.py (new): _subjects_for("avalanche", *) returns [] for every region input; avalanche absent from _SUBJECTS_BARE and CENTRAL_ADAPTER_TO_SOURCE; flipping avalanche.feed_source=central produces zero subscriptions; avalanche_watch present under toggle="avalanche" with required fields; avalanche_warning + avalanche_considerable still registry-present; native emit set equals {avalanche_warning, avalanche_watch} and is a subset of the registry. Safe-mode preserved (master off, all family toggles off, all adapters native, central disabled). No live toggle flipped. Not tagging yet -- v0.5.7 tag waits until all families ship. Co-Authored-By: Claude Opus 4.7 (1M context) --- meshai/notifications/categories.py | 22 ++++ meshai/notifications/renderers/composer.py | 6 +- tests/test_avalanche_v057.py | 139 +++++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/test_avalanche_v057.py diff --git a/meshai/notifications/categories.py b/meshai/notifications/categories.py index fe25930..c9684c6 100644 --- a/meshai/notifications/categories.py +++ b/meshai/notifications/categories.py @@ -423,6 +423,21 @@ ALERT_CATEGORIES = { }, # Environmental - Avalanche + # v0.5.7-avalanche audit (test_alert_categories_avalanche_complete enforces parity): + # Central v0.10.0 does NOT ship an avalanche adapter (verified against the + # guide TOC + producer src tree); avalanche is native-only in meshai. So + # the audit is single-source: meshai/env/avalanche.py emits exactly two + # categories based on the NWAC/CAIC danger level: + # danger_level >= 4 (High, Extreme) -> avalanche_warning + # danger_level == 3 (Considerable) -> avalanche_watch + # danger_level <= 2 (Low, Moderate) -> silently dropped (not actionable) + # + # Legacy entry kept: avalanche_considerable has no current emitter -- the + # Considerable-danger semantic now ships as avalanche_watch instead. The + # legacy registry entry remains UI-selectable as a forward-compat target + # (router.py source-attribution tables and composer.py emoji/label tables + # still reference it). Queued for cleanup if no emitter materializes by + # v0.6. "avalanche_warning": { "name": "Avalanche Danger High", "description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area", @@ -430,6 +445,13 @@ ALERT_CATEGORIES = { "example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.", "toggle": "avalanche", }, + "avalanche_watch": { + "name": "Avalanche Danger Considerable", + "description": "Avalanche danger level 3 (Considerable) — dangerous conditions on steep slopes; most avalanche fatalities occur at this level. Travel with caution and conservative decision-making.", + "default_severity": "routine", + "example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes. Cautious route-finding required.", + "toggle": "avalanche", + }, "avalanche_considerable": { "name": "Avalanche Danger Considerable", "description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level", diff --git a/meshai/notifications/renderers/composer.py b/meshai/notifications/renderers/composer.py index 65941f8..8499f3e 100644 --- a/meshai/notifications/renderers/composer.py +++ b/meshai/notifications/renderers/composer.py @@ -64,7 +64,8 @@ _CATEGORY_EMOJI: dict[str, str] = { "road_incident": "🚨", # Avalanche "avalanche_warning": "⛷", - "avalanche_considerable": "⛷", + "avalanche_watch": "⛷", # v0.5.7-avalanche + "avalanche_considerable": "⛷", # legacy / forward-compat # Mesh health "infra_offline": "⚠", "critical_node_down": "🚨", @@ -118,7 +119,8 @@ _CATEGORY_LABEL: dict[str, str] = { "road_closure": "ROADS", "traffic_congestion": "ROADS", "avalanche_warning": "AVY", - "avalanche_considerable": "AVY", + "avalanche_watch": "AVY", # v0.5.7-avalanche + "avalanche_considerable": "AVY", # legacy / forward-compat "earthquake_event": "QUAKE", "earthquake": "QUAKE", "critical_node_down": "MESH", diff --git a/tests/test_avalanche_v057.py b/tests/test_avalanche_v057.py new file mode 100644 index 0000000..a43d30c --- /dev/null +++ b/tests/test_avalanche_v057.py @@ -0,0 +1,139 @@ +"""v0.5.7-avalanche: Central avalanche check + categories audit. + +Covers two things shipped in v0.5.7-avalanche: + +1. Central avalanche adapter check -- VERIFIED ABSENT in Central v0.10.0. + The guide (docs/CONSUMER-INTEGRATION.md at v0.10.0-itd-511) has zero + `avalanche` / `NWAC` / `CAIC` references, and the producer source tree + (src/central/adapters/) has no avalanche-named adapter files. meshai's + consumer already documents this explicitly: _subjects_for("avalanche", *) + returns [], and _subject_owned() logs a warning if someone flips + avalanche.feed_source=central. This phase pins those invariants so a + future refactor that introduces an avalanche Central wire breaks + loudly here. + +2. ALERT_CATEGORIES avalanche-family audit. Native avalanche.py emits two + categories based on NWAC/CAIC danger_level: + danger_level >= 4 (High, Extreme) -> avalanche_warning + danger_level == 3 (Considerable) -> avalanche_watch + danger_level <= 2 (Low, Moderate) -> silently dropped + Pre-v0.5.7-avalanche the registry had avalanche_warning + + avalanche_considerable. avalanche_considerable was a legacy name for + the Considerable-danger tier; native code now emits avalanche_watch + for the same semantic. Added avalanche_watch in v0.5.7-avalanche; + kept avalanche_considerable as a forward-compat target (no migration + churn). +""" + +import inspect +import re + +import pytest + +from meshai.central.consumer import ( + CENTRAL_ADAPTER_TO_SOURCE, + CentralConsumer, + _SUBJECTS_BARE, + _subjects_for, +) +from meshai.config import EnvironmentalConfig +from meshai.notifications.categories import ALERT_CATEGORIES + + +# ---------- FIX 1: Central has no avalanche adapter ----------------------- + + +def test_avalanche_has_no_central_subscription(): + """_subjects_for returns empty for the avalanche source regardless of + region (no Central counterpart exists in v0.10.0).""" + for region in ("us.id", "us.mt", "us.co", "", None): + assert _subjects_for("avalanche", region) == [], \ + f"unexpected subjects for region={region!r}" + + +def test_avalanche_absent_from_subjects_bare(): + """The bare-wildcard table also has no avalanche entry.""" + assert "avalanche" not in _SUBJECTS_BARE + + +def test_avalanche_absent_from_central_adapter_remap(): + """No Central adapter name remaps to meshai's 'avalanche' source.""" + assert "avalanche" not in CENTRAL_ADAPTER_TO_SOURCE.values(), \ + f"unexpected avalanche remap entry: {CENTRAL_ADAPTER_TO_SOURCE}" + + +def test_avalanche_feed_source_central_subscribes_nothing(): + """If a user accidentally sets avalanche.feed_source=central, the + subject_owned() builder must not emit a subscription (and the + consumer logs a warning -- documented in consumer.py).""" + env = EnvironmentalConfig() + env.avalanche.feed_source = "central" + so = CentralConsumer(env, None)._subject_owned() + # No subjects added for avalanche; nothing to subscribe to. + assert not any("avalanche" in s.lower() for s in so.keys()) + + +# ---------- FIX 2: ALERT_CATEGORIES avalanche-family audit --------------- + + +def test_avalanche_watch_in_registry(): + """v0.5.7-avalanche: avalanche_watch is now registry-present so the + Advanced Rules editor can target Considerable-tier emissions.""" + assert "avalanche_watch" in ALERT_CATEGORIES + info = ALERT_CATEGORIES["avalanche_watch"] + assert info["toggle"] == "avalanche" + assert info["default_severity"] == "routine" + assert info["name"] + assert info["description"] + assert info["example_message"] + + +def test_avalanche_warning_still_in_registry(): + """Pre-v0.5.7-avalanche entry survives the edit.""" + assert "avalanche_warning" in ALERT_CATEGORIES + assert ALERT_CATEGORIES["avalanche_warning"]["toggle"] == "avalanche" + + +def test_avalanche_considerable_legacy_kept(): + """avalanche_considerable kept as forward-compat / legacy target even + though no current code path emits it. Documented in the commit body + and categories.py inline note for future cleanup.""" + assert "avalanche_considerable" in ALERT_CATEGORIES + assert ALERT_CATEGORIES["avalanche_considerable"]["toggle"] == "avalanche" + + +def _native_emitted_avalanche_categories() -> set[str]: + """Walk avalanche.py for category= literals routing to toggle=avalanche.""" + from meshai.env import avalanche as aval_mod + src = inspect.getsource(aval_mod) + emitted = set(re.findall(r'category\s*=\s*"([a-z_]+)"', src)) + return {c for c in emitted if c in ALERT_CATEGORIES + and ALERT_CATEGORIES[c].get("toggle") == "avalanche"} + + +def test_alert_categories_avalanche_complete(): + """Every category native avalanche.py emits must have a registry entry + under toggle='avalanche'. Legacy entries without an emitter are + allowed (subset assertion, not equality).""" + registry_avalanche = { + cid for cid, info in ALERT_CATEGORIES.items() + if info.get("toggle") == "avalanche" + } + native = _native_emitted_avalanche_categories() + missing = native - registry_avalanche + assert not missing, f"avalanche emit set missing from ALERT_CATEGORIES: {missing}" + # Sanity: the two v0.5.7-avalanche-recognized categories are both there. + assert "avalanche_warning" in native, "native should emit avalanche_warning" + assert "avalanche_watch" in native, "native should emit avalanche_watch" + + +@pytest.mark.parametrize( + "cat", ["avalanche_warning", "avalanche_watch", "avalanche_considerable"], +) +def test_avalanche_categories_have_required_fields(cat): + info = ALERT_CATEGORIES[cat] + assert info["toggle"] == "avalanche" + assert info["name"] + assert info["description"] + assert info["default_severity"] in {"routine", "priority", "immediate"} + assert info["example_message"]