Skip to content

Commit 1ba4814

Browse files
feat(routes): add per-route max_path_length cap
Adds a new nullable per-route knob that caps the total number of hops in a candidate packet's path. Packets whose path exceeds the cap 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. - Route model + migration (additive, nullable, default null = unlimited) - Threaded through matcher chain (_subsequence_indices early-return) - All 5 evaluate/preview/recent_matches call sites updated - API serializer/create/update/preview passthrough - CLI seed YAML import (update + create paths) - Frontend: distinct icons for span (<-o->) vs path-length (|<->|), always-rendered badges with infinity fallback, hover tooltips on every stats row item, i18n keys (en + nl) - Tests: matcher unit tests (within/exceeds cap), API round-trip, CLI seed import
1 parent f2137d0 commit 1ba4814

16 files changed

Lines changed: 181 additions & 22 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""add routes.max_path_length
2+
3+
Revision ID: d307ee761a34
4+
Revises: 5e3b712ccf10
5+
Create Date: 2026-07-20 12:00:00.000000+00:00
6+
7+
Adds a single nullable ``max_path_length`` column to ``routes``. The
8+
new knob caps the total number of hops in a candidate packet's full
9+
path; receptions whose path exceeds it are dropped from matching
10+
consideration entirely (before the subsequence matcher runs), so
11+
over-long paths never count toward ``packet_count_threshold``. Complements
12+
the existing ``max_hop_span`` (which only constrains the gap between
13+
the first and last *matched* configured node).
14+
15+
``null`` (the default) means unlimited and preserves the previous
16+
behaviour for every existing route, so this migration is additive and
17+
backwards-compatible with no data backfill.
18+
"""
19+
20+
from typing import Sequence, Union
21+
22+
import sqlalchemy as sa
23+
from alembic import op
24+
25+
# revision identifiers, used by Alembic.
26+
revision: str = "d307ee761a34"
27+
down_revision: Union[str, None] = "5e3b712ccf10"
28+
branch_labels: Union[str, Sequence[str], None] = None
29+
depends_on: Union[str, Sequence[str], None] = None
30+
31+
32+
def upgrade() -> None:
33+
op.add_column(
34+
"routes",
35+
sa.Column("max_path_length", sa.Integer(), nullable=True),
36+
)
37+
38+
39+
def downgrade() -> None:
40+
op.drop_column("routes", "max_path_length")

docs/routes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Each route carries these knobs:
2121
| `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. |
2222
| `clear_threshold` | _(3× threshold)_ | Comfort bar for the `clear`/`marginal` split. Omit/null to use three times the threshold. |
2323
| `max_hop_span` | `8` | Caps the position gap between the first and last matched node, to reject matches that wander too far. |
24+
| `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. |
2425
| `reversible` | `true` | Also match the path in reverse direction. |
2526
| `enabled` | `true` | When `false`, the route is skipped by the evaluator and reports `unknown`/`no_coverage`. |
2627

docs/seeding.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ routes:
130130
packet_count_threshold: 5
131131
# clear_threshold: 15 # optional; omit/null = 3x threshold
132132
# max_hop_span: 8 # optional; omit/null = unlimited
133+
# max_path_length: 16 # optional; omit/null = unlimited (drops over-long packet paths)
133134
enabled: true
134135
reversible: true # match both directions (A->B and B->A)
135136
path:

example/seed/routes.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ routes:
2121
packet_count_threshold: 3
2222
# clear_threshold: 10 # optional; omit/null = 2x threshold
2323
# max_hop_span: 8 # optional; omit/null = unlimited
24+
# max_path_length: 16 # optional; omit/null = unlimited (drops over-long packet paths)
2425
enabled: true
2526
reversible: true # match both directions (A->B and B->A)
2627
path:

src/meshcore_hub/api/routes/routes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def _route_to_read(route: Route, *, quality_avg: Any = _UNSET) -> RouteRead:
116116
packet_count_threshold=route.packet_count_threshold,
117117
clear_threshold=route.clear_threshold,
118118
max_hop_span=route.max_hop_span,
119+
max_path_length=route.max_path_length,
119120
enabled=route.enabled,
120121
reversible=route.reversible,
121122
route_nodes=[_route_node_to_read(rn) for rn in route.route_nodes],
@@ -269,6 +270,7 @@ def create_route(
269270
packet_count_threshold=body.packet_count_threshold,
270271
clear_threshold=body.clear_threshold,
271272
max_hop_span=body.max_hop_span,
273+
max_path_length=body.max_path_length,
272274
enabled=body.enabled,
273275
reversible=body.reversible,
274276
)
@@ -573,6 +575,8 @@ def update_route(
573575
route.clear_threshold = body.clear_threshold
574576
if body.max_hop_span is not None:
575577
route.max_hop_span = body.max_hop_span
578+
if body.max_path_length is not None:
579+
route.max_path_length = body.max_path_length
576580
if body.enabled is not None:
577581
route.enabled = body.enabled
578582
if body.reversible is not None:
@@ -646,6 +650,7 @@ def preview(
646650
"match_width": body.match_width,
647651
"observer_ids": [n.id for n in observer_nodes] if observer_nodes else None,
648652
"max_hop_span": body.max_hop_span,
653+
"max_path_length": body.max_path_length,
649654
"packet_count_threshold": body.packet_count_threshold,
650655
"clear_threshold": body.clear_threshold,
651656
"reversible": body.reversible,

src/meshcore_hub/collector/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,7 @@ def _import_routes(
810810
)
811811
route.clear_threshold = value.get("clear_threshold")
812812
route.max_hop_span = value.get("max_hop_span", 8)
813+
route.max_path_length = value.get("max_path_length")
813814
route.enabled = value.get("enabled", True)
814815
route.reversible = value.get("reversible", True)
815816
# Replace path nodes wholesale
@@ -830,6 +831,7 @@ def _import_routes(
830831
packet_count_threshold=value.get("packet_count_threshold", 5),
831832
clear_threshold=value.get("clear_threshold"),
832833
max_hop_span=value.get("max_hop_span", 8),
834+
max_path_length=value.get("max_path_length"),
833835
enabled=value.get("enabled", True),
834836
reversible=value.get("reversible", True),
835837
)

src/meshcore_hub/collector/routes.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,23 @@ def _subsequence_indices(
9797
path: list[dict[str, Any]],
9898
expected: list[str],
9999
max_hop_span: Optional[int] = None,
100+
max_path_length: Optional[int] = None,
100101
) -> Optional[tuple[int, int]]:
101102
"""Two-pointer subsequence prefix match with gaps allowed.
102103
103104
Each entry in *path* is a dict with ``position`` and ``node_hash``.
104105
*expected* is the ordered list of uppercase hash prefixes to find.
105106
A hop matches when ``node_hash.startswith(expected_hash)``.
106107
``max_hop_span`` constrains ``position(last) - position(first)`` when set.
108+
``max_path_length`` caps the total number of hops in *path*; when set
109+
and ``len(path)`` exceeds it, the packet is rejected up front (returns
110+
``None``) without running the match.
107111
108112
Returns the ``(first_i, last_i)`` indices into *path* of the matched
109113
endpoints, or ``None`` when no match is found.
110114
"""
115+
if max_path_length is not None and len(path) > max_path_length:
116+
return None
111117
if not expected:
112118
return None
113119
pi = 0
@@ -144,21 +150,26 @@ def is_subsequence(
144150
path: list[dict[str, Any]],
145151
expected: list[str],
146152
max_hop_span: Optional[int] = None,
153+
max_path_length: Optional[int] = None,
147154
) -> bool:
148155
"""Pure two-pointer subsequence prefix match with gaps allowed.
149156
150157
Each entry in *path* is a dict with ``position`` and ``node_hash``.
151158
*expected* is the ordered list of uppercase hash prefixes to find.
152159
A hop matches when ``node_hash.startswith(expected_hash)``.
153160
``max_hop_span`` constrains ``position(last) - position(first)`` when set.
161+
``max_path_length`` caps the total number of hops in *path* when set.
154162
"""
155-
return _subsequence_indices(path, expected, max_hop_span) is not None
163+
return (
164+
_subsequence_indices(path, expected, max_hop_span, max_path_length) is not None
165+
)
156166

157167

158168
def _matched_subpath(
159169
hops: list[dict[str, Any]],
160170
expected: list[str],
161171
max_hop_span: Optional[int] = None,
172+
max_path_length: Optional[int] = None,
162173
reversible: bool = True,
163174
) -> Optional[list[dict[str, Any]]]:
164175
"""Return the slice of *hops* between the first and last matched node.
@@ -169,7 +180,7 @@ def _matched_subpath(
169180
(never reversed), so a reverse-direction packet shows as To -> ... -> From.
170181
"""
171182
subpath, _first, _last = _matched_subpath_with_indices(
172-
hops, expected, max_hop_span, reversible
183+
hops, expected, max_hop_span, max_path_length, reversible
173184
)
174185
return subpath
175186

@@ -178,6 +189,7 @@ def _matched_subpath_with_indices(
178189
hops: list[dict[str, Any]],
179190
expected: list[str],
180191
max_hop_span: Optional[int] = None,
192+
max_path_length: Optional[int] = None,
181193
reversible: bool = True,
182194
) -> tuple[Optional[list[dict[str, Any]]], Optional[int], Optional[int]]:
183195
"""Variant of :func:`_matched_subpath` that also returns match indices.
@@ -189,11 +201,13 @@ def _matched_subpath_with_indices(
189201
``route_recent_matches`` so the detail page can slice the live hop
190202
list without re-running the matcher.
191203
"""
192-
idx = _subsequence_indices(hops, expected, max_hop_span)
204+
idx = _subsequence_indices(hops, expected, max_hop_span, max_path_length)
193205
if idx is not None:
194206
return hops[idx[0] : idx[1] + 1], idx[0], idx[1]
195207
if reversible and len(expected) > 1:
196-
idx = _subsequence_indices(hops, list(reversed(expected)), max_hop_span)
208+
idx = _subsequence_indices(
209+
hops, list(reversed(expected)), max_hop_span, max_path_length
210+
)
197211
if idx is not None:
198212
return hops[idx[0] : idx[1] + 1], idx[0], idx[1]
199213
return None, None, None
@@ -203,10 +217,14 @@ def _match_hops(
203217
hops: list[dict[str, Any]],
204218
expected: list[str],
205219
max_hop_span: Optional[int] = None,
220+
max_path_length: Optional[int] = None,
206221
reversible: bool = True,
207222
) -> bool:
208223
"""Check whether *hops* match *expected* forward (and optionally reverse)."""
209-
return _matched_subpath(hops, expected, max_hop_span, reversible) is not None
224+
return (
225+
_matched_subpath(hops, expected, max_hop_span, max_path_length, reversible)
226+
is not None
227+
)
210228

211229

212230
def _fetch_candidate_paths_maybe_bidirectional(
@@ -518,7 +536,9 @@ def evaluate_route(
518536

519537
matched_packets: set[str] = set()
520538
for hops in paths.values():
521-
if _match_hops(hops, expected, route.max_hop_span, reversible):
539+
if _match_hops(
540+
hops, expected, route.max_hop_span, route.max_path_length, reversible
541+
):
522542
identity = _match_identity(hops)
523543
if identity:
524544
matched_packets.add(identity)
@@ -571,7 +591,9 @@ def evaluate_route_day(
571591

572592
matched_packets: set[str] = set()
573593
for hops in paths.values():
574-
if _match_hops(hops, expected, route.max_hop_span, reversible):
594+
if _match_hops(
595+
hops, expected, route.max_hop_span, route.max_path_length, reversible
596+
):
575597
identity = _match_identity(hops)
576598
if identity:
577599
matched_packets.add(identity)
@@ -732,7 +754,9 @@ def evaluate_route_history(
732754
for i in range(historical_days):
733755
matched_packets: set[str] = set()
734756
for hops in day_paths[i].values():
735-
if _match_hops(hops, expected, route.max_hop_span, reversible):
757+
if _match_hops(
758+
hops, expected, route.max_hop_span, route.max_path_length, reversible
759+
):
736760
identity = _match_identity(hops)
737761
if identity:
738762
matched_packets.add(identity)
@@ -1129,7 +1153,11 @@ def recent_matches(
11291153
matches_by_identity: dict[str, dict[str, Any]] = {}
11301154
for rp_id, hops in paths.items():
11311155
subpath, first_idx, last_idx = _matched_subpath_with_indices(
1132-
hops, expected, route.max_hop_span, reversible
1156+
hops,
1157+
expected,
1158+
route.max_hop_span,
1159+
route.max_path_length,
1160+
reversible,
11331161
)
11341162
if not subpath or first_idx is None or last_idx is None:
11351163
continue
@@ -1232,13 +1260,14 @@ def preview_route(
12321260
"""Preview matching for an unsaved route config.
12331261
12341262
*config* keys: ``node_ids``, ``match_width``, ``observer_ids``,
1235-
``max_hop_span``, ``packet_count_threshold``, ``clear_threshold``,
1236-
``reversible``.
1263+
``max_hop_span``, ``max_path_length``, ``packet_count_threshold``,
1264+
``clear_threshold``, ``reversible``.
12371265
"""
12381266
node_ids: list[str] = config.get("node_ids") or []
12391267
match_width: int = config.get("match_width") or 1
12401268
observer_ids: Optional[list[str]] = config.get("observer_ids") or None
12411269
max_hop_span: Optional[int] = config.get("max_hop_span")
1270+
max_path_length: Optional[int] = config.get("max_path_length")
12421271
threshold: int = config.get("packet_count_threshold") or 5
12431272
clear_bar: Optional[int] = config.get("clear_threshold")
12441273
reversible: bool = config.get("reversible", True)
@@ -1298,7 +1327,7 @@ def preview_route(
12981327
contributing: dict[str, int] = {}
12991328

13001329
for hops in paths.values():
1301-
if _match_hops(hops, expected, max_hop_span, reversible):
1330+
if _match_hops(hops, expected, max_hop_span, max_path_length, reversible):
13021331
identity = _match_identity(hops)
13031332
if identity:
13041333
matched_packets.add(identity)

src/meshcore_hub/common/models/route.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
4343
packet_count_threshold: Minimum distinct packets for healthy
4444
clear_threshold: Comfort bar for the clear/marginal split (null = 3x threshold)
4545
max_hop_span: Max hops between first and last configured node (null = unlimited)
46+
max_path_length: Max number of hops in a candidate packet's full path (null = unlimited)
4647
enabled: Whether this route is actively evaluated
4748
"""
4849

@@ -86,6 +87,11 @@ class Route(Base, UUIDMixin, TimestampMixin):
8687
default=8,
8788
nullable=True,
8889
)
90+
max_path_length: Mapped[Optional[int]] = mapped_column(
91+
Integer,
92+
default=None,
93+
nullable=True,
94+
)
8995
enabled: Mapped[bool] = mapped_column(
9096
Boolean,
9197
default=True,

src/meshcore_hub/common/schemas/routes.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ class RouteCreate(BaseModel):
7575
max_hop_span: Optional[int] = Field(
7676
default=8, description="Max hop distance between first and last node"
7777
)
78+
max_path_length: Optional[int] = Field(
79+
default=None,
80+
ge=1,
81+
description="Max hops in a candidate packet's full path; null = unlimited",
82+
)
7883
enabled: bool = Field(default=True, description="Whether this route is evaluated")
7984
reversible: bool = Field(
8085
default=True, description="Whether to match the path in both directions"
@@ -113,6 +118,7 @@ class RouteUpdate(BaseModel):
113118
packet_count_threshold: Optional[int] = Field(default=None, ge=1, le=10000)
114119
clear_threshold: Optional[int] = None
115120
max_hop_span: Optional[int] = None
121+
max_path_length: Optional[int] = Field(default=None, ge=1)
116122
enabled: Optional[bool] = None
117123
reversible: Optional[bool] = None
118124
node_public_keys: Optional[list[str]] = None
@@ -149,6 +155,7 @@ class RouteRead(BaseModel):
149155
packet_count_threshold: int
150156
clear_threshold: Optional[int] = None
151157
max_hop_span: Optional[int] = None
158+
max_path_length: Optional[int] = None
152159
enabled: bool
153160
reversible: bool
154161
route_nodes: list[RouteNodeRead] = []
@@ -205,6 +212,7 @@ class RouteDetail(BaseModel):
205212
packet_count_threshold: int
206213
clear_threshold: Optional[int] = None
207214
max_hop_span: Optional[int] = None
215+
max_path_length: Optional[int] = None
208216
enabled: bool
209217
reversible: bool
210218
route_nodes: list[RouteNodeRead] = []
@@ -236,6 +244,7 @@ class RoutePreviewRequest(BaseModel):
236244
packet_count_threshold: int = Field(default=5, ge=1, le=10000)
237245
clear_threshold: Optional[int] = None
238246
max_hop_span: Optional[int] = Field(default=8)
247+
max_path_length: Optional[int] = Field(default=None, ge=1)
239248
observer_public_keys: Optional[list[str]] = None
240249
reversible: bool = Field(default=True)
241250

src/meshcore_hub/web/static/js/spa/icons.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,11 @@ export function iconRouteFrom(cls = 'h-5 w-5') {
189189
export function iconRouteTo(cls = 'h-5 w-5') {
190190
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>`;
191191
}
192+
193+
export function iconHopSpan(cls = 'h-5 w-5') {
194+
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>`;
195+
}
196+
197+
export function iconPathLength(cls = 'h-5 w-5') {
198+
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>`;
199+
}

0 commit comments

Comments
 (0)