From 0e202b399928a2b07f57a8e3b88ad2ba5a7dd991 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 12 Jul 2026 22:39:15 +0100 Subject: [PATCH] feat: reversible route matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes now default to bidirectional matching — packets traversing the configured path in reverse (C->B->A instead of A->B->C) also count towards health. A 'reversible' toggle lets users constrain a route to one direction when needed. - Route model: new 'reversible' column (default true, server_default 1) - Migration: a1b2c3d4e5f6 (additive ALTER TABLE) - Matching engine: _match_hops + _fetch_candidate_paths_maybe_bidirectional helpers shared across evaluate_route, recent_matches, and preview_route - API schemas: reversible on RouteCreate/Update/Read/Detail/PreviewRequest - SPA: toggle in modal; card path chips show <-> for reversible, -> otherwise - Seed loader + example YAML: reads 'reversible' key - 4 new collector tests (reverse match, non-reversible, both-directions, API) --- ...0_a1b2c3d4e5f6_add_reversible_to_routes.py | 37 ++++++++++++ example/seed/routes.yaml | 1 + src/meshcore_hub/api/routes/routes.py | 5 ++ src/meshcore_hub/collector/cli.py | 2 + src/meshcore_hub/collector/routes.py | 60 ++++++++++++++++--- src/meshcore_hub/common/models/route.py | 6 ++ src/meshcore_hub/common/schemas/routes.py | 7 +++ .../web/static/js/spa/pages/routes.js | 42 ++++++++++--- src/meshcore_hub/web/static/locales/en.json | 1 + src/meshcore_hub/web/static/locales/nl.json | 1 + tests/test_api/test_routes.py | 17 ++++++ tests/test_collector/test_routes.py | 53 ++++++++++++++++ 12 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 alembic/versions/20260713_0100_a1b2c3d4e5f6_add_reversible_to_routes.py diff --git a/alembic/versions/20260713_0100_a1b2c3d4e5f6_add_reversible_to_routes.py b/alembic/versions/20260713_0100_a1b2c3d4e5f6_add_reversible_to_routes.py new file mode 100644 index 0000000..9ac7ec1 --- /dev/null +++ b/alembic/versions/20260713_0100_a1b2c3d4e5f6_add_reversible_to_routes.py @@ -0,0 +1,37 @@ +"""add reversible column to routes + +Revision ID: a1b2c3d4e5f6 +Revises: 8f2a3c4d5e6f +Create Date: 2026-07-13 01:00:00.000000+00:00 + +Adds a ``reversible`` boolean to the ``routes`` table (default true). +When true, the matching engine also checks the reverse-ordered path, +so A->B->C matches packets observed as C->B->A. + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "8f2a3c4d5e6f" +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( + "reversible", + sa.Boolean(), + nullable=False, + server_default=sa.text("1"), + ), + ) + + +def downgrade() -> None: + op.drop_column("routes", "reversible") diff --git a/example/seed/routes.yaml b/example/seed/routes.yaml index 7ddb8c8..306b979 100644 --- a/example/seed/routes.yaml +++ b/example/seed/routes.yaml @@ -18,6 +18,7 @@ Ipswich ↔ Norwich: # degraded_threshold: 10 # optional; omit/null = 2x threshold # max_hop_span: 8 # optional; omit/null = unlimited enabled: true + reversible: true # match both directions (A->B and B->A) path: - a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 - 9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index 3d27114..ee13915 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -89,6 +89,7 @@ def _route_to_read(route: Route) -> RouteRead: degraded_threshold=route.degraded_threshold, max_hop_span=route.max_hop_span, enabled=route.enabled, + reversible=route.reversible, route_nodes=[_route_node_to_read(rn) for rn in route.route_nodes], route_observers=[_route_observer_to_read(ro) for ro in route.route_observers], route_result=_result_to_summary(route.route_result), @@ -191,6 +192,7 @@ def create_route( degraded_threshold=body.degraded_threshold, max_hop_span=body.max_hop_span, enabled=body.enabled, + reversible=body.reversible, ) session.add(route) session.flush() @@ -297,6 +299,8 @@ def update_route( route.max_hop_span = body.max_hop_span if body.enabled is not None: route.enabled = body.enabled + if body.reversible is not None: + route.reversible = body.reversible if body.node_public_keys is not None: nodes = _resolve_nodes_by_pubkey(session, body.node_public_keys) @@ -364,6 +368,7 @@ def preview( "max_hop_span": body.max_hop_span, "packet_count_threshold": body.packet_count_threshold, "degraded_threshold": body.degraded_threshold, + "reversible": body.reversible, } result = preview_route(session, config, since) return RoutePreviewResponse(**result) diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index b730d57..91a77e9 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -789,6 +789,7 @@ def _import_routes( route.degraded_threshold = value.get("degraded_threshold") route.max_hop_span = value.get("max_hop_span") route.enabled = value.get("enabled", True) + route.reversible = value.get("reversible", True) # Replace path nodes wholesale for rn in list(route.route_nodes): session.delete(rn) @@ -807,6 +808,7 @@ def _import_routes( degraded_threshold=value.get("degraded_threshold"), max_hop_span=value.get("max_hop_span"), enabled=value.get("enabled", True), + reversible=value.get("reversible", True), ) session.add(route) session.flush() diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py index 52d5864..7633d51 100644 --- a/src/meshcore_hub/collector/routes.py +++ b/src/meshcore_hub/collector/routes.py @@ -110,6 +110,38 @@ def is_subsequence( return True +def _match_hops( + hops: list[dict[str, Any]], + expected: list[str], + max_hop_span: Optional[int] = None, + reversible: bool = True, +) -> bool: + """Check whether *hops* match *expected* forward (and optionally reverse).""" + if is_subsequence(hops, expected, max_hop_span): + return True + if reversible and len(expected) > 1: + return is_subsequence(hops, list(reversed(expected)), max_hop_span) + return False + + +def _fetch_candidate_paths_maybe_bidirectional( + session: Session, + expected: list[str], + since: datetime, + observer_ids: Optional[list[str]] = None, + reversible: bool = True, +) -> dict[str, list[dict[str, Any]]]: + """Fetch candidate paths from one or both endpoints of *expected*.""" + paths = fetch_candidate_paths(session, expected[0], since, observer_ids) + if reversible and len(expected) > 1 and expected[-1] != expected[0]: + for rp_id, hops in fetch_candidate_paths( + session, expected[-1], since, observer_ids + ).items(): + if rp_id not in paths: + paths[rp_id] = hops + return paths + + def prefix_collision_counts(session: Session, match_width: int) -> dict[str, int]: """Count how many nodes share each public-key prefix at *match_width*. @@ -279,13 +311,16 @@ def evaluate_route( [ro.node_id for ro in route.route_observers] if route.route_observers else None ) - paths = fetch_candidate_paths(session, expected[0], since, observer_ids) + reversible = getattr(route, "reversible", True) + paths = _fetch_candidate_paths_maybe_bidirectional( + session, expected, since, observer_ids, reversible + ) eff_degraded = effective_degraded_threshold(route) matched_packets: set[str] = set() for hops in paths.values(): - if is_subsequence(hops, expected, route.max_hop_span): + if _match_hops(hops, expected, route.max_hop_span, reversible): ph = hops[0]["packet_hash"] if ph: matched_packets.add(ph) @@ -388,11 +423,14 @@ def recent_matches( ) since = datetime.now(timezone.utc) - timedelta(hours=route.window_hours) - paths = fetch_candidate_paths(session, expected[0], since, observer_ids) + reversible = getattr(route, "reversible", True) + paths = _fetch_candidate_paths_maybe_bidirectional( + session, expected, since, observer_ids, reversible + ) matches: list[dict[str, Any]] = [] for hops in paths.values(): - if is_subsequence(hops, expected, route.max_hop_span): + if _match_hops(hops, expected, route.max_hop_span, reversible): first = hops[0] if hops else {} matches.append( { @@ -418,7 +456,8 @@ def preview_route( """Preview matching for an unsaved route config. *config* keys: ``node_ids``, ``match_width``, ``observer_ids``, - ``max_hop_span``, ``packet_count_threshold``, ``degraded_threshold``. + ``max_hop_span``, ``packet_count_threshold``, ``degraded_threshold``, + ``reversible``. """ node_ids: list[str] = config.get("node_ids") or [] match_width: int = config.get("match_width") or 1 @@ -426,6 +465,7 @@ def preview_route( max_hop_span: Optional[int] = config.get("max_hop_span") threshold: int = config.get("packet_count_threshold") or 3 degraded: Optional[int] = config.get("degraded_threshold") + reversible: bool = config.get("reversible", True) if len(node_ids) < 2: return { @@ -460,6 +500,10 @@ def preview_route( candidate_count = _count_candidate_receptions( session, first_prefix, since, observer_ids ) + if reversible and len(expected) > 1 and expected[-1] != expected[0]: + candidate_count += _count_candidate_receptions( + session, expected[-1], since, observer_ids + ) if candidate_count > PREVIEW_CANDIDATE_CAP: return { "matched_count": None, @@ -468,7 +512,9 @@ def preview_route( "candidate_count": candidate_count, } - paths = fetch_candidate_paths(session, first_prefix, since, observer_ids) + paths = _fetch_candidate_paths_maybe_bidirectional( + session, expected, since, observer_ids, reversible + ) eff_degraded = degraded or (threshold * DEGRADED_DEFAULT_MULTIPLIER) @@ -476,7 +522,7 @@ def preview_route( contributing: dict[str, int] = {} for hops in paths.values(): - if is_subsequence(hops, expected, max_hop_span): + if _match_hops(hops, expected, max_hop_span, reversible): ph = hops[0]["packet_hash"] if ph: matched_packets.add(ph) diff --git a/src/meshcore_hub/common/models/route.py b/src/meshcore_hub/common/models/route.py index ae9c481..f0f4273 100644 --- a/src/meshcore_hub/common/models/route.py +++ b/src/meshcore_hub/common/models/route.py @@ -85,6 +85,12 @@ class Route(Base, UUIDMixin, TimestampMixin): default=True, nullable=False, ) + reversible: Mapped[bool] = mapped_column( + Boolean, + default=True, + server_default="1", + nullable=False, + ) route_nodes: Mapped[list["RouteNode"]] = relationship( "RouteNode", diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py index 816e646..e40b455 100644 --- a/src/meshcore_hub/common/schemas/routes.py +++ b/src/meshcore_hub/common/schemas/routes.py @@ -65,6 +65,9 @@ class RouteCreate(BaseModel): default=None, description="Max hop distance between first and last node" ) 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" + ) node_public_keys: list[str] = Field( ..., description="Ordered path node public keys (64-char hex, >= 2, distinct)" ) @@ -99,6 +102,7 @@ class RouteUpdate(BaseModel): degraded_threshold: Optional[int] = None max_hop_span: Optional[int] = None enabled: Optional[bool] = None + reversible: Optional[bool] = None node_public_keys: Optional[list[str]] = None observer_public_keys: Optional[list[str]] = None @@ -133,6 +137,7 @@ class RouteRead(BaseModel): degraded_threshold: Optional[int] = None max_hop_span: Optional[int] = None enabled: bool + reversible: bool route_nodes: list[RouteNodeRead] = [] route_observers: list[RouteObserverRead] = [] route_result: Optional[RouteResultSummary] = None @@ -179,6 +184,7 @@ class RouteDetail(BaseModel): degraded_threshold: Optional[int] = None max_hop_span: Optional[int] = None enabled: bool + reversible: bool route_nodes: list[RouteNodeRead] = [] route_observers: list[RouteObserverRead] = [] route_result: Optional[RouteResultSummary] = None @@ -202,6 +208,7 @@ class RoutePreviewRequest(BaseModel): degraded_threshold: Optional[int] = None max_hop_span: Optional[int] = None observer_public_keys: Optional[list[str]] = None + reversible: bool = Field(default=True) @model_validator(mode="after") def validate_preview(self) -> "RoutePreviewRequest": 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 7a8088d..d21cfcc 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -61,9 +61,10 @@ function renderSummaryStrip(routes) { function renderPathChips(route) { const nodes = route.route_nodes || []; + const arrow = route.reversible !== false ? '\u2194' : '\u2192'; return html`
${nodes.map((rn, i) => html` - ${i > 0 ? html`\u2192` : nothing} + ${i > 0 ? html`${arrow}` : nothing} ${rn.name || rn.public_key?.slice(0, 8) || rn.node_id.slice(0, 8)} `)}
`; @@ -147,9 +148,23 @@ function renderDetailContent(route, detail) { ${matches.length > 0 ? html`
${t('routes.recent_matches')}:
- ${matches.map(m => html`
- ${(m.hops || []).map((h, i) => html`${i > 0 ? ' \u2192 ' : nothing}${h.node_hash}`).slice(0, 10)} -
`)} + ${matches.map(m => { + const pathLookup = new Map( + (route.route_nodes || []).map(rn => + [rn.expected_hash?.toLowerCase(), rn]) + ); + return html`
+ ${(m.hops || []).slice(0, 10).map((h, i) => { + const rn = pathLookup.get((h.node_hash || '').toLowerCase()); + return html` + ${i > 0 ? html`\u2192` : nothing} + ${rn + ? html`${rn.name || h.node_hash}` + : html`${h.node_hash}`} + `; + })} +
`; + })}
` : nothing}
@@ -324,11 +339,18 @@ function renderRouteModal({ modalState, onSave, onCancel }) { placeholder="\u221E" min="1" />
- +
+ + +