diff --git a/alembic/versions/20260720_1200_d307ee761a34_add_route_max_path_length.py b/alembic/versions/20260720_1200_d307ee761a34_add_route_max_path_length.py new file mode 100644 index 0000000..04641b5 --- /dev/null +++ b/alembic/versions/20260720_1200_d307ee761a34_add_route_max_path_length.py @@ -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") diff --git a/docs/routes.md b/docs/routes.md index a252505..a17ebdf 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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`. | diff --git a/docs/seeding.md b/docs/seeding.md index 72bbf46..86b4f30 100644 --- a/docs/seeding.md +++ b/docs/seeding.md @@ -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: diff --git a/example/seed/routes.yaml b/example/seed/routes.yaml index 1ec0d75..b5b7bdc 100644 --- a/example/seed/routes.yaml +++ b/example/seed/routes.yaml @@ -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: diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index ae270ff..2d0f0cd 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -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, diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index 08b2474..1fde801 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -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), ) diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py index 6874d22..ddd6792 100644 --- a/src/meshcore_hub/collector/routes.py +++ b/src/meshcore_hub/collector/routes.py @@ -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) diff --git a/src/meshcore_hub/common/models/route.py b/src/meshcore_hub/common/models/route.py index e44468a..6cab52c 100644 --- a/src/meshcore_hub/common/models/route.py +++ b/src/meshcore_hub/common/models/route.py @@ -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, diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py index 074a0c9..8af0966 100644 --- a/src/meshcore_hub/common/schemas/routes.py +++ b/src/meshcore_hub/common/schemas/routes.py @@ -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) diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js index e7350c0..76cb3b0 100644 --- a/src/meshcore_hub/web/static/js/spa/icons.js +++ b/src/meshcore_hub/web/static/js/spa/icons.js @@ -189,3 +189,11 @@ export function iconRouteFrom(cls = 'h-5 w-5') { export function iconRouteTo(cls = 'h-5 w-5') { return html``; } + +export function iconHopSpan(cls = 'h-5 w-5') { + return html``; +} + +export function iconPathLength(cls = 'h-5 w-5') { + return html``; +} diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js index 7c730b6..90e7765 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -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`
- + ${iconPackets('h-3.5 w-3.5')} ${matched}/${threshold}\u2192${degraded} - + ${iconClock('h-3.5 w-3.5')} ${route.window_hours}h - + ${iconRuler('h-3.5 w-3.5')} ${route.match_width}B - + ${iconNodes('h-3.5 w-3.5')} ${nodeCount} - ${route.max_hop_span ? html` - ${iconPath('h-3.5 w-3.5')} - ${route.max_hop_span} - ` : nothing} - + + ${iconHopSpan('h-3.5 w-3.5')} + ${route.max_hop_span || '\u221E'} + + + ${iconPathLength('h-3.5 w-3.5')} + ${route.max_path_length || '\u221E'} + + ${iconSatelliteDish('h-3.5 w-3.5')} ${obsCount || '\u221E'} @@ -419,6 +423,12 @@ function renderRouteModal({ modalState, onSave, onCancel, saving }) { .value=${route.max_hop_span || ''} placeholder="\u221E" min="1" />
+
+ + +