Files
MeshTerm/meshterm/services/path_probe.py
T
Jean-Pierre MartineauandClaude Fable 5 ce133a321e One trace per action: retire bursts so repeaters never see us flood
Repeaters penalize (and can blacklist) nodes that burst traffic, so
every trace-transmitting surface now sends exactly one transmission
per human action: Enter on the live screen runs a single trace (the
screen still aggregates the session's medians), the scenario probe
measures each candidate path once, and the CLI drops --samples to
become a true one-shot. trace_runner.measure and the s-key sample
cycle go with it.

Also de-pollute the target picker's "Recently traced" section: the
repository now returns a short recency-ordered list instead of the
ever-growing all-time tally, and stored hex prefixes fold back to
their contact names so a node shows once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:51:04 -04:00

125 lines
4.8 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
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional
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:
"""Ranking key, best-first under ascending sort: 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: Optional[ProbeProgress] = None,
persist_trace: Optional[Callable[[TraceResult], Awaitable[None] | None]] = None,
persist_candidate: Optional[PersistCandidate] = 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):
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)