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>
116 lines
5.3 KiB
Python
116 lines
5.3 KiB
Python
"""v0.5.4: Central v0.9.20 region-aware subject building.
|
|
|
|
Exercises `_subjects_for(adapter, region)` and the wiring through
|
|
`CentralConsumer._subject_owned()`. The spec is hard-coded in the test
|
|
strings on purpose so a future drift in the v0.9.20 subject scheme
|
|
fails noisily here instead of silently shipping wrong filters.
|
|
"""
|
|
|
|
from meshai.central.consumer import (
|
|
CentralConsumer,
|
|
_subjects_for,
|
|
_SUBJECTS_BARE,
|
|
)
|
|
from meshai.config import EnvironmentalConfig
|
|
|
|
|
|
# --------------------------------------------------------------------- per-adapter
|
|
|
|
def test_subjects_for_nws_us_id():
|
|
"""NWS: region BEFORE wildcard (matches alert.<region>.<...>)."""
|
|
assert _subjects_for("nws", "us.id") == ["central.wx.alert.us.id.>"]
|
|
|
|
|
|
def test_subjects_for_usgs_quake_us_id_uses_tail_only_wildcard():
|
|
"""v0.5.7-seismic: USGS quake publishes `central.quake.event.<tier>` with
|
|
NO region in the subject (per Central v0.10.0 guide §usgs_quake; same
|
|
situation as FIRMS). The pre-v0.5.7-seismic `central.quake.event.>.us.id`
|
|
was syntactically invalid (`>` mid-subject) AND wouldn't have matched
|
|
anything Central publishes (only 4 tokens, no us.<state>). Region
|
|
filtering for quakes now happens client-side via data.latitude/longitude.
|
|
Subscription uses tail-only `>` (NATS-legal)."""
|
|
assert _subjects_for("usgs_quake", "us.id") == ["central.quake.event.>"]
|
|
|
|
|
|
def test_subjects_for_firms_us_id_uses_tail_only_wildcard():
|
|
"""v0.5.7-fire: FIRMS publishes `central.fire.hotspot.<satellite>.<confidence>`
|
|
with NO region in the subject (per Central v0.10.0 guide §firms). The
|
|
pre-v0.5.7-fire `central.fire.hotspot.>.us.id` was syntactically invalid
|
|
(`>` mid-subject) AND wouldn't have matched anything Central actually
|
|
publishes. Region filtering for FIRMS now happens client-side via
|
|
data.latitude/longitude. Subscription uses tail-only `>` (NATS-legal)."""
|
|
assert _subjects_for("firms", "us.id") == ["central.fire.hotspot.>"]
|
|
|
|
|
|
def test_subjects_for_fires_us_id_includes_tombstones():
|
|
"""v0.5.7-fire: WFIGS subjects -- active state-token at depth-3 + the
|
|
removal-tombstone subjects (`central.fire.{incident,perimeter}.removed.<state>`)
|
|
per Central v0.10.0 guide §wfigs_incidents §wfigs_perimeters. Pre-v0.5.7-fire
|
|
we only subscribed to active subjects, silently dropping fall-off signals."""
|
|
assert _subjects_for("fires", "us.id") == [
|
|
"central.fire.incident.id.>",
|
|
"central.fire.perimeter.id.>",
|
|
"central.fire.incident.removed.id",
|
|
"central.fire.perimeter.removed.id",
|
|
]
|
|
|
|
|
|
def test_subjects_for_traffic_uses_convention_b():
|
|
"""v0.5.7-traffic: traffic adapter -> bare-state Convention B with `*`
|
|
in the event_type slot. Pre-v0.5.7-traffic this was `>.{state}` which
|
|
is invalid NATS (`>` must be at the tail). The bare-state subject is
|
|
shared with roads511 (sub-adapter routing picks the right meshai source)."""
|
|
assert _subjects_for("traffic", "us.id") == ["central.traffic.*.id"]
|
|
|
|
|
|
def test_subjects_for_roads511_dual_subscribes_convention_a_and_b():
|
|
"""v0.5.7-traffic: roads511 owns BOTH the shared bare-state subject
|
|
(Convention B, shared with traffic) AND the us.<state> subject
|
|
(Convention A) where the new Idaho-only itd_511 adapter publishes."""
|
|
assert _subjects_for("roads511", "us.id") == [
|
|
"central.traffic.*.id",
|
|
"central.traffic.*.us.id",
|
|
]
|
|
|
|
|
|
def test_subjects_for_usgs_includes_unknown_workaround():
|
|
"""USGS hydro: subscribes to BOTH the region-tagged filter and the
|
|
".unknown" filter to cover gauges whose state Central v0.9.20 can't
|
|
infer yet (workaround until v0.9.20.1 backfills the tag)."""
|
|
assert _subjects_for("usgs", "us.id") == [
|
|
"central.hydro.>.us.id",
|
|
"central.hydro.>.unknown",
|
|
]
|
|
|
|
|
|
def test_subjects_for_swpc_stays_global():
|
|
"""SWPC: space weather is planetary; region argument is ignored."""
|
|
assert _subjects_for("swpc", "us.id") == ["central.space.>"]
|
|
assert _subjects_for("swpc", "us.mt") == ["central.space.>"] # same regardless
|
|
assert _subjects_for("swpc", "") == ["central.space.>"]
|
|
|
|
|
|
# --------------------------------------------------------------------- backward compat
|
|
|
|
def test_subjects_for_empty_region_falls_back_to_bare_wildcards():
|
|
"""Empty/None region = pre-v0.9.20 behaviour for every adapter, byte-identical
|
|
to the legacy _SUBJECTS_BARE map. Adapters absent from the map return []."""
|
|
for adapter, expected in _SUBJECTS_BARE.items():
|
|
assert _subjects_for(adapter, "") == expected, f"empty region mismatch for {adapter}"
|
|
assert _subjects_for(adapter, None) == expected, f"None region mismatch for {adapter}"
|
|
# Unknown adapters return empty regardless of region.
|
|
assert _subjects_for("ducting", "us.id") == []
|
|
assert _subjects_for("avalanche", "") == []
|
|
|
|
|
|
# --------------------------------------------------------------------- integration
|
|
|
|
def test_central_region_default_propagates_to_consumer_subjects():
|
|
"""Default region = 'us.id': flipping nws to central → consumer subscribes
|
|
to the region-aware subject, not the bare wildcard."""
|
|
env = EnvironmentalConfig()
|
|
assert env.central.region == "us.id" # spec default
|
|
env.nws.feed_source = "central"
|
|
so = CentralConsumer(env, None)._subject_owned()
|
|
assert list(so.keys()) == ["central.wx.alert.us.id.>"]
|
|
assert so["central.wx.alert.us.id.>"] == {"nws"}
|