feat: reversible route matching

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)
This commit is contained in:
Louis King committed 2026-07-12 22:39:15 +01:00
1 parent 14fbc45387
commit 0e202b3999
12 files changed
+211 -11

No files matched your search

@@ -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")
+1
View File
@@ -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
+5
View File
@@ -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)
+2
View File
@@ -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()
+53 -7
View File
@@ -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)
+6
View File
@@ -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",
@@ -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":
@@ -61,9 +61,10 @@ function renderSummaryStrip(routes) {
function renderPathChips(route) {
const nodes = route.route_nodes || [];
const arrow = route.reversible !== false ? '\u2194' : '\u2192';
return html`<div class="flex flex-wrap items-center gap-1 text-sm">
${nodes.map((rn, i) => html`
${i > 0 ? html`<span class="opacity-50">\u2192</span>` : nothing}
${i > 0 ? html`<span class="opacity-50">${arrow}</span>` : nothing}
<span class="badge badge-ghost badge-sm">${rn.name || rn.public_key?.slice(0, 8) || rn.node_id.slice(0, 8)}</span>
`)}
</div>`;
@@ -147,9 +148,23 @@ function renderDetailContent(route, detail) {
${matches.length > 0 ? html`<div>
<strong class="opacity-70">${t('routes.recent_matches')}:</strong>
<div class="mt-1 space-y-1">
${matches.map(m => html`<div class="font-mono text-xs opacity-60">
${(m.hops || []).map((h, i) => html`${i > 0 ? ' \u2192 ' : nothing}${h.node_hash}`).slice(0, 10)}
</div>`)}
${matches.map(m => {
const pathLookup = new Map(
(route.route_nodes || []).map(rn =>
[rn.expected_hash?.toLowerCase(), rn])
);
return html`<div class="flex flex-wrap items-center gap-0.5 text-xs">
${(m.hops || []).slice(0, 10).map((h, i) => {
const rn = pathLookup.get((h.node_hash || '').toLowerCase());
return html`
${i > 0 ? html`<span class="opacity-30 mx-0.5">\u2192</span>` : nothing}
${rn
? html`<span class="badge badge-primary badge-sm">${rn.name || h.node_hash}</span>`
: html`<span class="badge badge-ghost badge-sm opacity-50">${h.node_hash}</span>`}
`;
})}
</div>`;
})}
</div>
</div>` : nothing}
<div class="opacity-50 text-xs">
@@ -324,11 +339,18 @@ function renderRouteModal({ modalState, onSave, onCancel }) {
placeholder="\u221E" min="1" />
</div>
</div>
<div class="flex gap-6">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" id="route-modal-enabled" class="checkbox checkbox-sm"
.checked=${route.enabled !== false} />
<span class="text-sm">${t('routes.enabled_label')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" id="route-modal-reversible" class="checkbox checkbox-sm"
.checked=${route.reversible !== false} />
<span class="text-sm">${t('routes.reversible_label')}</span>
</label>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
@@ -652,6 +674,7 @@ export async function render(container, params, router) {
const degradedEl = document.getElementById('route-modal-degraded');
const spanEl = document.getElementById('route-modal-span');
const enabledEl = document.getElementById('route-modal-enabled');
const reversibleEl = document.getElementById('route-modal-reversible');
const isEdit = modalState.isEdit;
const nodePublicKeys = modalState.pathNodes.map(n => n.public_key);
@@ -671,6 +694,7 @@ export async function render(container, params, router) {
packet_count_threshold: parseInt(thresholdEl.value, 10) || 3,
max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null,
enabled: enabledEl.checked,
reversible: reversibleEl.checked,
node_public_keys: nodePublicKeys,
observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,
};
@@ -334,6 +334,7 @@
"degraded_label": "Degraded",
"span_label": "Max Span",
"enabled_label": "Enabled",
"reversible_label": "Reversible (match both directions)",
"disabled": "Disabled",
"not_evaluated": "Not yet evaluated",
"diagnosis": "Diagnosis",
@@ -239,6 +239,7 @@
"quality_no_coverage": "Geen dekking",
"quality_unknown": "Onbekend",
"disabled": "Uitgeschakeld",
"reversible_label": "Omkeerbaar (beide richtingen)",
"search_nodes_placeholder": "Zoek op naam of public key\u2026",
"path_label": "Padknooppunten",
"path_help": "Zoek op knooppuntnaam of public key en selecteer uit de resultaten. Minimaal 2 unieke knooppunten vereist.",
+17
View File
@@ -67,6 +67,23 @@ class TestCreateRoute:
assert data["name"] == "Route1"
assert len(data["route_nodes"]) == 2
assert data["route_nodes"][0]["expected_hash"] is not None
assert data["reversible"] is True
def test_create_non_reversible(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_no_auth.post(
"/api/v1/routes",
json={
"name": "OneWay",
"node_public_keys": [n.public_key for n in nodes],
"reversible": False,
},
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 201
assert resp.json()["reversible"] is False
def test_duplicate_name_rejected(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
+53
View File
@@ -84,6 +84,7 @@ def _make_route(
observers: list[Node] | None = None,
enabled: bool = True,
window_hours: int = 24,
reversible: bool = True,
) -> Route:
route = Route(
name=name,
@@ -93,6 +94,7 @@ def _make_route(
max_hop_span=max_hop_span,
enabled=enabled,
window_hours=window_hours,
reversible=reversible,
)
db_session.add(route)
db_session.flush()
@@ -341,6 +343,56 @@ class TestEvaluateRoute:
assert quality == RouteQuality.CLEAR.value
assert count == 4 # short-circuited at effective_degraded = 4
def test_reversible_matches_reverse_direction(self, db_session):
"""Reversible route matches packets travelling the reverse path."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
# Packets going B->A (reverse of configured A->B)
for i in range(5):
_make_reception(db_session, None, f"pkt{i}", ["BB", "AA"])
db_session.commit()
since = _NOW - timedelta(hours=24)
state, quality, count = evaluate_route(db_session, route, since)
assert state == RouteState.HEALTHY.value
assert count == 5
def test_non_reversible_ignores_reverse_direction(self, db_session):
"""Non-reversible route does NOT match reverse-direction packets."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(
db_session, "R1", [node_a, node_b], threshold=3, reversible=False
)
# Only reverse-direction packets exist (B->A)
for i in range(5):
_make_reception(db_session, None, f"pkt{i}", ["BB", "AA"])
db_session.commit()
since = _NOW - timedelta(hours=24)
state, quality, count = evaluate_route(db_session, route, since)
assert count == 0
def test_reversible_matches_both_directions(self, db_session):
"""Reversible route matches packets in both directions."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
_make_reception(db_session, None, "fwd1", ["AA", "BB"])
_make_reception(db_session, None, "rev1", ["BB", "AA"])
_make_reception(db_session, None, "fwd2", ["AA", "BB"])
_make_reception(db_session, None, "rev2", ["BB", "AA"])
db_session.commit()
since = _NOW - timedelta(hours=24)
state, quality, count = evaluate_route(db_session, route, since)
assert state == RouteState.HEALTHY.value
assert count == 4
class TestEvaluateAllRoutes:
def test_only_enabled_routes(self, db_session):
@@ -437,6 +489,7 @@ class TestPreviewRoute:
"node_ids": [node_a.id, node_b.id],
"match_width": 1,
"packet_count_threshold": 3,
"reversible": False,
},
since,
)