Mesh walk: one neighbour table, carried across the merges

The prefix settle walked every link in the graph three times per merge —
to derive the node set, to build the neighbour table the corroboration
vote reads, and to hunt down the links naming the id being folded. On the
device's mesh that is ~1150 links, three times over, some eighty times:
the largest remaining cost of opening the walk.

All three questions are one table's to answer, and a merge's effect on it
is knowable where it happens. So build it once and keep it: its keys are
the live node set, a fold relabels its own neighbours, and a stub whose
every link ran to its owner takes the owner out with it. Both passes now
walk only the under-specified ids, since a full-width one extends
nothing.

Ties in the shortest-first order break lexicographically too. Length
alone left equal-width ids in whatever order the node set iterated, and
CPython randomizes string hashing per process — so two runs over the same
evidence could take their merges in different orders and, where one fold
changes what a later one sees, settle to different graphs.

Verified byte-identical against the previous algorithm over twelve
randomized graphs, and 6.3x on the settle at that scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-09 05:42:20 -04:00
1 parent 6a002494c7
commit 714bb15fe6
2 files changed
+185 -29

No files matched your search

+96 -29
View File
@@ -34,6 +34,7 @@ import heapq
import math
import statistics
from dataclasses import dataclass, field
from itertools import chain
from datetime import datetime
from typing import Optional
@@ -73,6 +74,12 @@ _MAX_SCENARIO_HOPS = 5
_MIN_CORROBORATION = 2
_CORROBORATION_MARGIN = 3
#: The full canonical node-id width in hex digits (6 bytes of key). An id this wide is never
#: under-specified, so it can neither be folded onto another nor extend one — which is what
#: lets the prefix settle walk only the ids *below* this width (see
#: :meth:`MeshTopology.coalesce_prefixes`).
_CANONICAL_WIDTH = 12
_HEX_DIGITS = frozenset("0123456789abcdef")
#: Distinguishes "memoized as None" from "not memoized" in the identity caches.
@@ -440,33 +447,64 @@ class MeshTopology:
Called once at the end of :func:`build_topology`, so every consumer sees each node
once. Safe for our own node (its 12-hex id is never a short prefix candidate).
**One neighbour table is built and then carried across the merges**, rather than the
whole graph being walked afresh on every turn. Three separate passes over every link
used to run per merge — deriving the node set, building the neighbour table the
corroboration vote reads, and hunting down the links that name the id being folded —
and on the device's mesh that is ~1150 links walked three times over, some eighty
times: the largest remaining cost of opening the mesh walk. All three questions are
answered by the same table, and a merge's effect on it is knowable exactly where it
happens (see :meth:`_merge_node`), so the table is the graph's live node set and
adjacency for the whole settle. Its keys *are* the node ids: a node exists here
precisely while it holds a link.
"""
adjacency = self._adjacency()
while True:
nodes = self._node_ids()
merge = self._next_prefix_merge(nodes) or self._next_corroborated_merge(nodes)
# Only an under-specified id can fold, and a full-width one extends nothing —
# so both passes walk just the short ids, shortest first (a 65 → 6532 → 6532eb
# chain collapses from the tail end inward over successive turns), and a graph
# of fully named nodes settles without either of them looking at a single link.
#
# Ties break lexicographically, which is what makes a settle *repeatable*.
# Ordering by length alone leaves ids of equal width in whatever order the node
# set happened to iterate, and CPython randomizes string hashing per process —
# so two runs over the same evidence could take the merges in different orders
# and, where a fold changes what a later one sees, arrive at graphs that differ.
shorts = sorted(
(node for node in adjacency if len(node) < _CANONICAL_WIDTH),
key=lambda node: (len(node), node),
)
if not shorts:
return
ordered = sorted(adjacency)
merge = (
self._next_prefix_merge(shorts, ordered)
or self._next_corroborated_merge(shorts, ordered, adjacency)
)
if merge is None:
return
self._merge_node(*merge)
self._merge_node(*merge, adjacency=adjacency)
def _node_ids(self) -> set[str]:
"""Every node id that currently appears as a link endpoint."""
ids: set[str] = set()
for a, b in self._links:
ids.add(a)
ids.add(b)
return ids
return set(chain.from_iterable(self._links))
def _next_prefix_merge(self, nodes: set[str]) -> Optional[tuple[str, str]]:
def _next_prefix_merge(
self, shorts: list[str], ordered: list[str]
) -> Optional[tuple[str, str]]:
"""The next ``(short, long)`` pair to fold, or ``None`` when none remains.
A short id (under a full 6-byte canonical width) folds when the graph's longer
ids that extend it all lie on one prefix chain — i.e. the longest of them starts
with every other — so the short can only mean that one node. Shortest ids are
offered first, so a ``65`` → ``6532`` → ``6532eb`` chain collapses from the tail
end inward over successive calls.
with every other — so the short can only mean that one node.
Args:
shorts: The under-specified node ids, shortest first — so a ``65`` → ``6532``
→ ``6532eb`` chain collapses from the tail end inward over successive calls.
ordered: Every node id, sorted lexicographically (see :meth:`_extensions`).
"""
ordered = sorted(nodes)
for short in sorted(nodes, key=len):
for short in shorts:
exts = self._extensions(short, ordered)
if not exts:
continue
@@ -490,7 +528,7 @@ class MeshTopology:
of hundred merges to settle turned a linear scan here into ~24 million ``len``
calls and sixteen seconds of opening the mesh walk.
"""
if len(short) >= 12:
if len(short) >= _CANONICAL_WIDTH:
return []
out: list[str] = []
width = len(short)
@@ -502,7 +540,9 @@ class MeshTopology:
out.append(other)
return out
def _next_corroborated_merge(self, nodes: set[str]) -> Optional[tuple[str, str]]:
def _next_corroborated_merge(
self, shorts: list[str], ordered: list[str], adjacency: dict[str, set[str]]
) -> Optional[tuple[str, str]]:
"""The next ambiguous stub the *neighbourhood evidence* resolves, or ``None``.
Where :meth:`_next_prefix_merge` folds only what the hash alone settles, this
@@ -524,10 +564,14 @@ class MeshTopology:
Shortest ids are offered first, matching :meth:`_next_prefix_merge`, and only the
*maximal* candidates run — a chain's inner links (``f0`` → ``f062eb`` →
``f062eb…``) are the same node, so they never split their own vote.
Args:
shorts: The under-specified node ids, shortest first.
ordered: Every node id, sorted lexicographically (see :meth:`_extensions`).
adjacency: The graph's live neighbour table, carried by the settle loop that
calls this (see :meth:`coalesce_prefixes`).
"""
adjacency = self._adjacency()
ordered = sorted(nodes)
for short in sorted(nodes, key=len):
for short in shorts:
exts = self._extensions(short, ordered)
candidates = [
ext for ext in exts
@@ -580,19 +624,38 @@ class MeshTopology:
return None
return winner
def _merge_node(self, src: str, dst: str) -> None:
"""Relabel every link touching ``src`` onto ``dst``, folding shared links together."""
def _merge_node(
self, src: str, dst: str, *, adjacency: Optional[dict[str, set[str]]] = None
) -> None:
"""Relabel every link touching ``src`` onto ``dst``, folding shared links together.
Reached through the neighbour table rather than by scanning the graph: a link is
addressed by its two endpoints, so ``src``'s neighbours name its links directly —
a handful of dict lookups instead of a walk over every link in the mesh, on every
one of a settle's many merges.
Args:
src: The under-specified id being folded away.
dst: The node id it can only have meant.
adjacency: The caller's live neighbour table, **updated in place** to match the
reshaped graph: ``src`` leaves it, ``dst`` inherits ``src``'s neighbours,
and ``dst`` itself leaves in the one corner where every link ``src`` had ran
to ``dst`` — each collapses to a self-loop and is discarded, so unless
``dst`` held a link of its own it goes with them. No other node can lose its
last link here: a relabelled link keeps its far end. Omit it for a one-off
merge and the table is built (and thrown away) here.
"""
if adjacency is None:
adjacency = self._adjacency()
self._graph_version += 1 # the graph is being reshaped; memoized searches expire
for key in list(self._links):
if src not in key:
for far in adjacency.pop(src, ()):
link = self._links.pop((src, far) if src < far else (far, src), None)
if link is None:
continue
link = self._links.pop(key)
a, b = key
na = dst if a == src else a
nb = dst if b == src else b
if na == nb: # a src→dst link (src prefixes dst) collapses to a self-loop
adjacency[far].discard(src)
if far == dst: # a src→dst link (src prefixes dst) collapses to a self-loop
continue
new_key = (na, nb) if na < nb else (nb, na)
new_key = (dst, far) if dst < far else (far, dst)
existing = self._links.get(new_key)
if existing is None:
self._links[new_key] = Link(
@@ -608,6 +671,10 @@ class MeshTopology:
existing.last_seen is None or link.last_seen > existing.last_seen
):
existing.last_seen = link.last_seen
adjacency.setdefault(dst, set()).add(far)
adjacency[far].add(dst)
if not adjacency.get(dst, ()):
adjacency.pop(dst, None) # every link it had ran to the stub now folded away
# --- queries -----------------------------------------------------------------
+89
View File
@@ -860,3 +860,92 @@ def test_prefix_settle_folds_a_chain_but_not_a_fork() -> None:
assert "6532eb00aa11" in fork and "653300ff2211" in fork
assert "6532" not in fork, "an unambiguous link in the chain should still fold"
assert "65" in fork, "a stub opening two real nodes must not be guessed onto one"
def test_prefix_settle_is_repeatable_whatever_order_the_nodes_arrived_in() -> None:
"""The same evidence must always settle to the same graph.
The passes offer the under-specified ids shortest first, and for a while the ids of
equal width came out in whatever order the node *set* happened to iterate. CPython
randomizes string hashing per process, so two runs over identical evidence could take
their merges in different orders — and where one fold changes what a later one can see,
end up with different graphs. Feeding the same walks in reversed order reproduces that
(a set built by a different insertion sequence iterates differently); the settled
endpoints must not care.
"""
from meshterm.core.models import utcnow
from meshterm.services.topology import MeshTopology
walks = [
["local", "a1", "b2c3"], ["local", "a1b2", "b2c3d4"],
["local", "a1b2c3d4e5f6"], ["local", "b2c3d4e5f601"],
["local", "a1b2c3", "c4"], ["local", "c4d5e6f70011"],
["local", "b2", "a1b2c3d4e5f6"],
]
def settle(order):
topo = MeshTopology(self_id="local", contacts=[])
now = utcnow()
for walk in order:
topo.add_walk(list(walk), when=now, source="trace")
topo.coalesce_prefixes()
return sorted(topo._links)
assert settle(walks) == settle(list(reversed(walks)))
def test_a_merge_leaves_the_neighbour_table_exactly_as_a_fresh_one() -> None:
"""The settle carries one table across every merge instead of rebuilding it.
That is only sound while the incremental update is *exact* — a stale neighbour or a
node left behind after its last link folded away would feed the corroboration vote bad
evidence, or offer a merge candidate that no longer exists. So after each merge the
carried table has to equal one derived from the reshaped graph.
"""
from meshterm.core.models import utcnow
from meshterm.services.topology import MeshTopology
topo = MeshTopology(self_id="local", contacts=[])
now = utcnow()
for walk in (
["local", "a1", "b2c3d4e5f601"], ["local", "a1b2c3d4e5f6"],
["a1", "c4d5e6f70011"], ["a1b2", "a1b2c3d4e5f6"],
["b2c3d4e5f601", "c4d5e6f70011"], ["local", "d7"], ["d7e8f9001122", "local"],
):
topo.add_walk(list(walk), when=now, source="trace")
adjacency = topo._adjacency()
merges = 0
while True:
shorts = sorted(
(n for n in adjacency if len(n) < 12), key=lambda n: (len(n), n)
)
ordered = sorted(adjacency)
merge = topo._next_prefix_merge(shorts, ordered) or topo._next_corroborated_merge(
shorts, ordered, adjacency
)
if merge is None:
break
topo._merge_node(*merge, adjacency=adjacency)
merges += 1
assert adjacency == topo._adjacency(), f"table drifted after merging {merge}"
assert merges, "the fixture should exercise at least one merge"
def test_a_stub_whose_only_link_was_its_owner_takes_the_owner_out_with_it() -> None:
"""The corner the incremental table has to get right: both ends can leave at once.
``a1`` is heard only in company with ``a1b2c3d4e5f6`` — one link, between the two names
of one node. Folding the stub collapses that link to a self-loop and discards it, so the
owner is left holding nothing and leaves the graph too. Neither may linger in the node
set the next pass reads.
"""
from meshterm.core.models import utcnow
from meshterm.services.topology import MeshTopology
topo = MeshTopology(self_id="local", contacts=[])
topo.add_walk(["a1", "a1b2c3d4e5f6"], when=utcnow(), source="trace")
adjacency = topo._adjacency()
topo._merge_node("a1", "a1b2c3d4e5f6", adjacency=adjacency)
assert topo._links == {}
assert adjacency == {}