mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
Fourth family of the v0.5.7 NATS-and-categories campaign. Seismic = USGS quake adapter (the USGS water/hydro side stays under toggle="seismic" per the v0.5.2 geohazards migration but lives in the water phase that follows).
The Central v0.10.0 consumer integration guide (docs/CONSUMER-INTEGRATION.md on the v0.10.0-itd-511 branch) was treated as source of truth -- ground-truthing the prompt against the guide caught two prompt errors before they shipped (mirrors the FIRMS situation in v0.5.7-fire). Documented below.
FIX 1 -- USGS quake NATS pattern. Pre-v0.5.7-seismic `_subjects_for("usgs_quake","us.id")` returned `["central.quake.event.>.us.id"]`. That subject is BOTH invalid NATS (`>` is only legal at the tail token) AND wouldn't have matched anything Central publishes.
Per Central v0.10.0 guide §usgs_quake the actual published subject is `central.quake.event.<tier>` -- 4 tokens, no region. `<tier>` is one of {minor, light, moderate, strong, major, great} (USGS magnitude bands; bands live in the SUBJECT, not in the severity integer).
Note on prompt vs. guide discrepancy: the v0.5.7-seismic prompt described a "regional v0.9.20+ shape" `central.quake.event.<severity>.us.<state>` with 6 tokens and `us.<state>` at the tail. That's neither what Central v0.10.0 publishes nor what its guide documents. We follow the guide. Subscribing to the prompt's shape would silently match zero messages in production. State filtering for quakes happens client-side via data.latitude/longitude (same situation as FIRMS).
New subscription: `central.quake.event.>` -- tail-only `>`, NATS-legal, matches all <tier> values.
FIX 2 -- severity=5 great-quake clamp (no actual bug; regression-guard pin). The prompt described a "severity=5 IndexError or silent drop" failure mode. Investigation found NO such bug exists in the current code:
- consumer.map_severity already clamps `sev >= 3` to "immediate". A
severity=5 (or 99, or any 3+) maps safely to "immediate" with no
exception path.
- NotificationToggle.severity_channels is dict-keyed by severity STRING
({"routine","priority","immediate"}), not an int-indexed list, so
IndexError is structurally impossible from this boundary regardless
of upstream value.
- Per Central v0.10.0 guide §5b the documented severity vocabulary is
`0-4 or None`. Severity=5 is not in the published contract; the
clamp is defensive padding against future contract drift.
Three things were tightened anyway: (a) the map_severity docstring now explicitly documents the high-side clamp behavior and calls out the string-keyed dict guarantee; (b) parametrized test pins map_severity for the full 0..99 range including out-of-contract values; (c) an end-to-end synthetic-envelope test injects a severity=5 quake through _handle and asserts the resulting Event has severity="immediate" / category="earthquake_event" / source="usgs_quake" with no exception. These tests function as regression guards if a future refactor introduces the IndexError vector the prompt was guarding against.
FIX 3 -- ALERT_CATEGORIES seismic-family audit. The registry was MISSING `earthquake_event` entirely. Both native (`usgs_quake.py` emits `category="earthquake_event"`) and central (consumer._CATEGORY_MAP maps `quake.* -> earthquake_event`) paths produce that category, but get_category("earthquake_event") fell through to the mesh_health default -- so the Advanced Rules editor couldn't target quakes at all. The get_toggle() prefix fallback DID route it to "seismic" via the `("earthquake", "seismic")` rule, so events were filtered correctly; the gap was UI-selectability only.
Added the entry under toggle="seismic" with a representative example_message. composer.py already had matching emoji/label mappings (line 78-79, 107-108) from earlier work, no composer change needed.
The two hydro entries (`stream_flood_warning`, `stream_high_water`) also live under toggle="seismic" via the v0.5.2 USGS-water migration (Geohazards family in the GUI). They are OUT OF SCOPE for v0.5.7-seismic -- they belong to the water phase that follows. Verified-unchanged here so the next phase has a clean baseline.
Audit table after v0.5.7-seismic:
Native emit: usgs_quake.py -> earthquake_event
Central path: all 6 tiers (minor/light/moderate/strong/major/great) -> earthquake_event
Registry: {earthquake_event, stream_flood_warning, stream_high_water}
Quake side: parity (registry has earthquake_event; native + central emit it)
Hydro side: verified-unchanged (deferred to water phase)
Tests
-----
PYTHONPATH=. pytest -q: 400 passed (was 380; +20 net).
- tests/test_seismic_v057.py (new): quake subject tail-only `>`; no mid-subject `>`; bare-form backward compat; parametrized map_severity full range 0..99 + None / nonsense / negative; synthetic severity=5 envelope routes through _handle to severity="immediate" cleanly; NotificationToggle.severity_channels shape pinned to dict (no IndexError vector); earthquake_event present under toggle="seismic"; hydro entries still toggle="seismic" (regression guard); native + central-path quake emit set equals {earthquake_event}; required-fields check.
- tests/test_central_region_routing.py: updated `test_subjects_for_usgs_quake_us_id` -> `test_subjects_for_usgs_quake_us_id_uses_tail_only_wildcard` reflecting the guide-correct shape.
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) <noreply@anthropic.com>
199 lines
8.2 KiB
Python
199 lines
8.2 KiB
Python
"""v0.5.7-seismic: USGS quake NATS pattern + severity clamp + categories audit.
|
|
|
|
Covers three things shipped in v0.5.7-seismic:
|
|
|
|
1. USGS quake subject pattern -- per Central v0.10.0 guide §usgs_quake the
|
|
pattern is `central.quake.event.<tier>` (4 tokens, NO region). Pre-v0.5.7
|
|
we shipped `central.quake.event.>.us.id` which is invalid NATS (`>`
|
|
mid-subject) AND wouldn't have matched anything Central publishes.
|
|
2. Severity clamp -- documents/regression-tests the existing `map_severity`
|
|
behavior. The v0.5.7-seismic prompt described a "severity=5 great-quake
|
|
IndexError / drop" bug; investigation confirmed that bug does NOT exist:
|
|
- map_severity already clamps any int >= 3 to "immediate"
|
|
(so severity=5, 99, etc. all map safely).
|
|
- NotificationToggle.severity_channels is dict-keyed by severity STRING
|
|
({"routine","priority","immediate"}), not int -- IndexError is
|
|
structurally impossible from this boundary.
|
|
- Per the guide §5b severity vocabulary is documented as 0-4 only;
|
|
severity=5 is not in Central's contract. The clamp is defensive
|
|
padding against contract drift.
|
|
These tests pin the clamp so a future regression doesn't introduce the
|
|
bug Matt was guarding against.
|
|
3. ALERT_CATEGORIES seismic-family audit -- earthquake_event was MISSING
|
|
from the registry. Native usgs_quake.py emits it and the central path
|
|
maps every quake.event.<tier> to it via map_category, but the
|
|
Advanced Rules editor couldn't select it (it fell through to
|
|
get_category's mesh_health default). Added in v0.5.7-seismic. The
|
|
hydro entries (stream_flood_warning / stream_high_water under
|
|
toggle='seismic' from v0.5.2) are out of scope; this audit only adds
|
|
the quake side and verifies hydro toggles are unchanged.
|
|
"""
|
|
|
|
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
|
|
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: USGS quake subject pattern -----------------------------
|
|
|
|
|
|
def test_usgs_quake_subject_uses_tail_only_wildcard():
|
|
"""Per Central v0.10.0 guide §usgs_quake: `central.quake.event.<tier>`,
|
|
4 tokens, no region. Tail-only `>` is the legal wildcard form."""
|
|
subs = _subjects_for("usgs_quake", "us.id")
|
|
assert subs == ["central.quake.event.>"]
|
|
for s in subs:
|
|
_assert_legal_nats(s)
|
|
|
|
|
|
def test_usgs_quake_subject_has_no_mid_subject_wildcard():
|
|
"""Belt-and-braces NATS-syntax check."""
|
|
for s in _subjects_for("usgs_quake", "us.id"):
|
|
tokens = s.split(".")
|
|
for tok in tokens[:-1]:
|
|
assert tok != ">", f"`>` mid-subject in {s!r}"
|
|
|
|
|
|
def test_usgs_quake_bare_form_unchanged():
|
|
"""Empty region falls back to the broader bare wildcard for backward compat."""
|
|
assert _subjects_for("usgs_quake", "") == ["central.quake.>"]
|
|
|
|
|
|
# ---------- FIX 2: severity clamp regression guard ------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("sev,expected", [
|
|
(0, "routine"),
|
|
(1, "routine"),
|
|
(2, "priority"),
|
|
(3, "immediate"),
|
|
(4, "immediate"),
|
|
# v0.5.7-seismic regression guard: hypothetical "great quake" severity=5
|
|
# (not in the Central v0.10.0 contract, but defensible if it ever appears)
|
|
# MUST clamp to "immediate", not raise / not drop.
|
|
(5, "immediate"),
|
|
(10, "immediate"),
|
|
(99, "immediate"),
|
|
# Edge cases that previously degraded to "routine".
|
|
(None, "routine"),
|
|
("nonsense", "routine"),
|
|
(-1, "routine"),
|
|
])
|
|
def test_map_severity_handles_full_range(sev, expected):
|
|
assert map_severity(sev) == expected
|
|
|
|
|
|
def test_severity_5_quake_routes_through_consumer_without_crashing():
|
|
"""Inject a synthetic Central quake envelope with severity=5 (out-of-
|
|
contract great-quake hypothetical) and verify it normalizes cleanly
|
|
into an Event with severity='immediate' -- no IndexError, no drop."""
|
|
rec = []
|
|
bus = EventBus(); bus.subscribe(rec.append)
|
|
c = CentralConsumer(EnvironmentalConfig(), bus)
|
|
env = {"id": "us8000mc12", "data": {
|
|
"id": "us8000mc12", "adapter": "usgs_quake",
|
|
"category": "quake.event.great",
|
|
"time": "2026-05-19T02:50:39+00:00",
|
|
"severity": 5, # the out-of-contract value
|
|
"geo": {"centroid": [-148.93, 61.32], "primary_region": "US-AK", "regions": ["US-AK"]},
|
|
"data": {"title": "M 8.2 - 23 km ESE of Anchorage, AK",
|
|
"magnitude": 8.2, "depth": 32.0, "magType": "mw",
|
|
"alert": "red", "tsunami": 1, "type": "earthquake"}}}
|
|
ev = c._handle("central.quake.event.great", json.dumps(env).encode())
|
|
assert ev is not None
|
|
assert ev.severity == "immediate"
|
|
assert ev.category == "earthquake_event"
|
|
assert ev.source == "usgs_quake"
|
|
assert len(rec) == 1
|
|
|
|
|
|
def test_severity_channels_is_string_keyed_no_int_indexerror_risk():
|
|
"""The shape that would make severity=5 dangerous is an int-indexed
|
|
list; ours is a dict keyed by severity STRING. This pins that contract
|
|
so a refactor can't quietly introduce the IndexError vector."""
|
|
from meshai.config import NotificationToggle
|
|
t = NotificationToggle(name="seismic")
|
|
assert isinstance(t.severity_channels, dict)
|
|
# dict.get with an unknown key returns None / default, never raises.
|
|
assert t.severity_channels.get("any_string", []) == []
|
|
|
|
|
|
# ---------- FIX 3: seismic-family categories audit ------------------------
|
|
|
|
|
|
def test_earthquake_event_in_registry():
|
|
"""v0.5.7-seismic: registry now has earthquake_event so the Advanced
|
|
Rules editor can target it. Pre-v0.5.7-seismic it was missing entirely
|
|
and fell through to the mesh_health default via get_category()."""
|
|
assert "earthquake_event" in ALERT_CATEGORIES
|
|
assert ALERT_CATEGORIES["earthquake_event"]["toggle"] == "seismic"
|
|
|
|
|
|
def test_hydro_entries_still_seismic_toggle():
|
|
"""The v0.5.2 USGS-water migration to toggle='seismic' (geohazards
|
|
family in the GUI) must survive the v0.5.7-seismic edit. Out of scope
|
|
for THIS phase to modify; in scope to verify-unchanged."""
|
|
assert ALERT_CATEGORIES["stream_flood_warning"]["toggle"] == "seismic"
|
|
assert ALERT_CATEGORIES["stream_high_water"]["toggle"] == "seismic"
|
|
|
|
|
|
def _native_emitted_quake_categories() -> set[str]:
|
|
"""Walk usgs_quake.py for category= literals routing to toggle=seismic."""
|
|
from meshai.env import usgs_quake as quake_mod
|
|
src = inspect.getsource(quake_mod)
|
|
emitted = set(re.findall(r'category="([a-z_]+)"', src))
|
|
return {c for c in emitted if c in ALERT_CATEGORIES
|
|
and ALERT_CATEGORIES[c].get("toggle") == "seismic"}
|
|
|
|
|
|
def _central_path_quake_categories() -> set[str]:
|
|
central_inputs = [
|
|
"quake.event.minor", "quake.event.light", "quake.event.moderate",
|
|
"quake.event.strong", "quake.event.major", "quake.event.great",
|
|
]
|
|
return {map_category(c) for c in central_inputs}
|
|
|
|
|
|
def test_alert_categories_quake_complete():
|
|
"""Every quake-side category that meshai emits (native or central path)
|
|
must have an ALERT_CATEGORIES entry under toggle='seismic'. Hydro
|
|
entries are out of scope for this audit but kept as a control."""
|
|
native = _native_emitted_quake_categories()
|
|
central = _central_path_quake_categories()
|
|
emitted = native | central
|
|
# All six tiers should fold to earthquake_event via the central path.
|
|
assert emitted == {"earthquake_event"}, f"unexpected quake emit set: {emitted}"
|
|
assert "earthquake_event" in ALERT_CATEGORIES
|
|
|
|
|
|
def test_seismic_family_required_fields():
|
|
info = ALERT_CATEGORIES["earthquake_event"]
|
|
assert info["toggle"] == "seismic"
|
|
assert info["name"]
|
|
assert info["description"]
|
|
assert info["default_severity"] in {"routine", "priority", "immediate"}
|
|
assert info["example_message"]
|