chore(routes): default packet_count_threshold=5 and clear multiplier=3x

Change the new-route defaults so the create modal, REST API, YAML
import, and preview helper pre-fill packet_count_threshold=5 instead
of 3, and bump the effective-clear auto-multiplier from 2x to 3x so
the default comfort bar tracks to 15 for a default threshold of 5.

Schema/model/cli/preview:
- schemas/routes.py: RouteCreate + RoutePreviewRequest
  packet_count_threshold default 3 -> 5 (clear_threshold stays None)
- models/route.py: Route INSERT default 3 -> 5 (clear_threshold stays
  nullable, no default)
- collector/cli.py: YAML import fallbacks 3 -> 5
- collector/routes.py: preview helper fallback 3 -> 5; module constant
  CLEAR_DEFAULT_MULTIPLIER 2 -> 3 (drives effective_clear_threshold)
- api/metrics.py: route_clear gauge help text 2x -> 3x

UI:
- web/spa/pages/routes.js: new-route modal pre-fills
  packet_count_threshold=5; placeholder shows 3x threshold; parseInt
  fallback 3 -> 5. clear_threshold field unchanged (clearing still
  sends null for the auto-tracking behaviour).

Docs:
- docs/routes.md defaults table: window_hours 24->48, threshold 3->5,
  clear (2x)->(3x), max_hop_span (unlimited)->8. Previous four
  entries now match the shipped defaults (some were stale from #316).
- docs/seeding.md YAML example: window_hours 24->48, threshold 3->5,
  commented clear_threshold example 10->15 with 3x note.

No migration: packet_count_threshold uses Python-side default= (no
server_default), so existing rows keep their stored values. Existing
routes with clear_threshold=NULL continue to track via the new 3x
multiplier at evaluation time.
This commit is contained in:
Louis King
2026-07-19 21:40:26 +01:00
parent acd3a045ca
commit 8cf5dadc38
11 changed files with 30 additions and 30 deletions
+4 -4
View File
@@ -17,10 +17,10 @@ Each route carries these knobs:
| Field | Default | Description |
| --- | --- | --- |
| `match_width` | `1` | Path-hash prefix width in bytes (1/2/3). Higher widths disambiguate nodes that share a short public-key prefix. |
| `window_hours` | `24` | Rolling lookback window for the live status card. |
| `packet_count_threshold` | `3` | 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` | _(2× threshold)_ | Comfort bar for the `clear`/`marginal` split. Omit/null to use twice the threshold. |
| `max_hop_span` | _(unlimited)_ | Caps the position gap between the first and last matched node, to reject matches that wander too far. |
| `window_hours` | `48` | Rolling lookback window for the live status card. |
| `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. |
| `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`. |
+3 -3
View File
@@ -126,9 +126,9 @@ routes:
description: A140 corridor route
visibility: community
match_width: 1
window_hours: 24
packet_count_threshold: 3
# clear_threshold: 10 # optional; omit/null = 2x threshold
window_hours: 48
packet_count_threshold: 5
# clear_threshold: 15 # optional; omit/null = 3x threshold
# max_hop_span: 8 # optional; omit/null = unlimited
enabled: true
reversible: true # match both directions (A->B and B->A)
+1 -1
View File
@@ -348,7 +348,7 @@ def collect_metrics(session: Any) -> bytes:
)
route_clear = Gauge(
"meshcore_route_clear_threshold",
"Effective clear threshold (2x threshold when unset)",
"Effective clear threshold (3x threshold when unset)",
["route"],
registry=registry,
)
+2 -2
View File
@@ -806,7 +806,7 @@ def _import_routes(
route.match_width = match_width
route.window_hours = value.get("window_hours", 48)
route.packet_count_threshold = value.get(
"packet_count_threshold", 3
"packet_count_threshold", 5
)
route.clear_threshold = value.get("clear_threshold")
route.max_hop_span = value.get("max_hop_span", 8)
@@ -827,7 +827,7 @@ def _import_routes(
visibility=visibility,
match_width=match_width,
window_hours=value.get("window_hours", 48),
packet_count_threshold=value.get("packet_count_threshold", 3),
packet_count_threshold=value.get("packet_count_threshold", 5),
clear_threshold=value.get("clear_threshold"),
max_hop_span=value.get("max_hop_span", 8),
enabled=value.get("enabled", True),
+4 -4
View File
@@ -27,8 +27,8 @@ from meshcore_hub.common.models.route_result_history import RouteResultHistory
logger = logging.getLogger(__name__)
#: Multiplier for the relative default comfort bar (``clear_threshold = None``
#: means ``effective_clear = 2 × packet_count_threshold``).
CLEAR_DEFAULT_MULTIPLIER = 2
#: means ``effective_clear = 3 × packet_count_threshold``).
CLEAR_DEFAULT_MULTIPLIER = 3
#: Cap on candidate receptions for preview to bound work per call.
PREVIEW_CANDIDATE_CAP = 5000
@@ -54,7 +54,7 @@ def derive_expected_hash(public_key: str, match_width: int) -> str:
def effective_clear_threshold(route: Route) -> int:
"""The effective comfort bar: explicit value or ``2 × threshold``."""
"""The effective comfort bar: explicit value or ``3 × threshold``."""
return route.clear_threshold or (
route.packet_count_threshold * CLEAR_DEFAULT_MULTIPLIER
)
@@ -1239,7 +1239,7 @@ def preview_route(
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")
threshold: int = config.get("packet_count_threshold") or 3
threshold: int = config.get("packet_count_threshold") or 5
clear_bar: Optional[int] = config.get("clear_threshold")
reversible: bool = config.get("reversible", True)
+2 -2
View File
@@ -41,7 +41,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
match_width: Path-hash prefix width in bytes (1/2/3)
window_hours: Evaluation lookback window in hours
packet_count_threshold: Minimum distinct packets for healthy
clear_threshold: Comfort bar for the clear/marginal split (null = 2x threshold)
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)
enabled: Whether this route is actively evaluated
"""
@@ -74,7 +74,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
)
packet_count_threshold: Mapped[int] = mapped_column(
Integer,
default=3,
default=5,
nullable=False,
)
clear_threshold: Mapped[Optional[int]] = mapped_column(
+3 -3
View File
@@ -67,10 +67,10 @@ class RouteCreate(BaseModel):
default=48, ge=1, le=720, description="Evaluation window in hours"
)
packet_count_threshold: int = Field(
default=3, ge=1, le=10000, description="Minimum distinct packets for healthy"
default=5, ge=1, le=10000, description="Minimum distinct packets for healthy"
)
clear_threshold: Optional[int] = Field(
default=None, description="Comfort bar (null = 2x threshold)"
default=None, description="Comfort bar (null = 3x threshold)"
)
max_hop_span: Optional[int] = Field(
default=8, description="Max hop distance between first and last node"
@@ -233,7 +233,7 @@ class RoutePreviewRequest(BaseModel):
)
match_width: int = Field(default=1, ge=1, le=3)
window_hours: int = Field(default=48, ge=1, le=720)
packet_count_threshold: int = Field(default=3, ge=1, le=10000)
packet_count_threshold: int = Field(default=5, ge=1, le=10000)
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = Field(default=8)
observer_public_keys: Optional[list[str]] = None
@@ -405,13 +405,13 @@ function renderRouteModal({ modalState, onSave, onCancel, saving }) {
<div>
<label class="text-sm opacity-70">${t('routes.threshold_label')}</label>
<input type="number" id="route-modal-threshold" class="input input-sm w-full"
.value=${route.packet_count_threshold || 3} min="1" max="10000" />
.value=${route.packet_count_threshold || 5} min="1" max="10000" />
</div>
<div>
<label class="text-sm opacity-70">${t('routes.clear_label')}</label>
<input type="number" id="route-modal-clear" class="input input-sm w-full"
.value=${route.clear_threshold || ''}
placeholder="${2 * (route.packet_count_threshold || 3)}" min="1" />
placeholder="${3 * (route.packet_count_threshold || 5)}" min="1" />
</div>
<div>
<label class="text-sm opacity-70">${t('routes.span_label')}</label>
@@ -633,7 +633,7 @@ export async function render(container, params, router) {
}
function handleAdd() {
modalState = _newModalState('add', { visibility: 'community', enabled: true, match_width: 1, window_hours: 48, max_hop_span: 8 });
modalState = _newModalState('add', { visibility: 'community', enabled: true, match_width: 1, window_hours: 48, max_hop_span: 8, packet_count_threshold: 5 });
renderPage(routes);
}
@@ -804,7 +804,7 @@ export async function render(container, params, router) {
visibility: visEl.value,
match_width: parseInt(widthEl.value, 10) || 1,
window_hours: parseInt(windowEl.value, 10) || 48,
packet_count_threshold: parseInt(thresholdEl.value, 10) || 3,
packet_count_threshold: parseInt(thresholdEl.value, 10) || 5,
max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null,
enabled: enabledEl.checked,
reversible: reversibleEl.checked,
+1 -1
View File
@@ -390,7 +390,7 @@ class TestRouteQualityAvg:
quality="clear",
matched_count=1,
threshold=route.packet_count_threshold,
effective_clear=route.packet_count_threshold * 2,
effective_clear=route.packet_count_threshold * 3,
quality_avg=value,
)
)
+2 -2
View File
@@ -104,7 +104,7 @@ class TestRunEvaluation:
route = _make_route(
db_session, "R1", [node_a, node_b], packet_count_threshold=3
)
for i in range(7):
for i in range(10):
_make_reception(db_session, f"pkt{i}", ["AA", "BB"])
db_session.commit()
@@ -117,7 +117,7 @@ class TestRunEvaluation:
assert result.state == RouteState.HEALTHY.value
assert result.quality == RouteQuality.CLEAR.value
assert result.threshold == 3
assert result.effective_clear == 6
assert result.effective_clear == 9
def test_evaluation_error_logged(self, db_manager, db_session, monkeypatch):
"""An exception evaluating one route is caught; count stays 0."""
+4 -4
View File
@@ -212,14 +212,14 @@ class TestEffectiveClear:
)
assert effective_clear_threshold(route) == 20
def test_default_2x(self, db_session):
def test_default_3x(self, db_session):
route = Route(
from_label="t",
to_label="t",
packet_count_threshold=5,
clear_threshold=None,
)
assert effective_clear_threshold(route) == 10
assert effective_clear_threshold(route) == 15
class TestDeriveExpectedHash:
@@ -681,7 +681,7 @@ class TestPreviewRoute:
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
for i in range(7):
for i in range(10):
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
db_session.commit()
@@ -695,7 +695,7 @@ class TestPreviewRoute:
},
since,
)
assert result["matched_count"] == 7
assert result["matched_count"] == 10
assert result["quality"] == RouteQuality.CLEAR.value
assert result["truncated"] is False