The demo's message paths look like a real mesh

Measured against a real Montreal database: most messages arrive twice,
through the two repeaters a companion hears, on paths that share their
start and split near the end, with 3-byte hashes. The demo did five or
six short copies through five neighbours.

Our node now hears only two new rooftop repeaters, Villeray and
Rosemont (the terrain hides the big ones), companions lose 7 dB at street
level, and each sender has its own hash width (mostly 3 bytes). Now 91%
of messages arrive twice and 83% of paths use 3-byte hashes. Noor moves
to Côte-des-Neiges to stay in reach, and the traces leave through
Villeray or Rosemont.

The capture finds the Message paths message by its text (a key press
can be lost while the transcript draws) and selects the second path,
since the first is often a straight line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-16 04:30:43 -04:00
1 parent 5d2648d652
commit 37b17d8ba4
16 files changed
+185 -30

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+48 -12
View File
@@ -113,7 +113,7 @@ CHANNEL_LINES: dict[str, tuple[Line, ...]] = {
"Jean-Seb",
"And the repeater at the chalet hears everyone, even through the trees 😄",
),
Line(at(0, "07:48"), "Noor", "First test from Saint-Laurent, can anyone hear me?"),
Line(at(0, "07:48"), "Noor", "First test from Côte-des-Neiges, can anyone hear me?"),
Line(at(0, "07:52"), "Marie-Ève", "@[Noor] got you through Mont-Royal, +6 dB. Welcome!"),
Line(
at(0, "10:05"), "Mathilde", "Hi everyone! First time on MeshCore, I'm in Saint-Henri 👋"
@@ -127,7 +127,7 @@ CHANNEL_LINES: dict[str, tuple[Line, ...]] = {
Line(
at(0, "10:15"),
ME,
"Your advert reaches me in one hop through Mont-Royal, SNR +8. Trace path shows it all",
"Your advert reaches me through Mont-Royal, then Rosemont. Trace path shows it all",
),
Line(
at(0, "10:40"),
@@ -324,26 +324,59 @@ LIVE_CHANNEL: tuple[tuple[float, str, Line], ...] = (
#: the link model, tries again a few times when the trace does not come back (as a person
#: does), and stores each try.
TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = (
(at(6, "21:30"), "Longueuil", ("Mont-Royal", "Longueuil", "Mont-Royal")),
(at(5, "20:10"), "Pointe-Claire", ("Mont-Royal", "Pointe-Claire", "Mont-Royal")),
(
at(6, "21:30"),
"Longueuil",
("Rosemont", "Stade-Olympique", "Longueuil", "Stade-Olympique", "Rosemont"),
),
(
at(5, "20:10"),
"Pointe-Claire",
("Villeray", "Mont-Royal", "Pointe-Claire", "Mont-Royal", "Villeray"),
),
(
at(5, "20:20"),
None,
("Mont-Royal", "Pointe-Claire", "Île-Bizard", "Pointe-Claire", "Mont-Royal"),
(
"Villeray",
"Mont-Royal",
"Pointe-Claire",
"Île-Bizard",
"Pointe-Claire",
"Mont-Royal",
"Villeray",
),
),
(at(4, "21:00"), "Laval-Chomedey", ("Ahuntsic", "Laval-Chomedey", "Ahuntsic")),
(at(3, "22:15"), None, ("Mont-Royal", "Mont-St-Hilaire", "Mont-Royal")),
(at(2, "20:40"), None, ("Oratoire", "Lachine", "Oratoire")),
(
at(4, "21:00"),
"Laval-Chomedey",
("Villeray", "Ahuntsic", "Laval-Chomedey", "Ahuntsic", "Villeray"),
),
(at(3, "22:15"), None, ("Rosemont", "Mont-Royal", "Mont-St-Hilaire", "Mont-Royal", "Rosemont")),
(at(2, "20:40"), None, ("Villeray", "Oratoire", "Lachine", "Oratoire", "Villeray")),
(
at(2, "21:50"),
None,
("Mont-Royal", "Mont-St-Bruno", "Mont-St-Hilaire", "Mont-St-Bruno", "Mont-Royal"),
(
"Rosemont",
"Stade-Olympique",
"Mont-St-Bruno",
"Mont-St-Hilaire",
"Mont-St-Bruno",
"Stade-Olympique",
"Rosemont",
),
),
(
at(1, "20:30"),
None,
("Villeray", "Mont-Royal", "Laval-Chomedey", "Ahuntsic", "Stade-Olympique", "Rosemont"),
),
(at(1, "20:30"), None, ("Mont-Royal", "Laval-Chomedey", "Ahuntsic", "Stade-Olympique")),
(
at(1, "21:10"),
None,
(
"Rosemont",
"Centre-Ville",
"Verdun-Quai",
"Brossard",
@@ -359,12 +392,14 @@ TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = (
"Lachine",
"Pointe-Claire",
"Mont-Royal",
"Villeray",
),
),
(
at(1, "21:40"),
None,
(
"Villeray",
"Oratoire",
"Lachine",
"Pointe-Claire",
@@ -373,10 +408,11 @@ TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = (
"Mont-St-Bruno",
"Mont-St-Hilaire",
"Stade-Olympique",
"Rosemont",
),
),
(at(0, "09:05"), "Brossard", ("Mont-Royal", "Brossard", "Mont-Royal")),
(at(0, "10:20"), None, ("Mont-Royal", "Longueuil", "Stade-Olympique")),
(at(0, "09:05"), "Brossard", ("Rosemont", "Mont-Royal", "Brossard", "Mont-Royal", "Rosemont")),
(at(0, "10:20"), None, ("Rosemont", "Longueuil", "Stade-Olympique", "Villeray")),
)
#: The Watchtower: the nodes that our node watches, and the alerts that it raised and the
+22 -6
View File
@@ -51,6 +51,7 @@ from .world import (
flood,
link_snr,
loss_chance,
path_width,
reading,
)
@@ -255,8 +256,16 @@ def heard_flood(
if all(_relays_region(relay, region) for relay in copy.relays)
]
route = "TC_FLOOD" if region else "FLOOD"
width = path_width(origin)
out = [
_packet(origin if advert else None, start_s, copy, {"route_typename": route, **body}, rng)
_packet(
origin if advert else None,
start_s,
copy,
{"route_typename": route, **body},
rng,
width=width,
)
for copy in copies
]
if advert and copies:
@@ -266,11 +275,17 @@ def heard_flood(
def _packet(
origin: Node | None, start_s: float, copy: Copy, body: dict, rng: random.Random
origin: Node | None,
start_s: float,
copy: Copy,
body: dict,
rng: random.Random,
*,
width: int = 1,
) -> Heard:
"""One ``packet`` observation for one copy of a transmission."""
path = ",".join(relay.hash for relay in copy.relays)
raw = {**body, "path_len": len(copy.relays), "path_hash_size": 1}
"""One ``packet`` observation for one copy of a transmission, with ``width``-byte hashes."""
path = ",".join(relay.public_key[: 2 * width] for relay in copy.relays)
raw = {**body, "path_len": len(copy.relays), "path_hash_size": width}
share = origin is not None and origin.shares_location
return Heard(
start_s + copy.delay_s,
@@ -348,7 +363,8 @@ def heard_direct(
continue
remaining = route[index:]
copy = Copy(remaining, reading(snr, rng), elapsed)
out.append(_packet(None, start_s, copy, {"route_typename": "DIRECT", **body}, rng))
direct = {"route_typename": "DIRECT", **body}
out.append(_packet(None, start_s, copy, direct, rng, width=path_width(src)))
return out
+69 -3
View File
@@ -110,7 +110,9 @@ class Node:
_R, _C, _ROOM, _S = NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR
#: Our node: the companion that the demo device is. It stands in La Petite-Patrie, near the
#: Jean-Talon market, with an antenna on the roof of a triplex.
#: Jean-Talon market, with an antenna on the roof of a triplex. The buildings around it
#: hide the big repeaters (refer to :data:`_OBSTRUCTED`). It hears two small repeaters on
#: the roofs nearby, Villeray and Rosemont, as a companion in the city usually does.
SELF = Node("Alex Base", 0x91, _C, 45.5350, -73.6030, 2.0, first_heard_days=400.0)
# fmt: off
@@ -132,6 +134,8 @@ NODES: tuple[Node, ...] = (
Node("Brossard", 0x18, _R, 45.4570, -73.4660, 6.0, first_heard_days=70.0),
Node("Mont-St-Bruno", 0xE1, _R, 45.5530, -73.3260, 13.0, first_heard_days=280.0),
Node("Mont-St-Hilaire", 0x61, _R, 45.5490, -73.1570, 15.0, first_heard_days=45.0),
Node("Villeray", 0x70, _R, 45.5470, -73.6170, 4.0, first_heard_days=250.0),
Node("Rosemont", 0xD8, _R, 45.5420, -73.5850, 4.0, first_heard_days=170.0),
# The room servers.
Node("MTL BBS", 0x3B, _ROOM, 45.5225, -73.5840, 4.0, first_heard_days=330.0),
Node("Café Mesh", 0x64, _ROOM, 45.5250, -73.5990, 3.0, first_heard_days=60.0,
@@ -154,7 +158,7 @@ NODES: tuple[Node, ...] = (
Node("Kim-NDG", 0xBF, _C, 45.4735, -73.6145, 1.0, first_heard_days=260.0),
Node("Nico R.", 0x94, _C, 45.5110, -73.5695, 0.0, shares_location=False,
first_heard_days=90.0, talk=1.3),
Node("Noor", 0x50, _C, 45.5075, -73.6880, 1.0, first_heard_days=75.0),
Node("Noor", 0x50, _C, 45.4960, -73.6300, 1.0, first_heard_days=75.0),
Node("Chloé L.", 0xC9, _C, 45.5170, -73.6060, 1.0, first_heard_days=220.0),
Node("Sam Verdun", 0x68, _C, 45.4610, -73.5690, 1.0, first_heard_days=240.0),
Node("Steve K.", 0x22, _C, 45.5340, -73.5050, 1.0, first_heard_days=40.0),
@@ -214,6 +218,35 @@ _DECADE_DB = 25.0
_SOFT_CAP_DB = 10.0
#: The extra loss (dB) of a link from or to a companion of the people of the mesh. Such a
#: node is indoors, or in a pocket, at street level, so the buildings around it take more
#: than the distance explains. A link between two companions takes it two times. Thus a
#: companion reaches the repeaters near it, and another companion only when the two are
#: close, as on a real mesh. Our node has its antenna on a roof, and the room servers and
#: the sensors are outdoors, so they do not take it.
_GROUND_DB = 7.0
#: Links that the terrain blocks more than the model knows, by the names of their two
#: ends, in dB of extra loss. The buildings around our node hide the big repeaters from
#: it. Thus each flood reaches our node through the two small repeaters near it, usually
#: one copy from each. This is what a real companion in Montréal hears: two copies of most
#: messages, on paths that share their start and divide near the end.
_OBSTRUCTED: dict[frozenset[str], float] = {
frozenset((SELF.name, name)): 14.0
for name in ("Mont-Royal", "Oratoire", "Centre-Ville", "Stade-Olympique", "Ahuntsic")
}
def _ground_loss(a: Node, b: Node) -> float:
"""The loss that the street level adds to a link (refer to :data:`_GROUND_DB`)."""
return _GROUND_DB * (_indoors(a) + _indoors(b))
def _indoors(node: Node) -> bool:
"""Whether ``node`` is a companion at street level: each one except our node."""
return node.node_type == NODE_TYPE_CHAT and node is not SELF
def _skew(a: Node, b: Node) -> float:
"""A fixed offset (dB) for the link from ``a`` to ``b``: the terrain that no model knows.
@@ -253,6 +286,7 @@ def link_snr(a: Node, b: Node) -> float | None:
return None
km = max(haversine_km(a.lat, a.lon, b.lat, b.lon), 0.05)
snr = _ONE_KM_DB - _DECADE_DB * math.log10(km) + a.site_db + b.site_db + _skew(a, b)
snr -= _ground_loss(a, b) + _OBSTRUCTED.get(frozenset((a.name, b.name)), 0.0)
if snr > _SOFT_CAP_DB:
snr = _SOFT_CAP_DB + (snr - _SOFT_CAP_DB) * 0.25
return round(snr, 2) if snr >= FLOOR_DB else None
@@ -336,6 +370,30 @@ def _link_cost(snr: float) -> float:
return 1.0 + 4.0 * loss_chance(snr)
#: The nodes whose floods do not use three-byte hashes, and the width that they use. Our
#: node keeps the default of its device, one byte.
_NARROW_PATHS: dict[str, int] = {
SELF.name: 1,
"Steve K.": 2,
"Raphaël": 2,
"Olivier.T": 1,
"Tomás": 1,
"T1000-E": 1,
"Wio Tracker": 1,
"Mobile Node": 1,
}
def path_width(origin: Node) -> int:
"""The bytes of each hash in the path of a flood from ``origin``.
The node that starts a flood sets the width, and each relay adds its hash at that
width. Most nodes of the mesh of Montréal use three bytes, a few use two, and some
older ones still use one (:data:`_NARROW_PATHS`).
"""
return _NARROW_PATHS.get(origin.name, 3)
@dataclass(frozen=True, slots=True)
class Copy:
"""One copy of a flood that our node hears.
@@ -506,7 +564,15 @@ REGION_TABLES: dict[str, list[tuple[str, str, bool]]] = {}
_ISLAND = [("qc", "*", True), ("mtl", "qc", True), ("mtl-west", "mtl", True)]
_NORTH = [("qc", "*", True), ("mtl", "qc", True), ("laval", "qc", True)]
_SOUTH = [("qc", "*", True), ("mtl", "qc", False), ("south-shore", "qc", True)]
for _name in ("Oratoire", "Centre-Ville", "Stade-Olympique", "Verdun-Quai", "Ahuntsic"):
for _name in (
"Oratoire",
"Centre-Ville",
"Stade-Olympique",
"Verdun-Quai",
"Ahuntsic",
"Villeray",
"Rosemont",
):
REGION_TABLES[_name] = _ISLAND
for _name in ("Lachine", "Pointe-Claire", "Île-Bizard"):
REGION_TABLES[_name] = [*_ISLAND, ("west-island", "mtl-west", True)]
+44 -7
View File
@@ -80,10 +80,13 @@ NOW = datetime(2026, 10, 15, 11, 42, tzinfo=timezone(timedelta(hours=-4)))
#: system.
TZ = "EST5EDT"
#: How many times the capture presses ↑ in the chat to select the message whose paths it
#: shows: the line of Gabriel in Beloeil, which arrives on five paths through
#: Mont-St-Hilaire.
PATHS_MESSAGE = 2
#: The message whose paths the capture shows: the channel to type in the picker, how many
#: times to press ↑ from the newest message, and a part of its text near the start, which
#: the quote at the top of the dialog shows on each platform. The capture checks the quote,
#: and presses ↑ again until it finds the message, because a key press can be lost while
#: the transcript draws. In the dialog it then selects the second path: the first one is
#: often a straight line.
PATHS_MESSAGE = ("public", 2, "Hello from Beloeil")
def main(argv: list[str]) -> int:
@@ -177,6 +180,15 @@ class Capture:
screen = self.terminal.text()
raise TimeoutError(f"{text!r} did not appear. The screen:\n{screen}")
def quote(self) -> str:
"""The quote of the message in the paths dialog: the line above its "heard" line."""
with self.lock:
lines = self.terminal.text().splitlines()
for index, line in enumerate(lines):
if index and "heard" in line and "time" in line:
return lines[index - 1]
return ""
def grid(self) -> list[list[tuple[str, object]]]:
"""A copy of the cells of the screen."""
with self.lock:
@@ -269,10 +281,35 @@ def _tour(capture: Capture, type_text: Callable[[str], None], save: Callable[[st
capture.settle()
save("chat")
# The paths of a message.
press("up", count=PATHS_MESSAGE)
channel, ups, snippet = PATHS_MESSAGE
if channel != "public":
press(text="w", ctrl=True)
capture.settle(quiet=0.8)
press("escape")
typed("chat")
press("enter")
capture.wait_for("Public")
capture.settle()
typed(channel)
press("enter")
capture.settle(quiet=1.5)
for _ in range(ups):
press("up")
time.sleep(0.15)
capture.settle(quiet=0.6)
press(text="p", ctrl=True)
capture.settle(quiet=2.0)
for _ in range(8):
press(text="p", ctrl=True)
capture.settle(quiet=2.0)
if snippet in capture.quote():
break
press("escape")
capture.settle(quiet=0.6)
press("up")
capture.settle(quiet=0.6)
else:
raise TimeoutError(f"no message with {snippet!r} in {channel}")
press("down")
capture.settle(quiet=1.0)
save("paths")
# The map, from the main menu.
press("escape")
+2 -2
View File
@@ -62,7 +62,7 @@ def test_the_demo_device_traces_a_path() -> None:
async def run(): # noqa: ANN202
device = DemoDevice()
await device.connect()
spec = ",".join(world.node(n).hash for n in ("Mont-Royal", "Oratoire", "Mont-Royal"))
spec = ",".join(world.node(n).hash for n in ("Rosemont", "Mont-Royal", "Rosemont"))
for _ in range(10):
result = await device.run_trace("(path)", path=spec)
if result.success:
@@ -71,7 +71,7 @@ def test_the_demo_device_traces_a_path() -> None:
result = asyncio.run(run())
assert result.success
assert [h.node for h in result.hops] == ["2a", "4f", "2a", None]
assert [h.node for h in result.hops] == ["d8", "2a", "d8", None]
def test_prepare_writes_a_home(tmp_path: Path) -> None: