Merge pull request #321 from ipnet-mesh/feat/routes-max-path-length

feat(routes): add per-route max_path_length cap
This commit is contained in:
JingleManSweep
2026-07-20 12:51:15 +01:00
committed by GitHub
16 changed files with 181 additions and 22 deletions
@@ -0,0 +1,40 @@
"""add routes.max_path_length
Revision ID: d307ee761a34
Revises: 5e3b712ccf10
Create Date: 2026-07-20 12:00:00.000000+00:00
Adds a single nullable ``max_path_length`` column to ``routes``. The
new knob caps the total number of hops in a candidate packet's full
path; receptions whose path exceeds it are dropped from matching
consideration entirely (before the subsequence matcher runs), so
over-long paths never count toward ``packet_count_threshold``. Complements
the existing ``max_hop_span`` (which only constrains the gap between
the first and last *matched* configured node).
``null`` (the default) means unlimited and preserves the previous
behaviour for every existing route, so this migration is additive and
backwards-compatible with no data backfill.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d307ee761a34"
down_revision: Union[str, None] = "5e3b712ccf10"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"routes",
sa.Column("max_path_length", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("routes", "max_path_length")
+1
View File
@@ -21,6 +21,7 @@ Each route carries these knobs:
| `packet_count_threshold` | `5` | Distinct matching packets at/above which the route is `healthy`. "Distinct" is per underlying event, not per transmission — see [How health is evaluated](#how-health-is-evaluated) above. |
| `clear_threshold` | _(3× threshold)_ | Comfort bar for the `clear`/`marginal` split. Omit/null to use three times the threshold. |
| `max_hop_span` | `8` | Caps the position gap between the first and last matched node, to reject matches that wander too far. |
| `max_path_length` | _(unlimited)_ | Caps the total number of hops in a candidate packet's full path; receptions whose path exceeds this are dropped from matching entirely (never counted toward `packet_count_threshold`). Useful to ignore wandering packets that happen to include the configured endpoints but traversed a long detour. |
| `reversible` | `true` | Also match the path in reverse direction. |
| `enabled` | `true` | When `false`, the route is skipped by the evaluator and reports `unknown`/`no_coverage`. |
+1
View File
@@ -130,6 +130,7 @@ routes:
packet_count_threshold: 5
# clear_threshold: 15 # optional; omit/null = 3x threshold
# max_hop_span: 8 # optional; omit/null = unlimited
# max_path_length: 16 # optional; omit/null = unlimited (drops over-long packet paths)
enabled: true
reversible: true # match both directions (A->B and B->A)
path:
+1
View File
@@ -21,6 +21,7 @@ routes:
packet_count_threshold: 3
# clear_threshold: 10 # optional; omit/null = 2x threshold
# max_hop_span: 8 # optional; omit/null = unlimited
# max_path_length: 16 # optional; omit/null = unlimited (drops over-long packet paths)
enabled: true
reversible: true # match both directions (A->B and B->A)
path:
+5
View File
@@ -116,6 +116,7 @@ def _route_to_read(route: Route, *, quality_avg: Any = _UNSET) -> RouteRead:
packet_count_threshold=route.packet_count_threshold,
clear_threshold=route.clear_threshold,
max_hop_span=route.max_hop_span,
max_path_length=route.max_path_length,
enabled=route.enabled,
reversible=route.reversible,
route_nodes=[_route_node_to_read(rn) for rn in route.route_nodes],
@@ -269,6 +270,7 @@ def create_route(
packet_count_threshold=body.packet_count_threshold,
clear_threshold=body.clear_threshold,
max_hop_span=body.max_hop_span,
max_path_length=body.max_path_length,
enabled=body.enabled,
reversible=body.reversible,
)
@@ -573,6 +575,8 @@ def update_route(
route.clear_threshold = body.clear_threshold
if body.max_hop_span is not None:
route.max_hop_span = body.max_hop_span
if body.max_path_length is not None:
route.max_path_length = body.max_path_length
if body.enabled is not None:
route.enabled = body.enabled
if body.reversible is not None:
@@ -646,6 +650,7 @@ def preview(
"match_width": body.match_width,
"observer_ids": [n.id for n in observer_nodes] if observer_nodes else None,
"max_hop_span": body.max_hop_span,
"max_path_length": body.max_path_length,
"packet_count_threshold": body.packet_count_threshold,
"clear_threshold": body.clear_threshold,
"reversible": body.reversible,
+2
View File
@@ -810,6 +810,7 @@ def _import_routes(
)
route.clear_threshold = value.get("clear_threshold")
route.max_hop_span = value.get("max_hop_span", 8)
route.max_path_length = value.get("max_path_length")
route.enabled = value.get("enabled", True)
route.reversible = value.get("reversible", True)
# Replace path nodes wholesale
@@ -830,6 +831,7 @@ def _import_routes(
packet_count_threshold=value.get("packet_count_threshold", 5),
clear_threshold=value.get("clear_threshold"),
max_hop_span=value.get("max_hop_span", 8),
max_path_length=value.get("max_path_length"),
enabled=value.get("enabled", True),
reversible=value.get("reversible", True),
)
+41 -12
View File
@@ -97,6 +97,7 @@ def _subsequence_indices(
path: list[dict[str, Any]],
expected: list[str],
max_hop_span: Optional[int] = None,
max_path_length: Optional[int] = None,
) -> Optional[tuple[int, int]]:
"""Two-pointer subsequence prefix match with gaps allowed.
@@ -104,10 +105,15 @@ def _subsequence_indices(
*expected* is the ordered list of uppercase hash prefixes to find.
A hop matches when ``node_hash.startswith(expected_hash)``.
``max_hop_span`` constrains ``position(last) - position(first)`` when set.
``max_path_length`` caps the total number of hops in *path*; when set
and ``len(path)`` exceeds it, the packet is rejected up front (returns
``None``) without running the match.
Returns the ``(first_i, last_i)`` indices into *path* of the matched
endpoints, or ``None`` when no match is found.
"""
if max_path_length is not None and len(path) > max_path_length:
return None
if not expected:
return None
pi = 0
@@ -144,6 +150,7 @@ def is_subsequence(
path: list[dict[str, Any]],
expected: list[str],
max_hop_span: Optional[int] = None,
max_path_length: Optional[int] = None,
) -> bool:
"""Pure two-pointer subsequence prefix match with gaps allowed.
@@ -151,14 +158,18 @@ def is_subsequence(
*expected* is the ordered list of uppercase hash prefixes to find.
A hop matches when ``node_hash.startswith(expected_hash)``.
``max_hop_span`` constrains ``position(last) - position(first)`` when set.
``max_path_length`` caps the total number of hops in *path* when set.
"""
return _subsequence_indices(path, expected, max_hop_span) is not None
return (
_subsequence_indices(path, expected, max_hop_span, max_path_length) is not None
)
def _matched_subpath(
hops: list[dict[str, Any]],
expected: list[str],
max_hop_span: Optional[int] = None,
max_path_length: Optional[int] = None,
reversible: bool = True,
) -> Optional[list[dict[str, Any]]]:
"""Return the slice of *hops* between the first and last matched node.
@@ -169,7 +180,7 @@ def _matched_subpath(
(never reversed), so a reverse-direction packet shows as To -> ... -> From.
"""
subpath, _first, _last = _matched_subpath_with_indices(
hops, expected, max_hop_span, reversible
hops, expected, max_hop_span, max_path_length, reversible
)
return subpath
@@ -178,6 +189,7 @@ def _matched_subpath_with_indices(
hops: list[dict[str, Any]],
expected: list[str],
max_hop_span: Optional[int] = None,
max_path_length: Optional[int] = None,
reversible: bool = True,
) -> tuple[Optional[list[dict[str, Any]]], Optional[int], Optional[int]]:
"""Variant of :func:`_matched_subpath` that also returns match indices.
@@ -189,11 +201,13 @@ def _matched_subpath_with_indices(
``route_recent_matches`` so the detail page can slice the live hop
list without re-running the matcher.
"""
idx = _subsequence_indices(hops, expected, max_hop_span)
idx = _subsequence_indices(hops, expected, max_hop_span, max_path_length)
if idx is not None:
return hops[idx[0] : idx[1] + 1], idx[0], idx[1]
if reversible and len(expected) > 1:
idx = _subsequence_indices(hops, list(reversed(expected)), max_hop_span)
idx = _subsequence_indices(
hops, list(reversed(expected)), max_hop_span, max_path_length
)
if idx is not None:
return hops[idx[0] : idx[1] + 1], idx[0], idx[1]
return None, None, None
@@ -203,10 +217,14 @@ def _match_hops(
hops: list[dict[str, Any]],
expected: list[str],
max_hop_span: Optional[int] = None,
max_path_length: Optional[int] = None,
reversible: bool = True,
) -> bool:
"""Check whether *hops* match *expected* forward (and optionally reverse)."""
return _matched_subpath(hops, expected, max_hop_span, reversible) is not None
return (
_matched_subpath(hops, expected, max_hop_span, max_path_length, reversible)
is not None
)
def _fetch_candidate_paths_maybe_bidirectional(
@@ -518,7 +536,9 @@ def evaluate_route(
matched_packets: set[str] = set()
for hops in paths.values():
if _match_hops(hops, expected, route.max_hop_span, reversible):
if _match_hops(
hops, expected, route.max_hop_span, route.max_path_length, reversible
):
identity = _match_identity(hops)
if identity:
matched_packets.add(identity)
@@ -571,7 +591,9 @@ def evaluate_route_day(
matched_packets: set[str] = set()
for hops in paths.values():
if _match_hops(hops, expected, route.max_hop_span, reversible):
if _match_hops(
hops, expected, route.max_hop_span, route.max_path_length, reversible
):
identity = _match_identity(hops)
if identity:
matched_packets.add(identity)
@@ -732,7 +754,9 @@ def evaluate_route_history(
for i in range(historical_days):
matched_packets: set[str] = set()
for hops in day_paths[i].values():
if _match_hops(hops, expected, route.max_hop_span, reversible):
if _match_hops(
hops, expected, route.max_hop_span, route.max_path_length, reversible
):
identity = _match_identity(hops)
if identity:
matched_packets.add(identity)
@@ -1129,7 +1153,11 @@ def recent_matches(
matches_by_identity: dict[str, dict[str, Any]] = {}
for rp_id, hops in paths.items():
subpath, first_idx, last_idx = _matched_subpath_with_indices(
hops, expected, route.max_hop_span, reversible
hops,
expected,
route.max_hop_span,
route.max_path_length,
reversible,
)
if not subpath or first_idx is None or last_idx is None:
continue
@@ -1232,13 +1260,14 @@ def preview_route(
"""Preview matching for an unsaved route config.
*config* keys: ``node_ids``, ``match_width``, ``observer_ids``,
``max_hop_span``, ``packet_count_threshold``, ``clear_threshold``,
``reversible``.
``max_hop_span``, ``max_path_length``, ``packet_count_threshold``,
``clear_threshold``, ``reversible``.
"""
node_ids: list[str] = config.get("node_ids") or []
match_width: int = config.get("match_width") or 1
observer_ids: Optional[list[str]] = config.get("observer_ids") or None
max_hop_span: Optional[int] = config.get("max_hop_span")
max_path_length: Optional[int] = config.get("max_path_length")
threshold: int = config.get("packet_count_threshold") or 5
clear_bar: Optional[int] = config.get("clear_threshold")
reversible: bool = config.get("reversible", True)
@@ -1298,7 +1327,7 @@ def preview_route(
contributing: dict[str, int] = {}
for hops in paths.values():
if _match_hops(hops, expected, max_hop_span, reversible):
if _match_hops(hops, expected, max_hop_span, max_path_length, reversible):
identity = _match_identity(hops)
if identity:
matched_packets.add(identity)
+6
View File
@@ -43,6 +43,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
packet_count_threshold: Minimum distinct packets for healthy
clear_threshold: Comfort bar for the clear/marginal split (null = 3x threshold)
max_hop_span: Max hops between first and last configured node (null = unlimited)
max_path_length: Max number of hops in a candidate packet's full path (null = unlimited)
enabled: Whether this route is actively evaluated
"""
@@ -86,6 +87,11 @@ class Route(Base, UUIDMixin, TimestampMixin):
default=8,
nullable=True,
)
max_path_length: Mapped[Optional[int]] = mapped_column(
Integer,
default=None,
nullable=True,
)
enabled: Mapped[bool] = mapped_column(
Boolean,
default=True,
@@ -75,6 +75,11 @@ class RouteCreate(BaseModel):
max_hop_span: Optional[int] = Field(
default=8, description="Max hop distance between first and last node"
)
max_path_length: Optional[int] = Field(
default=None,
ge=1,
description="Max hops in a candidate packet's full path; null = unlimited",
)
enabled: bool = Field(default=True, description="Whether this route is evaluated")
reversible: bool = Field(
default=True, description="Whether to match the path in both directions"
@@ -113,6 +118,7 @@ class RouteUpdate(BaseModel):
packet_count_threshold: Optional[int] = Field(default=None, ge=1, le=10000)
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
max_path_length: Optional[int] = Field(default=None, ge=1)
enabled: Optional[bool] = None
reversible: Optional[bool] = None
node_public_keys: Optional[list[str]] = None
@@ -149,6 +155,7 @@ class RouteRead(BaseModel):
packet_count_threshold: int
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
max_path_length: Optional[int] = None
enabled: bool
reversible: bool
route_nodes: list[RouteNodeRead] = []
@@ -205,6 +212,7 @@ class RouteDetail(BaseModel):
packet_count_threshold: int
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
max_path_length: Optional[int] = None
enabled: bool
reversible: bool
route_nodes: list[RouteNodeRead] = []
@@ -236,6 +244,7 @@ class RoutePreviewRequest(BaseModel):
packet_count_threshold: int = Field(default=5, ge=1, le=10000)
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = Field(default=8)
max_path_length: Optional[int] = Field(default=None, ge=1)
observer_public_keys: Optional[list[str]] = None
reversible: bool = Field(default=True)
@@ -189,3 +189,11 @@ export function iconRouteFrom(cls = 'h-5 w-5') {
export function iconRouteTo(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12h13.5m0 0l-4-4m4 4l-4 4" /><circle cx="19" cy="12" r="2.5" stroke-width="2" /></svg>`;
}
export function iconHopSpan(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12h6M14 12h6M4 12l3-3M4 12l3 3M20 12l-3-3M20 12l-3 3" /><circle cx="12" cy="12" r="2" stroke-width="2" /></svg>`;
}
export function iconPathLength(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5v14M19 5v14M9 12h6M9 12l3-3M9 12l3 3M15 12l-3-3M15 12l-3 3" /></svg>`;
}
@@ -1,6 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish, iconRouteFrom, iconRouteTo } from '../icons.js';
import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish, iconRouteFrom, iconRouteTo, iconHopSpan, iconPathLength } from '../icons.js';
const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin'];
@@ -91,27 +91,31 @@ function renderStatsRow(route) {
const obsCount = (route.route_observers || []).length;
return html`<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs opacity-60 mt-1">
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_matched_tip')}>
${iconPackets('h-3.5 w-3.5')}
<span>${matched}/${threshold}\u2192${degraded}</span>
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_window_tip')}>
${iconClock('h-3.5 w-3.5')}
<span>${route.window_hours}h</span>
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_width_tip')}>
${iconRuler('h-3.5 w-3.5')}
<span>${route.match_width}B</span>
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_nodes_tip')}>
${iconNodes('h-3.5 w-3.5')}
<span>${nodeCount}</span>
</span>
${route.max_hop_span ? html`<span class="inline-flex items-center gap-1">
${iconPath('h-3.5 w-3.5')}
<span>${route.max_hop_span}</span>
</span>` : nothing}
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_span_tip')}>
${iconHopSpan('h-3.5 w-3.5')}
<span>${route.max_hop_span || '\u221E'}</span>
</span>
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_length_tip')}>
${iconPathLength('h-3.5 w-3.5')}
<span>${route.max_path_length || '\u221E'}</span>
</span>
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_observers_tip')}>
${iconSatelliteDish('h-3.5 w-3.5')}
<span>${obsCount || '\u221E'}</span>
</span>
@@ -419,6 +423,12 @@ function renderRouteModal({ modalState, onSave, onCancel, saving }) {
.value=${route.max_hop_span || ''}
placeholder="\u221E" min="1" />
</div>
<div>
<label class="text-sm opacity-70">${t('routes.path_length_label')}</label>
<input type="number" id="route-modal-path-length" class="input input-sm w-full"
.value=${route.max_path_length || ''}
placeholder="\u221E" min="1" />
</div>
</div>
<div class="flex gap-6">
<label class="label cursor-pointer justify-start gap-3">
@@ -785,6 +795,7 @@ export async function render(container, params, router) {
const thresholdEl = document.getElementById('route-modal-threshold');
const clearEl = document.getElementById('route-modal-clear');
const spanEl = document.getElementById('route-modal-span');
const pathLengthEl = document.getElementById('route-modal-path-length');
const enabledEl = document.getElementById('route-modal-enabled');
const reversibleEl = document.getElementById('route-modal-reversible');
@@ -806,6 +817,7 @@ export async function render(container, params, router) {
window_hours: parseInt(windowEl.value, 10) || 48,
packet_count_threshold: parseInt(thresholdEl.value, 10) || 5,
max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null,
max_path_length: pathLengthEl.value ? parseInt(pathLengthEl.value, 10) : null,
enabled: enabledEl.checked,
reversible: reversibleEl.checked,
node_public_keys: nodePublicKeys,
@@ -339,6 +339,14 @@
"threshold_label": "Threshold",
"clear_label": "Clear Threshold",
"span_label": "Max Span",
"path_length_label": "Max Path Length",
"stats_matched_tip": "Matched / threshold \u2192 clear bar",
"stats_window_tip": "Lookback window (hours)",
"stats_width_tip": "Hash prefix width (bytes)",
"stats_nodes_tip": "Configured path nodes",
"stats_span_tip": "Max gap between matched endpoints (\u221E = unlimited)",
"stats_length_tip": "Max hops in packet path (\u221E = unlimited)",
"stats_observers_tip": "Observer allow-list size (\u221E = all)",
"enabled_label": "Enabled",
"reversible_label": "Reversible (match both directions)",
"disabled": "Disabled",
@@ -261,6 +261,14 @@
"threshold_label": "Drempel",
"clear_label": "Heldere drempel",
"span_label": "Max span",
"path_length_label": "Max padlengte",
"stats_matched_tip": "Matched / drempel \u2192 helder",
"stats_window_tip": "Lookback venster (uren)",
"stats_width_tip": "Hash-prefixbreedte (bytes)",
"stats_nodes_tip": "Geconfigureerde pad-knooppunten",
"stats_span_tip": "Max afstand tussen matched eindpunten (\u221E = onbeperkt)",
"stats_length_tip": "Max hops in pakketpad (\u221E = onbeperkt)",
"stats_observers_tip": "Observer-allow-list (\u221E = alle)",
"enabled_label": "Ingeschakeld",
"reversible_label": "Omkeerbaar (beide richtingen)",
"disabled": "Uitgeschakeld",
+2
View File
@@ -950,6 +950,7 @@ class TestUpdateRouteFields:
"packet_count_threshold": 5,
"clear_threshold": 8,
"max_hop_span": 4,
"max_path_length": 6,
"enabled": False,
"reversible": False,
},
@@ -964,6 +965,7 @@ class TestUpdateRouteFields:
assert data["packet_count_threshold"] == 5
assert data["clear_threshold"] == 8
assert data["max_hop_span"] == 4
assert data["max_path_length"] == 6
assert data["enabled"] is False
assert data["reversible"] is False
+2
View File
@@ -499,6 +499,7 @@ class TestImportRoutes:
" packet_count_threshold: 5\n"
" clear_threshold: 8\n"
" max_hop_span: 3\n"
" max_path_length: 12\n"
" enabled: true\n"
" reversible: false\n"
f" observers:\n - '{_PK_C}'\n"
@@ -519,6 +520,7 @@ class TestImportRoutes:
assert route.packet_count_threshold == 5
assert route.clear_threshold == 8
assert route.max_hop_span == 3
assert route.max_path_length == 12
assert route.enabled is True
assert route.reversible is False
assert route.description == "a route"
+25
View File
@@ -172,6 +172,31 @@ class TestIsSubsequence:
]
assert is_subsequence(path, ["A1", "B2"], max_hop_span=2) is False
def test_path_length_cap_within(self):
path = [
{"position": 0, "node_hash": "A1"},
{"position": 1, "node_hash": "X"},
{"position": 2, "node_hash": "B2"},
]
assert is_subsequence(path, ["A1", "B2"], max_path_length=3) is True
def test_path_length_cap_exceeds(self):
path = [
{"position": 0, "node_hash": "A1"},
{"position": 1, "node_hash": "X"},
{"position": 2, "node_hash": "X"},
{"position": 3, "node_hash": "X"},
{"position": 4, "node_hash": "B2"},
]
assert is_subsequence(path, ["A1", "B2"], max_path_length=3) is False
def test_path_length_cap_zero_ignored(self):
"""max_path_length=None means unlimited (default)."""
path = [{"position": i, "node_hash": "X"} for i in range(20)]
path[0] = {"position": 0, "node_hash": "A1"}
path[-1] = {"position": 19, "node_hash": "B2"}
assert is_subsequence(path, ["A1", "B2"]) is True
def test_empty_expected(self):
assert is_subsequence([{"position": 0, "node_hash": "A1"}], []) is False