Files
MeshTerm/meshterm/services/topology.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

1053 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The observed mesh topology: a link graph distilled from every path we ever received.
MeshCore never tells us the network's shape outright, but almost everything we *receive*
carries a fragment of it: a successful trace demonstrably crossed every link in its path
(out and back, with an SNR reading at each hop), the firmware's per-contact ``out_path``
is a route distilled from received floods, and — when the companion's packet logging is
on — every overheard frame reports the relay chain it rode in on. A fourth fragment can
be *asked for*: a repeater we hold admin rights on reports its own neighbour table (who
it hears directly, at what SNR) when queried over the mesh — a second vantage point that
reveals links, and whole nodes, our radio has never received anything from. This module
folds those fragments into one undirected evidence graph and answers the questions the
trace path composer asks of it:
* *who neighbours whom* — :meth:`MeshTopology.next_hops`, the suggestion list while
composing a path hop by hop, sorted by the strongest observed link first;
* *how might we reach a target* — :meth:`MeshTopology.scenarios`, ranked candidate
outbound paths (the device's learned route, the direct shot, and the best alternatives
the evidence supports) ready to be traced or probed.
Links are deliberately undirected: radio paths work both ways, and every reading — an SNR
measured at either end, a route learned from the opposite direction — is evidence for the
same physical link. Node identity is canonicalized against the contact list, because the
same node appears in different sources at different hash widths (a 1-byte trace hop, a
12-hex observation id, a full public key).
Everything here is pure data-in/data-out: the callers supply repository rows and contacts,
so the graph is unit-testable without a device or a database.
"""
from __future__ import annotations
import bisect
import heapq
import math
import statistics
from dataclasses import dataclass, field
from datetime import datetime
from itertools import chain, pairwise
from ..core.models import Contact, utcnow
from ..persistence.repository import NeighbourLink, PacketPath, TracedPath
#: Canonical id for our own device in the graph (its 12-hex key prefix when known).
#: Kept distinct from hop hashes by construction: it is derived from the full public key.
#: Half-life, in days, of link evidence: a reading a week old counts half as much as one
#: from just now. Old links stay suggestible but fresh observations dominate the ranking.
_EVIDENCE_HALF_LIFE_DAYS = 7.0
#: SNR range mapped onto the link-quality factor: -15 dB (barely readable) scores the
#: floor, +10 dB (excellent) the ceiling. Matches the trace screen's quality-bar span.
_SNR_FLOOR_DB = -15.0
_SNR_CEIL_DB = 10.0
_QUALITY_MIN = 0.15
_QUALITY_MAX = 1.5
#: Per-hop cost added when ranking candidate paths, so a marginally "stronger" long way
#: around doesn't beat a short path — every extra hop is real airtime and a real failure
#: point (and a trace crosses it twice: out and back).
_HOP_PENALTY = 0.35
#: Bounds for scenario generation: how many alternatives to offer and how deep a
#: suggested route may go (each hop is crossed twice by the trace's boomerang).
_MAX_SCENARIOS = 5
_MAX_SCENARIO_HOPS = 5
#: The bar an ambiguous short hash must clear before it folds onto one of the nodes it
#: could name (see :meth:`MeshTopology._corroborated_owner`): how many *discriminating*
#: neighbours — ones that link to exactly one candidate — have to vote for the winner, and
#: how far ahead of the runner-up it has to finish. Both are set to leave a contested stub
#: alone: two independent neighbours agreeing with nothing dissenting is evidence, one is a
#: coincidence, and a close second means the hash is probably pooling two nodes' traffic.
_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.
_MISS = object()
def is_path_hash(value: str) -> bool:
"""Whether ``value`` could be a path hash: non-empty hex, a whole number of bytes.
THE test, shared by everything that has to tell a hop id from a name or a graph
sentinel. Even length because a hash is bytes, never a half one — which is also what
keeps a one-character endpoint marker out.
"""
return bool(value) and len(value) % 2 == 0 and all(c in _HEX_DIGITS for c in value)
def render_forced_spec(hops: tuple[str, ...], target_hash: str, width_bytes: int) -> str:
"""Render a symmetric forced-path spec: outbound hops, the target, the mirror back.
The MeshCore trace protocol has no separate "return path" field — the whole
boomerang (out to the target, back to us) is one path the repeaters walk in
order, so the spec must spell out the return hops explicitly, not just the
outbound ones. This renders the *symmetric* round trip — the return leg is the
outbound hops in reverse, appended here rather than asked of the caller. A
hand-composed route that comes home a different way is rendered verbatim by
:func:`render_custom_spec` instead.
Args:
hops: Intermediate repeaters, in order from us outward, excluding both
endpoints (empty = trace the target directly, no forced hops).
target_hash: The target's hex hash (any width ≥ 1 byte).
width_bytes: Preferred per-hop path-hash width in bytes (1, 2, 4, or 8);
shrunk via :func:`collapse_width` when a hop's known hash is narrower.
Returns:
A comma-separated hex spec, e.g. ``"3d,f2,3d"`` for one forced hop.
"""
width = collapse_width(*hops, target_hash, ceiling=width_bytes)
outbound = [h[: width * 2] for h in (*hops, target_hash)]
outbound.extend(h[: width * 2] for h in reversed(hops))
return ",".join(outbound)
def render_custom_spec(hops: tuple[str, ...], width_bytes: int) -> str:
"""Render a hand-composed (asymmetric) path spec exactly as given.
The counterpart of :func:`render_forced_spec` for a route the user spelled out in
full — out to the target and back home by whatever way they chose. Nothing is
appended or mirrored: the hops *are* the path, and the trace protocol happily
walks any sequence that ends within earshot of us. Only the shared width rules
apply.
Args:
hops: Every hop of the route in walk order, the target among them, excluding
our own device at both ends (the reply lands on us off the final hop).
width_bytes: Preferred per-hop path-hash width in bytes (1, 2, 4, or 8);
shrunk via :func:`collapse_width` when a hop's known hash is narrower.
Returns:
A comma-separated hex spec, e.g. ``"3d,f2,27"`` — or ``""`` with no hops
(there is no meaningful zero-hop custom route).
"""
if not hops:
return ""
width = collapse_width(*hops, ceiling=width_bytes)
return ",".join(h[: width * 2] for h in hops)
def collapse_width(*hashes: str, ceiling: int) -> int:
"""The widest representable per-hop hash width every given hash can honour.
Trace paths transmit hops at a uniform width of 1, 2, 4, or 8 bytes. Canonical node
ids are usually 6 bytes, but an unidentified hop may be as short as the width it was
once traced at — the spec must shrink to what its narrowest hash can supply.
Args:
*hashes: The hex hashes destined for one spec.
ceiling: The width the caller would prefer (bytes, itself 1/2/4/8).
Returns:
The largest of 1/2/4/8 that is ≤ ``ceiling`` and ≤ every hash's byte length.
"""
limit = min([ceiling, *(len(h) // 2 for h in hashes if h)] or [ceiling])
return max(s for s in (1, 2, 4, 8) if s <= max(limit, 1))
@dataclass(slots=True)
class Link:
"""The accumulated evidence for one undirected link between two nodes.
Attributes:
a: Canonical id of one endpoint (the lexically smaller, so the pair is a key).
b: Canonical id of the other endpoint.
samples: How many independent readings crossed this link, any source.
snrs: The SNR readings (dB) attributed to the link, either direction.
last_seen: When the link was most recently observed.
sources: Which evidence classes saw it (``trace`` / ``route`` / ``packet`` /
``neighbour``).
"""
a: str
b: str
samples: int = 0
snrs: list[float] = field(default_factory=list)
last_seen: datetime | None = None
sources: set[str] = field(default_factory=set)
@property
def median_snr(self) -> float | None:
"""Median SNR (dB) across the link's readings, or ``None`` without any."""
return statistics.median(self.snrs) if self.snrs else None
def strength(self, now: datetime | None = None) -> float:
"""Score the link's observed reliability for ranking (higher = stronger).
Three factors multiply: *evidence* (log-scaled sample count, so ten sightings
beat one but a thousand don't drown everything), *recency* (exponential decay
with a :data:`one-week half-life <_EVIDENCE_HALF_LIFE_DAYS>`), and *quality*
(median SNR mapped linearly from -15 dB → :data:`_QUALITY_MIN` to +10 dB →
beyond 1.0; links with no SNR reading — e.g. known only from a firmware route —
score a neutral 1.0 rather than being punished for missing data).
Args:
now: The reference time for recency (defaults to the current time).
Returns:
The link's strength score, ``> 0``.
"""
now = now or utcnow()
evidence = 1.0 + math.log2(1 + self.samples)
recency = 1.0
if self.last_seen is not None and getattr(self.last_seen, "tzinfo", None):
age_days = max(0.0, (now - self.last_seen).total_seconds() / 86400.0)
recency = 0.5 ** (age_days / _EVIDENCE_HALF_LIFE_DAYS)
snr = self.median_snr
quality = 1.0
if snr is not None:
span = _SNR_CEIL_DB - _SNR_FLOOR_DB
frac = (snr - _SNR_FLOOR_DB) / span
reach = _QUALITY_MIN + frac * (_QUALITY_MAX - _QUALITY_MIN)
quality = min(_QUALITY_MAX, max(_QUALITY_MIN, reach))
return evidence * recency * quality
@dataclass(slots=True)
class HopSuggestion:
"""One suggested next hop while composing a path.
Attributes:
node: Canonical id of the suggested node.
link: The evidence for the link from the path's current tail to ``node``.
strength: The link's precomputed strength (the sort key), so the UI can render
a stable ranking without re-scoring per repaint.
"""
node: str
link: Link
strength: float
@dataclass(slots=True)
class PathScenario:
"""One candidate outbound route to a target, ready to trace.
A scenario stores only the *outbound* leg — the return is always the symmetric
mirror, so there's nothing to choose there — but :meth:`spec` renders the full
boomerang, since the trace protocol has no separate return-path field: the whole
out-and-back route is one spec the repeaters walk in order.
Attributes:
label: Short human description of where the route came from.
hops: Canonical ids of the intermediate repeaters, in order from us outward —
excluding both endpoints (empty = trace the target directly).
source: The evidence class that proposed it (``device`` / ``direct`` /
``observed``).
score: Ranking score (higher first): the path's weakest-link strength, hop count
penalized. Direct/device scenarios with no observed evidence score 0.
weakest_snr: The lowest per-link median SNR (dB) along the route, when known —
the bottleneck the trace is expected to measure.
samples: Total readings across the route's links (a rough confidence figure).
"""
label: str
hops: tuple[str, ...]
source: str
score: float
weakest_snr: float | None = None
samples: int = 0
def spec(self, target_hash: str, width_bytes: int) -> str:
"""Render the scenario as a forced-path spec, return leg included.
See :func:`render_forced_spec` for the width-collapsing and return-leg rules.
Args:
target_hash: The target's hex hash (any width ≥ 1 byte).
width_bytes: Preferred per-hop path-hash width in bytes (1, 2, 4, or 8).
Returns:
A comma-separated hex spec, e.g. ``"3d,f2,3d"``.
"""
return render_forced_spec(self.hops, target_hash, width_bytes)
class MeshTopology:
"""The evidence graph over observed mesh links, queryable for routing suggestions.
Build one with :func:`build_topology`; instances are cheap, immutable-in-spirit
snapshots — rebuild rather than mutate when fresh evidence lands.
"""
def __init__(self, self_id: str, contacts: list[Contact]) -> None:
"""Create an empty graph rooted at our own node.
Args:
self_id: Canonical id of our own device (12-hex key prefix, lowercased).
contacts: Known contacts, used to canonicalize hop hashes of any width to
stable node ids and to name nodes for display.
"""
self.self_id = self_id.lower().removeprefix("0x")[:12]
self._links: dict[tuple[str, str], Link] = {}
self._now = utcnow()
# Contact index for canonicalization: full lowercased public keys (falling back
# to the stored prefix) mapped to the 12-hex canonical id and display name.
self._known: list[tuple[str, str, str]] = [] # (full_key, canonical, name)
for c in contacts:
key = (c.public_key or c.key_prefix or "").lower().removeprefix("0x")
if key:
self._known.append((key, key[:12], c.name))
# ``_known`` never changes after construction, and a build feeds the same few
# dozen distinct hashes through canonical() thousands of times (every hop of
# every stored packet path), so both identity lookups memoize per instance.
self._canonical_memo: dict[str, str | None] = {}
self._name_memo: dict[str, str | None] = {}
# Route search results per (target, k), valid for one graph shape: every link
# mutation bumps the version, so a memoized Yen's run can never outlive the
# evidence it ranked. Callers ask for the same target twice per screen open
# (suggested() and scenarios() both run the search), and the search is the
# single priciest part of a topology-backed open.
self._graph_version = 0
self._routes_memo: dict[tuple[str, int, int], list[tuple[str, ...]]] = {}
# --- identity ----------------------------------------------------------------
def canonical(self, hop: str | None) -> str | None:
"""Collapse a hop hash of any width onto a stable node id.
Trace hops arrive at the command's path-hash width (1-8 bytes), observation ids
at 12 hex, routes at the region's width — all naming the same nodes. A hash that
prefix-matches exactly one known contact adopts that contact's 12-hex id; an
ambiguous or unknown hash stays as itself, so evidence about nodes we can't
identify is kept (under the short id) rather than guessed onto the wrong node.
Args:
hop: The raw hex hash (any case, optional ``0x``), or ``None``/empty.
Returns:
The canonical id, or ``None`` for a missing/non-hex hop (non-hex covers
legacy simulator artefacts like ``hop0``).
"""
if not hop:
return None
cached = self._canonical_memo.get(hop, _MISS)
if cached is not _MISS:
return cached
needle = hop.lower().removeprefix("0x")
if not is_path_hash(needle):
result: str | None = None
else:
matches = {
canonical
for key, canonical, _name in self._known
if key.startswith(needle) or needle.startswith(key[:12])
}
result = next(iter(matches)) if len(matches) == 1 else needle[:12]
self._canonical_memo[hop] = result
return result
def display_name(self, node: str) -> str | None:
"""The contact name for a canonical id, or ``None`` when unknown."""
cached = self._name_memo.get(node, _MISS)
if cached is not _MISS:
return cached
result = next((name for _key, canonical, name in self._known if canonical == node), None)
self._name_memo[node] = result
return result
# --- construction ------------------------------------------------------------
def add_walk(
self,
nodes: list[str | None],
*,
snrs: list[float | None] | None = None,
when: datetime | None = None,
source: str,
) -> None:
"""Record one walked node sequence as a set of link readings.
Consecutive nodes become one reading each on their undirected link. ``nodes``
holds canonical ids (``None`` entries — unresolvable hops — break the chain, so
no false adjacency is invented across them). Self-loops (a hash repeated
back-to-back, e.g. a manually forced there-and-back) are skipped.
Args:
nodes: The walked sequence, endpoints included, in order.
snrs: Optional per-link SNR readings aligned with the links (``snrs[i]``
belongs to the ``nodes[i] → nodes[i+1]`` link, measured at arrival).
when: When the walk was observed (stamps every touched link's recency).
source: Evidence class tag (``trace`` / ``route`` / ``packet`` /
``neighbour``).
"""
self._graph_version += 1 # link evidence moved; memoized route searches expire
for i in range(len(nodes) - 1):
a, b = nodes[i], nodes[i + 1]
if not a or not b or a == b:
continue
key = (a, b) if a < b else (b, a)
link = self._links.get(key)
if link is None:
link = self._links[key] = Link(a=key[0], b=key[1])
link.samples += 1
link.sources.add(source)
snr = snrs[i] if snrs and i < len(snrs) else None
if snr is not None:
link.snrs.append(snr)
if when is not None and (link.last_seen is None or when > link.last_seen):
link.last_seen = when
def coalesce_prefixes(self) -> None:
"""Fold each under-specified node id into the longer id it can only be.
The same physical node reaches the graph at different hash widths: a trace hop
logged at 1 byte (``27``), a packet relay at 3 (``27d439``), a full 6-byte
contact id (``27d4396a2967``). :meth:`canonical` widens a hop onto a contact id
only when the prefix names *exactly one* contact — but the mesh holds two nodes
whose keys both open ``27`` (``27d4…`` and ``27e4…``), so the bare ``27`` hop
stays short and stands beside the wide ``27d4396a2967`` as a phantom second node.
Left alone that doubles the node on every surface the graph feeds — the mesh walk
draws it twice, the route graph fans two lanes to one repeater, and the scenario
ranker offers a redundant weaker route through the stub.
This closes the gap using the evidence the graph *actually holds* rather than the
whole contact list, in two passes of descending confidence:
1. **By prefix alone** (:meth:`_next_prefix_merge`) — a short id is merged into a
longer node id present in the graph when it is a strict prefix of **exactly
one** of them (following a prefix chain — ``65`` → ``6532`` → ``6532eb`` — to
its longest end). Nothing is inferred: the short can only mean that one node.
2. **By corroboration** (:meth:`_next_corroborated_merge`) — a short id that opens
two distinct longer nodes (``c5`` → ``c5bc…`` and ``c5ba…``) is ambiguous by
prefix, but the graph usually knows which one it is anyway: the stub carries the
links of whichever node it really was, so its *neighbourhood* names the owner.
Neighbours that can tell the candidates apart vote, and a decisive result folds
the stub onto the winner. See that method for the bar a vote has to clear.
The passes interleave: the graph changes with every merge, so an ambiguity can
resolve itself once a neighbouring stub folds, and the certain pass is always
re-run before the inferring one gets another turn.
A short id that opens no longer node at all (one we have only ever heard narrowly)
keeps its width — it is one node, merely under-named — and so does one whose vote
stays contested. Merging folds the short id's links into the wide id's, summing
samples, pooling SNR readings and sources, and keeping the freshest sighting, so
the wide node inherits every reading the stub had gathered. Idempotent: a second
call finds nothing left to merge.
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:
# 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, adjacency=adjacency)
def _node_ids(self) -> set[str]:
"""Every node id that currently appears as a link endpoint."""
return set(chain.from_iterable(self._links))
def _next_prefix_merge(self, shorts: list[str], ordered: list[str]) -> tuple[str, str] | None:
"""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.
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`).
"""
for short in shorts:
exts = self._extensions(short, ordered)
if not exts:
continue
longest = max(exts, key=len)
if all(longest.startswith(ext) for ext in exts): # one node, not two
return short, longest
return None
@staticmethod
def _extensions(short: str, ordered: list[str]) -> list[str]:
"""Every node id in ``ordered`` that strictly extends the under-specified ``short``.
Empty for a full-width canonical id (12 hex is never under-specified) and for a
short id nothing in the graph extends.
``ordered`` must be the node ids sorted lexicographically, which is what makes this
cheap: every id sharing a prefix sorts into one contiguous run, so the extensions
are a slice found by binary search rather than a scan of the whole graph. That
matters because the caller asks this question once per node, and its own caller
starts the whole pass again after every merge — a mesh with ~1100 links and a couple
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) >= _CANONICAL_WIDTH:
return []
out: list[str] = []
width = len(short)
for i in range(bisect.bisect_left(ordered, short), len(ordered)):
other = ordered[i]
if not other.startswith(short):
break # past the run: nothing further can share the prefix
if len(other) > width:
out.append(other)
return out
def _next_corroborated_merge(
self, shorts: list[str], ordered: list[str], adjacency: dict[str, set[str]]
) -> tuple[str, str] | None:
"""The next ambiguous stub the *neighbourhood evidence* resolves, or ``None``.
Where :meth:`_next_prefix_merge` folds only what the hash alone settles, this
answers the case it walks away from: a short id like ``bf`` that opens two real
nodes (``bf61f2…`` and ``bfbeef…``). Prefix-wise that is a coin toss — but the stub
is not an empty label. It carries the links of whichever node it actually was, so
the company it keeps names its owner: every neighbour that can *tell the candidates
apart* — one it links to that links to exactly one candidate — is a vote, and the
rest (neighbours both candidates share, which two repeaters in the same city have
plenty of) abstain rather than drown the signal in local density.
The bar is deliberately high, because a wrong merge does what this whole routine
exists to prevent — attributes one node's evidence to another. The winner must draw
at least :data:`_MIN_CORROBORATION` discriminating votes *and* lead the runner-up by
:data:`_CORROBORATION_MARGIN`×, so a genuinely contested stub (one whose neighbours
point both ways, i.e. one that really is two nodes' traffic pooled under one hash)
stays exactly where it was: standing on its own, under-named but honest.
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`).
"""
for short in shorts:
exts = self._extensions(short, ordered)
candidates = [
ext
for ext in exts
if not any(other != ext and other.startswith(ext) for other in exts)
]
if len(candidates) < 2: # settled (or ignored) by the certain pass
continue
owner = self._corroborated_owner(short, candidates, adjacency)
if owner is not None:
return short, owner
return None
def _adjacency(self) -> dict[str, set[str]]:
"""The graph as neighbour sets, for the questions link pairs answer awkwardly."""
adjacency: dict[str, set[str]] = {}
for a, b in self._links:
adjacency.setdefault(a, set()).add(b)
adjacency.setdefault(b, set()).add(a)
return adjacency
@staticmethod
def _corroborated_owner(
short: str, candidates: list[str], adjacency: dict[str, set[str]]
) -> str | None:
"""Which candidate the stub's discriminating neighbours elect, if any.
A neighbour votes only when it separates the field — it neighbours exactly one
candidate — so shared company counts for nothing and the ``short``/candidate ids
themselves never vote for their own case. ``None`` means no decisive winner: too
few votes, or a runner-up close enough that the stub may well be both nodes'
traffic under one hash.
Args:
short: The under-specified id being resolved.
candidates: The distinct node ids it could name (two or more).
adjacency: Neighbour sets over the whole graph (see :meth:`_adjacency`).
Returns:
The elected candidate, or ``None`` to leave the stub standing.
"""
field = set(candidates) | {short}
votes = dict.fromkeys(candidates, 0)
for neighbour in adjacency.get(short, ()) - field:
electors = [c for c in candidates if neighbour in adjacency.get(c, ())]
if len(electors) == 1:
votes[electors[0]] += 1
ranked = sorted(votes.items(), key=lambda pair: (-pair[1], pair[0]))
(winner, top), (_runner, second) = ranked[0], ranked[1]
if top < _MIN_CORROBORATION or top < _CORROBORATION_MARGIN * second:
return None
return winner
def _merge_node(
self, src: str, dst: str, *, adjacency: dict[str, set[str]] | None = 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 far in adjacency.pop(src, ()):
link = self._links.pop((src, far) if src < far else (far, src), None)
if link is None:
continue
adjacency[far].discard(src)
if far == dst: # a src→dst link (src prefixes dst) collapses to a self-loop
continue
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(
a=new_key[0],
b=new_key[1],
samples=link.samples,
snrs=list(link.snrs),
last_seen=link.last_seen,
sources=set(link.sources),
)
else:
existing.samples += link.samples
existing.snrs.extend(link.snrs)
existing.sources |= link.sources
if link.last_seen is not None and (
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 -----------------------------------------------------------------
@property
def link_count(self) -> int:
"""How many distinct links the graph holds evidence for."""
return len(self._links)
def links(self) -> list[Link]:
"""Every link the graph holds evidence for (no particular order).
The whole-graph view the Mesh walk draws; path queries should prefer
:meth:`next_hops` / :meth:`scenarios`, which rank and filter.
"""
return list(self._links.values())
def link(self, a: str, b: str) -> Link | None:
"""The evidence for the undirected link between ``a`` and ``b``, if any."""
return self._links.get((a, b) if a < b else (b, a))
def next_hops(self, tail: str, *, exclude: frozenset[str] = frozenset()) -> list[HopSuggestion]:
"""Suggest nodes reachable from ``tail``, strongest observed link first.
This is the composer's step-by-step feed: standing at the path's current tail,
every node the evidence says ``tail`` can hear (links are bidirectional, so a
path observed in either direction qualifies) — minus the nodes already used.
Args:
tail: Canonical id the path currently ends at (our own id to start).
exclude: Canonical ids to omit (nodes already in the path; revisiting one
is never useful and duplicated hops are dropped by repeaters anyway).
Returns:
Suggestions sorted by descending link strength.
"""
out: list[HopSuggestion] = []
for (a, b), link in self._links.items():
other = b if a == tail else a if b == tail else None
if other is None or other in exclude:
continue
out.append(HopSuggestion(node=other, link=link, strength=link.strength(self._now)))
out.sort(key=lambda s: (-s.strength, s.node))
return out
def scenarios(
self, target: str, *, device_route: tuple[str, ...] | None = None
) -> list[PathScenario]:
"""Rank the candidate outbound routes for reaching ``target``.
Three families, deduplicated in this priority order:
1. **device** — the route the firmware itself learned from received floods (what
an auto-routed trace would walk), when the caller supplies one;
2. **direct** — no repeaters at all, always offered as the baseline;
3. **observed** — the strongest simple paths through the evidence graph
(weakest-link scoring with a per-hop penalty), which is where a path the
device never learned — but the data supports — comes from.
Every scenario stores an *outbound* leg only — the return is always those hops
mirrored, so there's nothing to rank there — but :meth:`PathScenario.spec`
renders the full boomerang; intermediate links are implicitly crossed twice.
Args:
target: The target's canonical id.
device_route: The firmware's learned route (canonical intermediate hops),
if the contact has one.
Returns:
Scenarios ranked best-evidence first (the device route, when present, is
always listed first regardless of score — it is what "auto" would do).
"""
seen: set[tuple[str, ...]] = set()
out: list[PathScenario] = []
def add(label: str, hops: tuple[str, ...], source: str) -> None:
if hops in seen or len(hops) > _MAX_SCENARIO_HOPS or target in hops:
return
seen.add(hops)
score, weakest, samples = self._score_route(hops, target)
out.append(
PathScenario(
label=label,
hops=hops,
source=source,
score=score,
weakest_snr=weakest,
samples=samples,
)
)
if device_route is not None:
add("device route", device_route, "device")
add("direct", (), "direct")
for hops in self._best_routes(target, k=_MAX_SCENARIOS):
add("observed path", hops, "observed")
# Rank observed evidence, but keep the device route pinned on top: it is the
# reference point every alternative is compared against.
head = [s for s in out if s.source == "device"]
rest = sorted(
(s for s in out if s.source != "device"),
key=lambda s: (-s.score, len(s.hops)),
)
return (head + rest)[:_MAX_SCENARIOS]
def suggested(self, target: str) -> PathScenario | None:
"""The single best *evidence-backed* outbound route to ``target``, if one exists.
The data-driven answer to "what's the best way to reach this node?": the highest-
scoring **observed** scenario — a route through repeaters the recorder has actually
watched carry traffic (traces, overheard relay chains, fetched neighbour tables),
ranked weakest-link-first with a per-hop penalty and its links' sample counts and
median SNR behind the score. It deliberately ignores the *device* and *direct*
families :meth:`scenarios` also offers: the firmware's learned route is what an
auto-routed trace already walks (the caller prefers it when it has one), and a
direct shot is the trivial fallback — neither is a *suggestion* the observations
made. A node the evidence can only reach directly (or not at all) yields ``None``,
so the caller keeps whatever default it would have used.
Args:
target: The target's canonical id.
Returns:
The best observed :class:`PathScenario` (always with at least one intermediate
hop and a positive, evidence-backed score), or ``None`` when the observations
support nothing better than a direct route.
"""
for scenario in self.scenarios(target):
if scenario.source == "observed" and scenario.hops and scenario.score > 0:
return scenario
return None
# --- internals -----------------------------------------------------------------
def _score_route(self, hops: tuple[str, ...], target: str) -> tuple[float, float | None, int]:
"""Score one outbound route by its weakest observed link.
A chain is only as reliable as its weakest link, so the route's score is the
minimum link strength along ``us → hops… → target``, discounted per hop (see
:data:`_HOP_PENALTY`). A route containing a link we have *no* evidence for
scores 0 — offerable, but ranked below anything actually observed.
Args:
hops: Intermediate canonical ids (may be empty for a direct route).
target: The destination's canonical id.
Returns:
``(score, weakest_median_snr, total_samples)``.
"""
chain = [self.self_id, *hops, target]
weakest_strength: float | None = None
weakest_snr: float | None = None
samples = 0
for a, b in pairwise(chain):
link = self.link(a, b)
if link is None:
return 0.0, None, samples
strength = link.strength(self._now)
samples += link.samples
if weakest_strength is None or strength < weakest_strength:
weakest_strength = strength
snr = link.median_snr
if snr is not None and (weakest_snr is None or snr < weakest_snr):
weakest_snr = snr
score = (weakest_strength or 0.0) / (1.0 + _HOP_PENALTY * len(hops))
return score, weakest_snr, samples
def _best_routes(self, target: str, *, k: int) -> list[tuple[str, ...]]:
"""Find up to ``k`` strong simple paths from us to ``target``, cheapest first.
Yen's k-shortest-loopless-paths over the evidence graph. Each link costs
``1/strength + _HOP_PENALTY`` — a weak or roundabout way scores dearer — and the
routes come back ranked by total cost, cheapest first (the candidate set
:meth:`scenarios` then re-scores by weakest link). Simple (loopless) paths only,
since revisiting a node is never useful, and only routes within
:data:`_MAX_SCENARIO_HOPS` intermediate hops — a longer chain is more airtime and
more failure points than a trace should carry (and it is crossed twice).
Yen's builds on plain Dijkstra rather than enumerating simple paths with a
best-first frontier, because that frontier is pathological on a real mesh graph.
A well-heard core has cheap links everywhere, so a partial-path search *floods*
it — every wandering prefix through the core costs less than the one weak link a
distant target sits behind — exploring millions of dead-end prefixes before it
reaches the target (tens of seconds for a busy node). Yen's instead finds the
single shortest path, then derives each next-shortest by rerouting around one
edge of the previous one, so the work stays polynomial in the graph size however
dense the core: the same routes, in the same cost order (ties aside), in
milliseconds.
Args:
target: The destination's canonical id.
k: Maximum number of routes to return.
Returns:
The intermediate-hop tuples of the found routes (endpoints stripped),
cheapest total-cost first. Memoized per graph version (see
``_routes_memo``) — callers treat the list as read-only, which they do
(both consumers only iterate it).
"""
memo_key = (target, k, self._graph_version)
memoized = self._routes_memo.get(memo_key)
if memoized is not None:
return memoized
neighbors: dict[str, list[tuple[str, float]]] = {}
for (a, b), link in self._links.items():
cost = 1.0 / max(link.strength(self._now), 1e-6) + _HOP_PENALTY
neighbors.setdefault(a, []).append((b, cost))
neighbors.setdefault(b, []).append((a, cost))
def shortest(
source: str, banned_nodes: set[str], banned_edges: set[tuple[str, str]]
) -> tuple[float, list[str]] | None:
"""Dijkstra ``source``→``target`` avoiding the banned nodes/edges, or ``None``."""
dist: dict[str, float] = {source: 0.0}
prev: dict[str, str] = {}
heap: list[tuple[float, str]] = [(0.0, source)]
done: set[str] = set()
while heap:
d, u = heapq.heappop(heap)
if u in done:
continue
done.add(u)
if u == target:
break
for v, cost in neighbors.get(u, ()):
if v in banned_nodes or (u, v) in banned_edges:
continue
nd = d + cost
if nd < dist.get(v, math.inf):
dist[v] = nd
prev[v] = u
heapq.heappush(heap, (nd, v))
if target not in dist:
return None
path = [target]
while path[-1] != source:
path.append(prev[path[-1]])
path.reverse()
return dist[target], path
def path_cost(path: list[str]) -> float:
total = 0.0
for a, b in pairwise(path):
total += min((c for v, c in neighbors.get(a, ()) if v == b), default=math.inf)
return total
first = shortest(self.self_id, set(), set())
if first is None or len(first[1]) - 2 > _MAX_SCENARIO_HOPS:
self._routes_memo[memo_key] = []
return []
accepted: list[list[str]] = [first[1]]
# Spur candidates, a min-heap by total cost (counter tiebreaker so equal-cost
# candidates never fall through to comparing the path lists themselves).
candidates: list[tuple[float, int, list[str]]] = []
seen: set[tuple[str, ...]] = {tuple(first[1])}
counter = 0
while len(accepted) < k:
prev_path = accepted[-1]
for i in range(len(prev_path) - 1):
spur = prev_path[i]
root = prev_path[: i + 1]
# Ban the next edge every accepted path with this same root took, so the
# spur is forced onto a genuinely different route out of the spur node...
banned_edges: set[tuple[str, str]] = set()
for p in accepted:
if p[: i + 1] == root and len(p) > i + 1:
banned_edges.add((p[i], p[i + 1]))
banned_edges.add((p[i + 1], p[i]))
# ...and ban the root's interior nodes so the detour stays loopless.
banned_nodes = set(root[:-1])
spur_result = shortest(spur, banned_nodes, banned_edges)
if spur_result is None:
continue
full = root[:-1] + spur_result[1]
if len(full) - 2 > _MAX_SCENARIO_HOPS:
continue
key = tuple(full)
if key in seen:
continue
seen.add(key)
counter += 1
heapq.heappush(candidates, (path_cost(full), counter, full))
if not candidates:
break
_, _, best_path = heapq.heappop(candidates)
accepted.append(best_path)
routes = [tuple(path[1:-1]) for path in accepted]
self._routes_memo[memo_key] = routes
return routes
def build_topology(
*,
self_id: str,
contacts: list[Contact],
trace_paths: list[TracedPath],
packet_paths: list[PacketPath],
neighbour_links: tuple[NeighbourLink, ...] | list[NeighbourLink] = (),
) -> MeshTopology:
"""Assemble the evidence graph from everything we have received — or asked for.
Four sources fold in, each one walked through :meth:`MeshTopology.add_walk`:
* **traces** — each successful trace's hop sequence, bracketed by us at both ends
(the boomerang leaves us and returns to us; the final hash-less hop *is* us).
Every hop's SNR is a reading on the link it arrived over.
* **contact routes** — the firmware's learned ``out_path`` per contact: a chain from
us through its repeaters to the contact. No SNR, but it is the distillation of
real received floods, so it counts as one reading per link (stamped with the
contact's last-heard time). A learned *direct* route is a direct link to us.
* **packets** — RX-logged frames: the relay chain, prefixed by the originator when
known and always ending at us (we heard the last relay). The measured SNR belongs
to that final link only.
* **neighbour reports** — links a remote repeater asserted about itself when we
fetched its neighbour table: one two-node walk per entry, SNR as measured at the
repeater. The only evidence here not derived from our own reception — it can
introduce nodes no other source has ever seen.
Args:
self_id: Our device's key/hash (any width; canonicalized to 12 hex).
contacts: The device's known contacts (canonicalization + route evidence).
trace_paths: Stored successful trace walks (see ``Repository.trace_paths``).
packet_paths: Stored RX-logged packet paths (see ``Repository.packet_paths``).
neighbour_links: Current repeater-reported links (see
``Repository.neighbour_links``); empty when none have been fetched.
Returns:
The populated :class:`MeshTopology`.
"""
topo = MeshTopology(self_id, contacts)
us = topo.self_id
for traced in trace_paths:
nodes: list[str | None] = [us]
snrs: list[float | None] = []
for hop, snr in traced.hops:
# The final hash-less hop is the reply landing back at us.
nodes.append(topo.canonical(hop) if hop is not None else us)
snrs.append(snr)
topo.add_walk(nodes, snrs=snrs, when=traced.when, source="trace")
for contact in contacts:
if contact.route_hops is None:
continue
contact_id = topo.canonical(contact.public_key or contact.key_prefix)
route = [topo.canonical(h) for h in contact.route_hops]
topo.add_walk([us, *route, contact_id], when=contact.last_seen, source="route")
for packet in packet_paths:
nodes = []
if packet.origin:
nodes.append(topo.canonical(packet.origin))
nodes.extend(topo.canonical(h) for h in packet.hops)
nodes.append(us)
if len(nodes) < 2:
continue
# Only the last link (last relay → us) carries the measured reception SNR.
snrs = [None] * (len(nodes) - 2) + [packet.snr]
topo.add_walk(nodes, snrs=snrs, when=packet.when, source="packet")
for reported in neighbour_links:
topo.add_walk(
[topo.canonical(reported.repeater), topo.canonical(reported.neighbour)],
snrs=[reported.snr],
when=reported.when,
source="neighbour",
)
# Every source has folded in; collapse any node that reached us at two hash widths
# (a bare trace hop beside its wide contact id) so each appears once everywhere.
topo.coalesce_prefixes()
return topo