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:
1 parent
6a002494c7
commit
714bb15fe6
2 files changed
+185
-29
No files matched your search
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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 == {}
|
||||
Reference in new issue
Block a user