refactor(sizing): fixed universal mesh budget (mesh_max_chars=140)

Replace per-transport / min-of-active max_chars with a single fixed
universal constant (mesh_max_chars, default 140 = MeshCore LCD). Every
message is built once against one deterministic budget regardless of
which radios are connected; no runtime variance, no per-transport
retooling. Meshtastic sizing intentionally moves 200 -> 140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-02 17:53:13 +00:00
commit 82f1f95988
12 changed files with 135 additions and 71 deletions

View file

@ -413,7 +413,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
# digest_max_chars: mesh wire cap. The LLM is told to fit under this.
# Reuses the response.max_length chunking if the LLM ignores the cap.
("fires", "digest_max_chars"): {
"default": 200,
"default": 140,
"type": "int",
"description": "Hard cap on the digest wire string length (chars). The LLM prompt asks to fit; the chunker enforces.",
},

View file

@ -36,12 +36,12 @@ class ConnectionConfig:
reconnect_health_interval: float = 30.0
# --- transport selection (Phase 1 seam; MeshCore support is Phase 2) ---
transport: str = "meshtastic" # "meshtastic" | "meshcore" | "both"
meshtastic_max_chars: int = 200 # default Meshtastic packet budget
# Universal mesh message budget (MeshCore LCD → 140 for all transports).
mesh_max_chars: int = 140
# --- MeshCore transport settings (used when transport="meshcore") ---
meshcore_host: str = "100.64.0.9" # pyMC companion frame server host
meshcore_port: int = 5050 # pyMC companion frame server port
meshcore_channel_index: int = 0 # default channel index for broadcasts
meshcore_max_chars: int = 140 # placeholder max chars (Phase 3 chunker)
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)

View file

@ -77,7 +77,7 @@ class MeshtasticTransport(MeshTransport):
@property
def max_chars(self) -> int:
return getattr(self.config, "meshtastic_max_chars", 200)
return self.config.mesh_max_chars
def connect(self) -> None:
"""Establish connection to Meshtastic node."""

View file

@ -74,7 +74,7 @@ def build_pipeline(config, llm_backend, connector=None) -> EventBus:
accumulator = DigestAccumulator(
llm_backend=llm_backend,
include_toggles=include_toggles,
mesh_char_limit=connector.max_chars if connector is not None else 200,
mesh_char_limit=connector.max_chars if connector is not None else 140,
)
# Tee closure: events go to BOTH dispatcher and accumulator

View file

@ -112,7 +112,8 @@ async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
else:
fire_lines.append(name)
# Assemble within ~200-byte LoRa budget; trim fire lines, never the tail
# Assemble within the universal mesh budget; trim fire lines, never the tail
budget = int(adapter_config.fires.digest_max_chars)
shown: list[str] = []
for line in fire_lines:
# Estimate tail for budget check
@ -127,7 +128,7 @@ async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
if est_tail:
parts.append(est_tail)
candidate = "\n".join(parts)
if len(candidate.encode("utf-8")) <= 200:
if len(candidate.encode("utf-8")) <= budget:
shown.append(line)
else:
break

View file

@ -51,10 +51,11 @@ class CompositeTransport(MeshTransport):
filtering is per-child in the inbound wrapper).
"""
def __init__(self, children: List[MeshTransport]) -> None:
def __init__(self, children: List[MeshTransport], config=None) -> None:
if not children:
raise ValueError("CompositeTransport requires at least one child")
self._children = list(children)
self._config = config # ConnectionConfig; used for the universal mesh_max_chars budget
# Build a stable name → child mapping for O(1) routing-hint lookup.
self._by_name: dict[str, MeshTransport] = {
_child_name(c): c for c in self._children
@ -173,16 +174,17 @@ class CompositeTransport(MeshTransport):
@property
def max_chars(self) -> int:
"""Return the minimum max_chars across all children.
"""Return the universal mesh message budget from config.
The tightest budget (MeshCore 140) governs so broadcasts which
fan out to all children always fit every mesh.
Sourced directly from ``config.mesh_max_chars`` (default 140) the
single fixed constant that governs all transports regardless of which
radios are connected. MeshCore is the LCD, so 140 is the right value
for every path.
"""
connected = [c for c in self._children if c.connected]
if connected:
return min(c.max_chars for c in connected)
# Fallback to overall minimum if no child is connected yet.
return min(c.max_chars for c in self._children)
if self._config is not None:
return self._config.mesh_max_chars
# Fallback for tests that build CompositeTransport without a config.
return 140
# ------------------------------------------------------------------
# Message I/O

