From 8cf5dadc38f048232abbabc47f188928d1fee6c1 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 21:40:26 +0100 Subject: [PATCH] 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. --- docs/routes.md | 8 ++++---- docs/seeding.md | 6 +++--- src/meshcore_hub/api/metrics.py | 2 +- src/meshcore_hub/collector/cli.py | 4 ++-- src/meshcore_hub/collector/routes.py | 8 ++++---- src/meshcore_hub/common/models/route.py | 4 ++-- src/meshcore_hub/common/schemas/routes.py | 6 +++--- src/meshcore_hub/web/static/js/spa/pages/routes.js | 8 ++++---- tests/test_api/test_routes.py | 2 +- tests/test_collector/test_route_evaluator.py | 4 ++-- tests/test_collector/test_routes.py | 8 ++++---- 11 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/routes.md b/docs/routes.md index 6c794ef..a252505 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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`. | diff --git a/docs/seeding.md b/docs/seeding.md index 2127bc7..72bbf46 100644 --- a/docs/seeding.md +++ b/docs/seeding.md @@ -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) diff --git a/src/meshcore_hub/api/metrics.py b/src/meshcore_hub/api/metrics.py index aa04912..927558b 100644 --- a/src/meshcore_hub/api/metrics.py +++ b/src/meshcore_hub/api/metrics.py @@ -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, ) diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index 8e78cc0..08b2474 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -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), diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py index cd286b2..6874d22 100644 --- a/src/meshcore_hub/collector/routes.py +++ b/src/meshcore_hub/collector/routes.py @@ -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) diff --git a/src/meshcore_hub/common/models/route.py b/src/meshcore_hub/common/models/route.py index cf5c0a0..e44468a 100644 --- a/src/meshcore_hub/common/models/route.py +++ b/src/meshcore_hub/common/models/route.py @@ -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( diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py index d2df380..074a0c9 100644 --- a/src/meshcore_hub/common/schemas/routes.py +++ b/src/meshcore_hub/common/schemas/routes.py @@ -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 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 f86a128..8ba9c89 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -405,13 +405,13 @@ function renderRouteModal({ modalState, onSave, onCancel, saving }) {
+ .value=${route.packet_count_threshold || 5} min="1" max="10000" />
+ placeholder="${3 * (route.packet_count_threshold || 5)}" min="1" />
@@ -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, diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 8d01c97..0d1cdfb 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -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, ) ) diff --git a/tests/test_collector/test_route_evaluator.py b/tests/test_collector/test_route_evaluator.py index 463521b..f7af1f0 100644 --- a/tests/test_collector/test_route_evaluator.py +++ b/tests/test_collector/test_route_evaluator.py @@ -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.""" diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py index dac2da6..892a073 100644 --- a/tests/test_collector/test_routes.py +++ b/tests/test_collector/test_routes.py @@ -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