Files
MeshTerm/meshterm/services/path_probe.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

134 lines
5.3 KiB
Python

"""Multi-path probing: measure candidate routes to a target and rank what actually works.
The topology graph (:mod:`~meshterm.services.topology`) proposes routes from *received*
evidence; this module puts them to the test. Each candidate outbound path is traced once
(by default — repeaters penalize, and can blacklist, nodes that burst traffic), every
trace is persisted exactly like a normal trace run, the per-candidate aggregate is
recorded as a ``path_candidates`` row, and the outcomes come back ranked the way the TX
optimizer ranks its levels: reliability first, bottleneck SNR as the tie-breaker, then
round-trip time. The caller (the live trace screen) shows the ranking and offers to adopt
the winner — measurement proposes, the user disposes.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from ..core.connection import Device
from ..core.models import TraceResult, TraceStats
ProbeProgress = Callable[[int, int, TraceResult], None]
PersistTrace = Callable[[TraceResult], None]
PersistCandidate = Callable[["ProbeOutcome"], None]
@dataclass(slots=True)
class ProbeCandidate:
"""One route to measure.
Attributes:
label: Short description of where the route came from (shown in the results).
spec: The forced-path spec to trace — comma-separated hex hashes, outbound
only, ending at the target's own hash (the reply retraces it in reverse).
"""
label: str
spec: str
@dataclass(slots=True)
class ProbeOutcome:
"""One candidate's measured result.
Attributes:
candidate: The route that was measured.
stats: The aggregated trace statistics over its traces.
"""
candidate: ProbeCandidate
stats: TraceStats
@property
def sort_key(self) -> tuple:
"""A ranking key that sorts the best path first.
Reliability, then bottleneck SNR, then RTT — the same priority order the TX
optimizer applies.
"""
snr = self.stats.median_min_snr
rtt = self.stats.median_rtt_ms
return (
-self.stats.success_rate,
-(snr if snr is not None else float("-inf")),
rtt if rtt is not None else float("inf"),
)
async def probe_paths(
device: Device,
target: str,
candidates: list[ProbeCandidate],
*,
samples: int = 1,
cooldown_s: float = 1.0,
on_result: ProbeProgress | None = None,
persist_trace: Callable[[TraceResult], Awaitable[None] | None] | None = None,
persist_candidate: PersistCandidate | None = None,
) -> list[ProbeOutcome]:
"""Trace every candidate path and return the outcomes ranked best-first.
Runs the candidates sequentially (one radio, duty cycle applies), a single trace
each by default. Persistence is delegated through callbacks so this stays pure
measurement: the caller owns the run row and decides where traces and candidate
aggregates go. Cancelling the surrounding task stops mid-candidate; everything
persisted so far stays persisted.
Args:
device: The connected device to trace through.
target: The destination (for :class:`TraceStats` labelling).
candidates: The routes to measure, in the order to try them.
samples: Traces per candidate. The default of one is deliberate — repeaters
can blacklist nodes that burst traffic; raise it only when the airtime
budget clearly allows.
cooldown_s: Pause between traces (and between candidates).
on_result: Optional callback ``(candidate_index, done_in_candidate, result)``
invoked as each trace lands, e.g. to advance the probe dialog.
persist_trace: Optional per-trace persistence callback (sync or async).
persist_candidate: Optional callback invoked with each candidate's finished
:class:`ProbeOutcome` (e.g. to record a ``path_candidates`` row).
Returns:
One :class:`ProbeOutcome` per candidate, ranked by :attr:`ProbeOutcome.sort_key`.
"""
from . import trace_runner
outcomes: list[ProbeOutcome] = []
for index, candidate in enumerate(candidates):
if index and cooldown_s > 0:
# ``run_traces`` paces *between* its own samples and never after the last
# one, so at the default of one trace per candidate it paces nothing at
# all: without this the sweep walks every candidate back to back and the
# repeaters see a burst — the traffic pattern that gets a node ignored,
# after which every later trace comes home empty.
await asyncio.sleep(cooldown_s)
results = await trace_runner.run_traces(
device,
target,
samples=samples,
path=candidate.spec,
cooldown_s=cooldown_s,
on_result=(
(lambda done, _total, result, i=index: on_result(i, done, result))
if on_result is not None
else None
),
persist=persist_trace,
)
outcome = ProbeOutcome(candidate=candidate, stats=TraceStats.from_traces(target, results))
outcomes.append(outcome)
if persist_candidate is not None:
persist_candidate(outcome)
return sorted(outcomes, key=lambda o: o.sort_key)