View file

@ -39,7 +39,7 @@ def build_transport(config) -> MeshTransport:
return CompositeTransport([
MeshtasticTransport(config),
MeshCoreTransport(config),
])
], config=config)
raise ValueError(
f"Unknown transport {transport_name!r}. "

View file

@ -199,7 +199,7 @@ class MeshCoreTransport(MeshTransport):
Args:
text: Message text (caller is responsible for length limits; see
``meshcore_max_chars`` config field for the future chunker).
``mesh_max_chars`` config field).
destination: hex pubkey string for a DM, or None for channel send.
channel: Channel index for channel sends (0 = default).
transport: Optional routing hint (for CompositeTransport); ignored here.
@ -369,7 +369,7 @@ class MeshCoreTransport(MeshTransport):
@property
def max_chars(self) -> int:
return getattr(self.config, "meshcore_max_chars", 140)
return self.config.mesh_max_chars
def get_node_name(self, node_id: str) -> str:
"""Resolve a pubkey prefix to a contact display name, or return node_id."""

View file

@ -132,17 +132,30 @@ class TestFactory:
# ---------------------------------------------------------------------------
class TestMaxChars:
def test_min_of_children(self) -> None:
"""max_chars == min(child.max_chars) — MeshCore 140 wins over MT 200."""
def test_fixed_universal_budget_no_config(self) -> None:
"""Without a config, CompositeTransport falls back to the 140 constant."""
mt = FakeChild("meshtastic", max_chars_val=200)
mc = FakeChild("meshcore", max_chars_val=140)
comp = CompositeTransport([mt, mc])
assert comp.max_chars == 140
def test_single_child(self) -> None:
def test_fixed_universal_budget_from_config(self) -> None:
"""With a config, CompositeTransport reads mesh_max_chars directly."""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
mt = FakeChild("meshtastic", max_chars_val=200)
mc = FakeChild("meshcore", max_chars_val=140)
comp = CompositeTransport([mt, mc], config=cfg)
assert comp.max_chars == 140
def test_fixed_universal_budget_ignores_child_values(self) -> None:
"""CompositeTransport must NOT take min(children); it sources config."""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
# Even if a child would report 230, the composite must return 140.
child = FakeChild("meshtastic", max_chars_val=230)
comp = CompositeTransport([child])
assert comp.max_chars == 230
comp = CompositeTransport([child], config=cfg)
assert comp.max_chars == 140
# ---------------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
Validates recency ordering, contained/tombstoned exclusion, the
"Name in County Co, ST" line format, correct tail count after budget
trimming, singular/plural grammar, and the 200-byte LoRa budget.
trimming, singular/plural grammar, and the 140-byte universal mesh budget.
"""
from __future__ import annotations
@ -62,28 +62,34 @@ class TestFireDigestRecency:
"""Deterministic fire digest renderer tests."""
def test_top_2_listed_in_recency_order(self):
"""The 2 most recent active fires are listed in order."""
"""The most recent active fire is listed first.
With the universal 140-byte budget, the header + 1 fire line + tail
fits; the second fire line does not. Alpha (most recent) must appear;
Bravo belongs to the tail count.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
# Most recent fire must appear in the wire body.
assert "Alpha Fire" in wire
assert "Bravo Fire" in wire
pos_alpha = wire.index("Alpha Fire")
pos_bravo = wire.index("Bravo Fire")
assert pos_alpha < pos_bravo, "Alpha Fire (most recent) should appear before Bravo Fire"
# Bravo does not fit within 140 bytes alongside the header + tail.
assert "Bravo Fire" not in wire
def test_line_format_name_in_county_co_state(self):
"""Fire lines render as 'Name in County Co, ST'."""
"""Fire lines render as 'Name in County Co, ST'.
Only the most recent fire fits within the 140-byte budget.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Alpha Fire in Ada Co, ID" in wire
assert "Bravo Fire in Boise Co, ID" in wire
def test_missing_county_renders_name_in_state(self):
"""Fire with no county renders as 'Name in ST'."""
@ -123,17 +129,25 @@ class TestFireDigestRecency:
"""N == 1 renders 'There is 1 additional wildfire.'."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
# 3 fires; short names + no county → lines are short enough that
# 2 fit within the 140-byte budget, leaving 1 in the tail.
for irwin, name, offset in [
("SG-01", "A", 3600),
("SG-02", "B", 7200),
("SG-03", "C", 10800),
]:
_seed_fire(conn, irwin_id=irwin, name=name, acres=100,
contained=None, last_event_at=now - offset,
county=None, state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 3 active total, 2 shown, 1 remaining
# 3 active total, 2 shown (budget), 1 remaining
assert "There is 1 additional wildfire. DM me for the full list." in wire
def test_n_plural_grammar(self):
"""N > 1 renders 'There are N additional wildfires.'."""
conn = get_db()
now = int(time.time())
day = 86400
for i in range(4):
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
acres=100 + i, contained=None,
@ -141,8 +155,9 @@ class TestFireDigestRecency:
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 4 active, 2 shown, 2 remaining
assert "There are 2 additional wildfires. DM me for the full list." in wire
# 4 active; "Fire N in Ada Co, ID" lines total ~142 bytes for 2 lines +
# header + tail → only 1 line fits in the 140-byte budget → 3 remaining.
assert "There are 3 additional wildfires. DM me for the full list." in wire
def test_n_zero_omits_sentence(self):
"""When N == 0, the tail sentence is omitted entirely."""
@ -186,7 +201,7 @@ class TestFireDigestRecency:
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 200, f"Wire is {byte_len} bytes, exceeds 200"
assert byte_len <= 140, f"Wire is {byte_len} bytes, exceeds 140"
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
# Either way the tail count must match (total - shown)
lines = wire.split("\n")
@ -205,7 +220,7 @@ class TestFireDigestRecency:
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 200, f"Digest is {byte_len} bytes, exceeds 200-byte budget"
assert byte_len <= 140, f"Digest is {byte_len} bytes, exceeds 140-byte budget"
def test_no_fires_returns_empty(self):
"""No active fires -> empty wire, 'no_fires' source."""

View file

@ -76,7 +76,7 @@ def test_adapter_config_seeds_digest_keys():
assert rows[("fires", "digest_enabled")] == "true"
assert rows[("fires", "digest_schedule")] == '["06:00", "18:00"]'
assert rows[("fires", "digest_timezone")] == '"America/Boise"'
assert rows[("fires", "digest_max_chars")] == "200"
assert rows[("fires", "digest_max_chars")] == "140"
# ===========================================================================
@ -107,7 +107,7 @@ def test_render_digest_terse_fallback_when_no_llm():
assert source == "deterministic"
assert wire
assert "Cache Peak" in wire
assert len(wire) <= 200
assert len(wire) <= 140
def test_render_digest_uses_llm_when_available():

View file

@ -1,8 +1,8 @@
"""Tests for Phase 3 uniform mesh-packet sizing.
"""Tests for uniform mesh-packet sizing.
Verifies that every size-sensitive code path uses the active transport's
max_chars rather than a hardcoded 200. Meshtastic default stays 200
(byte-identical); MeshCore uses 140.
Verifies that every size-sensitive code path uses the single universal
mesh_max_chars constant (140 = MeshCore LCD). All transports Meshtastic,
MeshCore, and the composite report the same fixed value.
"""
import sys
@ -127,59 +127,90 @@ def _minimal_config():
# ---------------------------------------------------------------------------
class TestTransportMaxChars:
def test_meshtastic_default_is_200(self):
def test_meshtastic_default_is_140(self):
"""Meshtastic now uses the universal mesh_max_chars constant (140)."""
cfg = _mt_config()
t = MeshtasticTransport(cfg)
assert t.max_chars == 200
def test_meshtastic_respects_config_field(self):
cfg = _mt_config(meshtastic_max_chars=160)
t = MeshtasticTransport(cfg)
assert t.max_chars == 160
assert t.max_chars == 140
def test_meshcore_default_is_140(self):
cfg = _mc_config()
t = MeshCoreTransport(cfg)
assert t.max_chars == 140
def test_meshcore_respects_config_field(self):
cfg = _mc_config(meshcore_max_chars=120)
t = MeshCoreTransport(cfg)
assert t.max_chars == 120
def test_build_transport_meshtastic_max_chars(self):
"""build_transport(meshtastic) → 140 (universal constant)."""
t = build_transport(_mt_config())
assert t.max_chars == 200
assert t.max_chars == 140
def test_build_transport_meshcore_max_chars(self):
t = build_transport(_mc_config())
assert t.max_chars == 140
def test_mesh_max_chars_config_field_governs_all(self):
"""A non-default mesh_max_chars propagates to both transport types."""
cfg_mt = _mt_config(mesh_max_chars=120)
assert MeshtasticTransport(cfg_mt).max_chars == 120
cfg_mc = _mc_config(mesh_max_chars=120)
assert MeshCoreTransport(cfg_mc).max_chars == 120
def test_all_three_transports_and_composite_report_140(self):
"""Sanity: all transports + CompositeTransport report mesh_max_chars=140.
This is the canonical single-budget guarantee: Meshtastic, MeshCore,
and the composite (transport=both) all resolve to the same constant.
"""
from meshai.config import ConnectionConfig
from meshai.transport.composite_transport import CompositeTransport
# Meshtastic
assert MeshtasticTransport(_mt_config()).max_chars == 140
# MeshCore
assert MeshCoreTransport(_mc_config()).max_chars == 140
# Composite (built via factory with transport="both")
cfg_both = ConnectionConfig(
transport="both",
type="tcp",
tcp_host="127.0.0.1",
tcp_port=4403,
meshcore_host="127.0.0.1",
meshcore_port=5050,
meshcore_channel_index=0,
)
comp = build_transport(cfg_both)
assert isinstance(comp, CompositeTransport)
assert comp.max_chars == 140
# ---------------------------------------------------------------------------
# 2. MeshRenderer char_limit propagation via channels
# ---------------------------------------------------------------------------
class TestChannelRendererBudget:
def test_broadcast_channel_with_meshcore_connector_uses_140(self):
def test_broadcast_channel_propagates_connector_max_chars(self):
"""Channel renderer inherits max_chars from whatever the connector reports."""
conn = _fake_connector(140)
ch = MeshBroadcastChannel(connector=conn, channel_index=0)
assert ch._renderer._limit == 140
def test_broadcast_channel_with_meshtastic_connector_uses_200(self):
conn = _fake_connector(200)
def test_broadcast_channel_with_meshtastic_connector_uses_140(self):
"""After the universal budget refactor, a meshtastic connector reports 140."""
conn = _fake_connector(140)
ch = MeshBroadcastChannel(connector=conn, channel_index=0)
assert ch._renderer._limit == 200
assert ch._renderer._limit == 140
def test_dm_channel_with_meshcore_connector_uses_140(self):
def test_dm_channel_propagates_connector_max_chars(self):
conn = _fake_connector(140)
ch = MeshDMChannel(connector=conn, node_ids=["!aabbccdd"])
assert ch._renderer._limit == 140
def test_dm_channel_with_meshtastic_connector_uses_200(self):
conn = _fake_connector(200)
def test_dm_channel_with_meshtastic_connector_uses_140(self):
conn = _fake_connector(140)
ch = MeshDMChannel(connector=conn, node_ids=["!aabbccdd"])
assert ch._renderer._limit == 200
assert ch._renderer._limit == 140
# ---------------------------------------------------------------------------
@ -187,11 +218,12 @@ class TestChannelRendererBudget:
# ---------------------------------------------------------------------------
class TestBuildPipelineMeshCharLimit:
def test_no_connector_uses_200(self):
def test_no_connector_uses_140(self):
"""Without a connector the pipeline falls back to the universal constant (140)."""
cfg = _minimal_config()
bus = build_pipeline(cfg, llm_backend=None, connector=None)
acc = bus._pipeline_components["accumulator"]
assert acc._mesh_char_limit == 200
assert acc._mesh_char_limit == 140
def test_meshcore_connector_uses_140(self):
cfg = _minimal_config()
@ -200,12 +232,13 @@ class TestBuildPipelineMeshCharLimit:
acc = bus._pipeline_components["accumulator"]
assert acc._mesh_char_limit == 140
def test_meshtastic_connector_uses_200(self):
def test_meshtastic_connector_uses_140(self):
"""After universal budget, a meshtastic connector also reports 140."""
cfg = _minimal_config()
conn = _fake_connector(200)
conn = _fake_connector(140)
bus = build_pipeline(cfg, llm_backend=None, connector=conn)
acc = bus._pipeline_components["accumulator"]
assert acc._mesh_char_limit == 200
assert acc._mesh_char_limit == 140
# ---------------------------------------------------------------------------