diff --git a/work/meshai/adapter_config/defaults.py b/work/meshai/adapter_config/defaults.py index 3585b42..5196d62 100644 --- a/work/meshai/adapter_config/defaults.py +++ b/work/meshai/adapter_config/defaults.py @@ -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.", }, diff --git a/work/meshai/config.py b/work/meshai/config.py index 69205d9..1b1491c 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -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) diff --git a/work/meshai/connector.py b/work/meshai/connector.py index 12ed83e..a908bf2 100644 --- a/work/meshai/connector.py +++ b/work/meshai/connector.py @@ -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.""" diff --git a/work/meshai/notifications/pipeline/__init__.py b/work/meshai/notifications/pipeline/__init__.py index 7f0e67b..d6d2908 100644 --- a/work/meshai/notifications/pipeline/__init__.py +++ b/work/meshai/notifications/pipeline/__init__.py @@ -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 diff --git a/work/meshai/notifications/scheduled/fire_digest.py b/work/meshai/notifications/scheduled/fire_digest.py index 84d41d2..176c142 100644 --- a/work/meshai/notifications/scheduled/fire_digest.py +++ b/work/meshai/notifications/scheduled/fire_digest.py @@ -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 diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index aca4081..4b407fb 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -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 diff --git a/work/meshai/transport/factory.py b/work/meshai/transport/factory.py index 2076900..6890878 100644 --- a/work/meshai/transport/factory.py +++ b/work/meshai/transport/factory.py @@ -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}. " diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 00a1f33..c6f2cf0 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -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.""" diff --git a/work/tests/test_composite_transport.py b/work/tests/test_composite_transport.py index b352b6d..d5c1c80 100644 --- a/work/tests/test_composite_transport.py +++ b/work/tests/test_composite_transport.py @@ -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 # --------------------------------------------------------------------------- diff --git a/work/tests/test_fire_digest_recency.py b/work/tests/test_fire_digest_recency.py index b1a30ec..1c85f00 100644 --- a/work/tests/test_fire_digest_recency.py +++ b/work/tests/test_fire_digest_recency.py @@ -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.""" diff --git a/work/tests/test_fire_tracker_phase4.py b/work/tests/test_fire_tracker_phase4.py index ddcbbd5..0408b9b 100644 --- a/work/tests/test_fire_tracker_phase4.py +++ b/work/tests/test_fire_tracker_phase4.py @@ -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(): diff --git a/work/tests/test_uniform_sizing.py b/work/tests/test_uniform_sizing.py index e2f3a92..7a48414 100644 --- a/work/tests/test_uniform_sizing.py +++ b/work/tests/test_uniform_sizing.py @@ -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 # ---------------------------------------------------------------------------