mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(broadcast): fit all mesh message formats to the packet budget (#8)
Tighten broadcast formats to fit 140 chars with critical info preserved: traffic (directions/milepost never abbreviated, narrative trimmed from end), nws hazard wording tightened (towns already path-sampled), fires (drop ID line + ** + discovery time), avy (advice -> first sentence), satpass/quake safety cap. Fire digest broadcast disabled by default. All budget-aware via the shared max_chars. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c15fa3f09
commit
6bc57709a2
19 changed files with 546 additions and 116 deletions
|
|
@ -194,3 +194,49 @@ def test_missing_event_id_returns_none(adapter):
|
|||
def test_does_not_raise_on_corrupted_dict(adapter):
|
||||
"""Corrupted dict returns None without raising."""
|
||||
assert adapter.to_event({"garbage": True}) is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Central avy_handler._render (mesh broadcast wire) -- budget-fit format.
|
||||
# advice -> FIRST SENTENCE only; zone/level/source kept FULL; fits 140.
|
||||
# ============================================================================
|
||||
|
||||
from meshai.central.avy_handler import _render as _avy_render
|
||||
|
||||
|
||||
def test_avy_render_advice_first_sentence_only():
|
||||
wire = _avy_render(
|
||||
danger_level=4, danger_name="High",
|
||||
zone_name="Western Mountains",
|
||||
center_id="SNFAC",
|
||||
travel=("Avoid all avalanche terrain today. Natural and human-triggered "
|
||||
"avalanches are likely on steep slopes."),
|
||||
)
|
||||
# first sentence retained (with its period), the rest dropped
|
||||
assert "Avoid all avalanche terrain today." in wire
|
||||
assert "Natural and human-triggered" not in wire
|
||||
# zone / level / source kept FULL (never abbreviated)
|
||||
assert "Western Mountains" in wire
|
||||
assert "High (4)" in wire
|
||||
assert "SNFAC" in wire
|
||||
|
||||
|
||||
def test_avy_render_worst_case_fits_140():
|
||||
# Long multi-sentence advice paragraph -- only the first sentence is kept,
|
||||
# which lets the full zone / level / source survive under the 140 budget.
|
||||
wire = _avy_render(
|
||||
danger_level=5, danger_name="Extreme",
|
||||
zone_name="Western Mountains",
|
||||
center_id="Sawtooth Avalanche Center",
|
||||
travel=("Avoid all avalanche terrain today! Very dangerous conditions "
|
||||
"exist across all elevations and aspects with widespread natural "
|
||||
"avalanche activity likely through the afternoon and overnight."),
|
||||
)
|
||||
assert len(wire) <= 140, f"{len(wire)} chars:\n{wire!r}"
|
||||
# zone, level, source, and the first-sentence advice all present
|
||||
assert "Western Mountains" in wire
|
||||
assert "Extreme (5)" in wire
|
||||
assert "Sawtooth Avalanche Center" in wire
|
||||
assert "Avoid all avalanche terrain today!" in wire
|
||||
# first sentence terminates at the '!' -> the rest is gone
|
||||
assert "Very dangerous conditions" not in wire
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def test_list_returns_all_59_keys(client):
|
|||
# 14 adapters with at least one key (itd_511 has zero -- not in the
|
||||
# grouped dict because the SQL only returns rows that exist).
|
||||
total = sum(len(v) for v in body.values())
|
||||
assert total == 92
|
||||
assert total == 96
|
||||
|
||||
|
||||
def test_list_grouped_by_adapter(client):
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
|
|||
|
||||
def test_registry_at_59_entries():
|
||||
"""v0.6-3a.1 trim: 43 CONFIG-only keys (was 77 in v0.6-3a draft)."""
|
||||
assert len(REGISTRY) == 92, (
|
||||
assert len(REGISTRY) == 96, (
|
||||
f"REGISTRY drift guard; got {len(REGISTRY)}. "
|
||||
f"If a sentence template / emoji / heuristic snuck in, it belongs in CODE not config."
|
||||
)
|
||||
|
|
|
|||
73
work/tests/test_fire_digest_broadcast_gate.py
Normal file
73
work/tests/test_fire_digest_broadcast_gate.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Tests for the fire-digest broadcast kill-switch (fires.digest_broadcast_enabled).
|
||||
|
||||
The scheduler wiring stays intact; only the mesh EMISSION is gated. Disabled by
|
||||
default -> fire_slot() dispatches nothing. Flipping the flag True re-enables it
|
||||
cleanly. Per-fire wfigs alerts are a separate path and are unaffected (covered in
|
||||
test_wfigs_handler.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from meshai.persistence import get_db
|
||||
from meshai.notifications.scheduled.fire_digest import FireDigestScheduler
|
||||
|
||||
|
||||
class _RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def dispatch_scheduled_broadcast(self, *, text, source_event_table,
|
||||
source_event_pk):
|
||||
self.calls.append(
|
||||
{"text": text, "table": source_event_table, "pk": source_event_pk})
|
||||
return True
|
||||
|
||||
|
||||
def _seed_active_fire(conn):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, lat, lon, county, state, "
|
||||
"declared_at, last_event_at, tombstoned_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("GATE-01", "Gatekeeper Fire", "WF", 900, None, 43.6, -116.2,
|
||||
"Ada", "ID", now, now - 600, None),
|
||||
)
|
||||
|
||||
|
||||
def test_fire_slot_disabled_by_default_dispatches_nothing():
|
||||
"""With defaults (digest_broadcast_enabled=False) fire_slot emits nothing."""
|
||||
conn = get_db()
|
||||
_seed_active_fire(conn) # a broadcast WOULD be produced if the gate were open
|
||||
now = int(time.time())
|
||||
dispatcher = _RecordingDispatcher()
|
||||
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
|
||||
|
||||
result = asyncio.run(sched.fire_slot(now, "06:00"))
|
||||
|
||||
assert result is False
|
||||
assert dispatcher.calls == [], "digest broadcast must NOT be dispatched by default"
|
||||
|
||||
|
||||
def test_fire_slot_dispatches_when_flag_enabled():
|
||||
"""Flipping fires.digest_broadcast_enabled True cleanly re-enables emission."""
|
||||
from meshai.adapter_config import set_runtime_override
|
||||
conn = get_db()
|
||||
_seed_active_fire(conn)
|
||||
now = int(time.time())
|
||||
dispatcher = _RecordingDispatcher()
|
||||
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
|
||||
|
||||
set_runtime_override("fires", "digest_broadcast_enabled", True)
|
||||
try:
|
||||
result = asyncio.run(sched.fire_slot(now, "06:00"))
|
||||
finally:
|
||||
# Reset so the override doesn't leak into other tests in this process.
|
||||
set_runtime_override("fires", "digest_broadcast_enabled", False)
|
||||
|
||||
assert result is True
|
||||
assert len(dispatcher.calls) == 1
|
||||
assert dispatcher.calls[0]["table"] == "fire_digest_broadcasts"
|
||||
assert "Gatekeeper Fire" in dispatcher.calls[0]["text"]
|
||||
|
|
@ -33,6 +33,7 @@ import pytest
|
|||
|
||||
from meshai.central.incident_handler import (
|
||||
handle_incident,
|
||||
_render as _incident_render,
|
||||
)
|
||||
from meshai.persistence import close_thread_connection, init_db
|
||||
from meshai.persistence import db as persistence_db
|
||||
|
|
@ -216,7 +217,10 @@ def test_a_tomtom_icon_renders(mem_db, no_photon, icon, expected_emoji, expected
|
|||
assert wire.startswith(f"{expected_emoji} {expected_phrase}")
|
||||
assert "Near Boise, ID" in wire
|
||||
assert "I-84" in wire
|
||||
assert "5 min delay" in wire
|
||||
# Budget-fit rework: the separate "N min delay" line was dropped; the road
|
||||
# segment carries road (+ direction/lanes), and the message fits 140.
|
||||
assert "min delay" not in wire
|
||||
assert len(wire) <= 140
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
@ -774,3 +778,51 @@ def test_w_itd_511_future_scheduled_dropped_via_start_epoch(mem_db, no_photon):
|
|||
now = 2_000_000_000
|
||||
wire = handle_incident(env, env["subject"], data={}, now=now)
|
||||
assert wire is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Budget-fit worst case: longest plausible traffic payload must fit 140 chars
|
||||
# with critical fields (type, location, road, FULL direction word, lane
|
||||
# status, trimmed narrative with intact direction word + "milepost") present.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_incident_worst_case_fits_140():
|
||||
n = {
|
||||
"sub_type": "accident",
|
||||
"geocoder_city": None,
|
||||
"county": "Minidoka",
|
||||
"state": "ID",
|
||||
"road": "SH-27",
|
||||
"direction": "north", # abbreviation/short form -> MUST expand
|
||||
"mile_marker": None,
|
||||
"lanes_affected": "1 Right lane blocked",
|
||||
"comment": (
|
||||
"Southbound right lane at milepost 24 and westbound onramp to I-84 "
|
||||
"blocked due to a multi-vehicle collision, expect major delays "
|
||||
"through the evening commute and seek alternate routes tonight"
|
||||
),
|
||||
}
|
||||
wire = _incident_render(n)
|
||||
|
||||
# (a) fits one mesh packet
|
||||
assert len(wire) <= 140, f"{len(wire)} chars:\n{wire!r}"
|
||||
|
||||
# (b) critical fields present
|
||||
assert wire.startswith("🚨 Crash") # type
|
||||
assert "Near Minidoka Co, ID" in wire # location
|
||||
assert "SH-27" in wire # road
|
||||
assert "Northbound" in wire # FULL direction (expanded)
|
||||
assert "1 Right lane blocked" in wire # lane status
|
||||
assert "SH-27 Northbound · 1 Right lane blocked" in wire # road·lane line
|
||||
|
||||
# (c) direction is NEVER abbreviated anywhere in the wire
|
||||
for abbr in (" NB", " N ", "Sbound", "N/B"):
|
||||
assert abbr not in wire
|
||||
|
||||
# (d) narrative present and word-boundary trimmed (no mid-word chop): the
|
||||
# intact direction word "Southbound" and the intact word "milepost" survive.
|
||||
assert "Southbound" in wire
|
||||
assert "milepost" in wire
|
||||
# trimmed from the END -> ends with the ellipsis, not raw text
|
||||
assert wire.endswith("…")
|
||||
|
|
|
|||
|
|
@ -282,22 +282,25 @@ def test_svr_long_locations_path_sampled(mem_db):
|
|||
d=d,
|
||||
)
|
||||
|
||||
# (a) fits in one mesh packet
|
||||
assert len(rendered) <= 200, (
|
||||
f"rendered is {len(rendered)} chars (expected <= 200):\n{rendered!r}"
|
||||
# (a) fits in one mesh packet (budget is now the 140-char LoRa max)
|
||||
assert len(rendered) <= 140, (
|
||||
f"rendered is {len(rendered)} chars (expected <= 140):\n{rendered!r}"
|
||||
)
|
||||
|
||||
# (b) all data-point categories present
|
||||
# (b) all data-point categories present, hazard wording TIGHTENED
|
||||
assert "Severe Thunderstorm Warning" in rendered, "event type missing"
|
||||
assert "Until" in rendered, "expiry time segment missing"
|
||||
assert "Twin Falls County" in rendered, "area missing"
|
||||
assert "mph winds" in rendered or "hail" in rendered, "hazard segment missing"
|
||||
assert "60mph winds" in rendered, "wind hazard not tightened to '60mph winds'"
|
||||
assert '1" hail' in rendered, "hail hazard not rendered as numeric inches"
|
||||
assert "radar" in rendered, "certainty not collapsed to 'radar'"
|
||||
assert "Moving" in rendered, "motion segment missing"
|
||||
|
||||
# (c) path-sampling, not tail-drop: first town, last town, and the arrow
|
||||
# (c) path-sampling applied (arrow) with the soonest-impact town retained.
|
||||
# At the 140 budget the farthest-along town may be trimmed by the final
|
||||
# backstop; the hard cap wins over endpoint preservation.
|
||||
assert "→" in rendered, "no arrow -> not path-sampled"
|
||||
assert "Buhl" in rendered, "first town missing"
|
||||
assert "Shoshone" in rendered, "last town dropped (the old tail-trim bug)"
|
||||
assert "Buhl" in rendered, "first (soonest-impact) town missing"
|
||||
|
||||
|
||||
def test_svr_short_locations_shown_in_full(mem_db):
|
||||
|
|
@ -331,6 +334,47 @@ def test_svr_short_locations_shown_in_full(mem_db):
|
|||
d=d,
|
||||
)
|
||||
|
||||
assert len(rendered) <= 200
|
||||
assert len(rendered) <= 140
|
||||
assert "→" not in rendered, "short list should not be path-sampled"
|
||||
assert "Buhl, Eden, Hazelton" in rendered, "full comma-joined list expected"
|
||||
# Hazard wording is tightened even on the short-list path.
|
||||
assert "60mph winds" in rendered
|
||||
assert '1" hail' in rendered
|
||||
assert "radar" in rendered
|
||||
|
||||
|
||||
def test_svr_worst_case_fits_140(mem_db):
|
||||
"""Pathologically long SVR payload: the final wire MUST fit 140 chars while
|
||||
still carrying event name, area, time, tightened hazard, and >=1 town."""
|
||||
long_locations = (
|
||||
"Buhl, Eden, Hazelton, Murtaugh, Richfield, Dietrich, Gooding, "
|
||||
"Hagerman, Wendell, Jerome, Kimberly, Hansen, Filer, and Shoshone"
|
||||
)
|
||||
description = (
|
||||
"HAZARD...Damaging winds to 70 mph and golf ball size hail.\n\n"
|
||||
f"Locations impacted include...{long_locations}"
|
||||
)
|
||||
d = {
|
||||
"eventCode": {"SAME": ["SVR"]},
|
||||
"certainty": "Observed",
|
||||
"parameters": {
|
||||
"maxWindGust": ["70 MPH"],
|
||||
"maxHailSize": ["1.75"],
|
||||
"eventMotionDescription": ["2200000T254DEG...35KT 42.5,-114.5"],
|
||||
},
|
||||
"description": description,
|
||||
}
|
||||
rendered = _render(
|
||||
event_type="Severe Thunderstorm Warning",
|
||||
area_desc="Twin Falls County",
|
||||
geocoder_city=None, county="Twin Falls", state="ID",
|
||||
expires_epoch=1_751_400_000, lat=42.5, lon=-114.46,
|
||||
now=1_751_400_000, d=d,
|
||||
)
|
||||
assert len(rendered) <= 140, f"{len(rendered)} chars:\n{rendered!r}"
|
||||
assert "Severe Thunderstorm Warning" in rendered # event name
|
||||
assert "Twin Falls County" in rendered # area
|
||||
assert "Until" in rendered # time
|
||||
assert "70mph winds" in rendered # tightened hazard (wind)
|
||||
assert '1.75" hail' in rendered # golf ball -> 1.75"
|
||||
assert "Buhl" in rendered # >=1 town present
|
||||
|
|
|
|||
|
|
@ -173,3 +173,21 @@ def test_commit_callback_updates_last_broadcast(mem_db):
|
|||
"SELECT last_broadcast_at FROM quake_events WHERE event_id='cb1'"
|
||||
).fetchone()
|
||||
assert post["last_broadcast_at"] == 1_000_001
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Budget-fit SAFETY CAP: a freak-long USGS place string must still fit 140.
|
||||
# ============================================================================
|
||||
|
||||
from meshai.central.quake_handler import _render as _quake_render
|
||||
|
||||
|
||||
def test_quake_render_worst_case_fits_140():
|
||||
place = ("293 km SSW of a pathologically long place description island "
|
||||
"region in the remote northern pacific ocean near absolutely nowhere "
|
||||
"at all off the coast of the far edge of the map")
|
||||
wire = _quake_render(mag=7.9, place=place, depth_km=12, lat=44.123,
|
||||
lon=-114.987, tsunami=True, is_update=False)
|
||||
assert len(wire) <= 140, f"{len(wire)} chars:\n{wire!r}"
|
||||
# magnitude survives on the (critical) first line
|
||||
assert "M7.9" in wire
|
||||
|
|
|
|||
|
|
@ -171,3 +171,24 @@ class TestSatpassHandler:
|
|||
result = handle_satpass(env, "central.sat.pass.iss", data={}, now=1718163120)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Budget-fit SAFETY CAP: a pathologically long satellite name must not push
|
||||
# the broadcast string past 140 chars. Calls format_pass directly (bypasses
|
||||
# the broken consolidation path).
|
||||
# ============================================================================
|
||||
|
||||
def test_format_pass_worst_case_fits_140():
|
||||
from meshai.central.satpass_handler import format_pass
|
||||
wire = format_pass(
|
||||
sat_name=("NOAA-19 EXPERIMENTAL SUPER LONG SATELLITE DESIGNATION "
|
||||
"PAYLOAD REVISION X PROTOTYPE FLIGHT MODEL SERIAL 00042"),
|
||||
max_el=78.0,
|
||||
aos_epoch=1719900000, los_epoch=1719900780,
|
||||
aos_compass="NNW", los_compass="SSE",
|
||||
entry_observer="Treasure Valley Observatory West Ridge Site",
|
||||
exit_observer="Magic Valley Observatory East Rim Station",
|
||||
broadcast=True,
|
||||
)
|
||||
assert len(wire) <= 140, f"{len(wire)} chars:\n{wire!r}"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from meshai import central_normalizer as cn
|
|||
from meshai.central.wfigs_handler import (
|
||||
WFIGS_BROADCAST_COOLDOWN_S,
|
||||
handle_wfigs,
|
||||
_render as _wfigs_render,
|
||||
)
|
||||
from meshai.persistence import close_thread_connection, init_db
|
||||
from meshai.persistence import db as persistence_db
|
||||
|
|
@ -283,7 +284,10 @@ def test_g_new_irwin_inserts_and_broadcasts(mem_db, no_photon):
|
|||
assert wire.startswith("🔥 Cache Peak Fire — New")
|
||||
assert "Burley" in wire
|
||||
assert "1,847 ac" in wire
|
||||
assert "23% contained" in wire
|
||||
assert "containment 23%" in wire
|
||||
# Budget-fit rework: no unique-fire-id line, no bold markdown.
|
||||
assert "ID:" not in wire
|
||||
assert "**" not in wire
|
||||
|
||||
# v0.5.8b: handler INSERTs the fires row with last_broadcast_*=NULL,
|
||||
# then attaches a commit callback. The dispatcher fires the callback
|
||||
|
|
@ -416,7 +420,7 @@ def test_j_known_irwin_change_after_cooldown_broadcasts(mem_db, no_photon):
|
|||
assert out is not None
|
||||
assert out.startswith("🔥 Cache Peak Fire — Update")
|
||||
assert "3,000 ac" in out
|
||||
assert "35% contained" in out
|
||||
assert "containment 35%" in out
|
||||
|
||||
# Simulate dispatcher commit.
|
||||
data2["_on_broadcast_committed"](float(later))
|
||||
|
|
@ -615,3 +619,70 @@ def test_h_handler_attaches_audit_descriptor_and_callback(mem_db, no_photon):
|
|||
assert callable(data["_on_broadcast_committed"])
|
||||
assert data["_broadcast_audit"]["table"] == "fires"
|
||||
assert data["_broadcast_audit"]["pk"] == _IRWIN_B
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Budget-fit worst case: longest plausible fire payload fits 140 chars, with
|
||||
# no `ID:` line, no `**` markdown, and discovery rendered DATE-ONLY.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_wfigs_worst_case_fits_140():
|
||||
n = {
|
||||
"incident_name": "East Fork Salmon River Complex Lightning Fire",
|
||||
"acres": 128456,
|
||||
"contained_pct": 42,
|
||||
"fire_cause": "Lightning",
|
||||
"unique_fire_id": "2026-IDSCF-000987",
|
||||
# Jun 18 2026 ~14:30 local
|
||||
"declared_at_epoch": 1_781_204_400,
|
||||
"geocoder_city": "Near Clayton, ID",
|
||||
}
|
||||
wire = _wfigs_render(n, prefix="Update", last_bcast_acres=100000)
|
||||
|
||||
assert len(wire) <= 140, f"{len(wire)} chars:\n{wire!r}"
|
||||
# critical fields
|
||||
assert "East Fork Salmon River Complex Lightning Fire" in wire # name
|
||||
assert "128,456 ac" in wire # acreage
|
||||
assert "containment 42%" in wire # containment
|
||||
assert "Near Clayton, ID" in wire # location
|
||||
assert "Cause: Lightning" in wire # cause
|
||||
# format rules
|
||||
assert "ID:" not in wire, "unique-fire-id line must be dropped"
|
||||
assert "**" not in wire, "no bold markdown"
|
||||
|
||||
|
||||
def test_wfigs_discovery_is_date_only():
|
||||
n = {
|
||||
"incident_name": "Short Fire",
|
||||
"acres": 100,
|
||||
"contained_pct": 0,
|
||||
"fire_cause": "Human",
|
||||
"unique_fire_id": "2026-X",
|
||||
"declared_at_epoch": 1_781_204_400, # renders Jun 11 in the handler's UTC-6
|
||||
"geocoder_city": "Boise",
|
||||
}
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
assert "Discovered Jun 11" in wire
|
||||
# no time-of-day (colon in an H:MM would appear as ":3" etc.)
|
||||
assert "2:30" not in wire and "PM" not in wire and "AM" not in wire
|
||||
assert "ID:" not in wire
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fire-digest kill-switch does NOT touch the per-fire wfigs path: a new-fire
|
||||
# envelope still produces a broadcast even though digest broadcast is disabled.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_per_fire_wfigs_broadcasts_while_digest_disabled(mem_db, no_photon):
|
||||
from meshai.adapter_config import adapter_config
|
||||
# Default posture: the twice-daily digest broadcast is OFF.
|
||||
assert bool(adapter_config.fires.digest_broadcast_enabled) is False
|
||||
# ... yet a new per-fire wfigs alert still broadcasts.
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
data = {}
|
||||
wire = handle_wfigs(cn.normalize(env), env, env["subject"],
|
||||
data=data, now=6_000_000)
|
||||
assert wire is not None
|
||||
assert wire.startswith("🔥 Cache Peak Fire — New")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue